/**
* Service identity layer — backed by hyperident.
*
* Uses the hyperident IdentityManager at its DEFAULT vault location
* (~/.hyperident/). Identity is a user-level concern, not a daemon-
* instance concern — the same identity persists across daemon storage
* migrations, across reinstalls, and is shared with other apps that
* use hyperident (wirecore, hyper-social-web, future pgh).
*
* The daemon's storage directory holds corestore + overlay data; the
* keypair that signs and advertises on swarm comes from the user's
* hyperident vault.
*
* Falls back to raw Ed25519 keypair if hyperident is not available or
* if an existing identity.json (pre-hyperident) is found.
*/
import process from '#node:process'
import crypto from 'hypercore-crypto'
import { readFile, writeFile, mkdir, chmod } from '#node:fs/promises'
import { join } from '#node:path'
import { homedir } from '#node:os'
import b4a from 'b4a'
const IDENTITY_FILE = 'identity.json'
const DEFAULT_LINKED_DEVICE_FILE = join(homedir(), '.pgh', 'linked-device.json')
// Linked-device file path. Env-overridable so tests can run with their
// own throwaway file location and not pick up the real-machine state
// at ~/.pgh/linked-device.json — that hardcoded path was leaking into
// the identity-chain test, making the hyperident-only setup return
// source=linked instead of source=hyperident.
//
// Read dynamically each call (not via a module-init constant) so a
// test that sets the env var AFTER importing the module still takes
// effect.
export function getLinkedDeviceFile () {
return process.env.PGH_LINKED_DEVICE_FILE || DEFAULT_LINKED_DEVICE_FILE
}
// Back-compat: callers that read `LINKED_DEVICE_FILE` as a constant
// still get the default path. New call sites should prefer
// getLinkedDeviceFile() so the env override applies.
export const LINKED_DEVICE_FILE = DEFAULT_LINKED_DEVICE_FILE
/**
* Load or create the daemon's identity.
*
* Strategy:
* 1. If a linked-device.json is present (Phase 2c) → use the linked
* device keypair + its attached chain. This takes precedence over
* hyperident because a linked device is the explicit choice of "I
* am a secondary device, don't create a new identity here."
* 2. If hyperident vault exists → load from it
* 3. If legacy identity.json exists → load it (backward compat)
* 4. If neither → create new hyperident identity
* 5. If hyperident fails → fall back to raw keypair
*
* @param {string} storagePath - the daemon's storage directory
* @returns {Promise<{ publicKey: Buffer, secretKey: Buffer, publicKeyHex: string, createdAt: number, source: string, chain: string|null, rootPubkeyHex: string|null }>}
*/
export async function loadOrCreateIdentity (storagePath) {
if (!storagePath) throw new Error('loadOrCreateIdentity requires a storage path')
await mkdir(storagePath, { recursive: true })
// Phase 2c — a pgh device-linker payload, if present, takes precedence.
const linked = await tryLoadLinkedDevice()
if (linked) return linked
// Try hyperident first
try {
return await loadOrCreateHyperident(storagePath)
} catch (err) {
// hyperident not available or failed — try legacy, then raw
const legacy = await tryLoadLegacy(storagePath)
if (legacy) return legacy
// Fall back to raw keypair
return createRawIdentity(storagePath)
}
}
/**
* The identity's ROOT pubkey as hex — the anchor that survives device
* rotation, and the thing a NameRegistry claim or a release signature should
* be bound to.
*
* Every loader path exposes `rootPubkeyHex`; only the hyperident path also
* exposes `masterPublicKey` (as a Buffer or hex string, historically). Callers
* that consulted `masterPublicKey` alone silently fell through to the DEVICE
* key on a linked device, which is precisely the key the anchor exists to
* outlive.
*
* Falls back to the device pubkey only when there is no root at all (the raw
* / legacy identities, which are their own root).
*
* @param {object|null} identity - as returned by loadOrCreateIdentity
* @returns {string|null} 64-char hex, or null if there is no identity
*/
export function identityRootHex (identity) {
if (!identity) return null
// Canonical: every loader that HAS a root exposes it here (hyperident sets
// it from identity.publicKey, linked-device from the joined payload). This
// is the same field `pgh identity show` has always used.
if (identity.rootPubkeyHex) return identity.rootPubkeyHex
// hyperident also carries it as masterPublicKey, Buffer or hex.
const master = identity.masterPublicKey
if (master) return Buffer.isBuffer(master) ? b4a.toString(master, 'hex') : master
return identity.publicKeyHex ||
(identity.publicKey ? b4a.toString(identity.publicKey, 'hex') : null)
}
/**
* Load a linked-device payload written by `pgh device join`. Shape:
* {
* version: 1,
* label,
* rootPubkeyHex,
* devicePublicKeyHex,
* deviceSecretKeyHex,
* chainB64,
* createdAt
* }
*/
async function tryLoadLinkedDevice () {
let text
try { text = await readFile(getLinkedDeviceFile(), 'utf8') } catch { return null }
let data
try { data = JSON.parse(text) } catch { return null }
if (!data || data.version !== 1) return null
if (!data.devicePublicKeyHex || !data.deviceSecretKeyHex || !data.chainB64 || !data.rootPubkeyHex) {
return null
}
const publicKey = b4a.from(data.devicePublicKeyHex, 'hex')
const secretKey = b4a.from(data.deviceSecretKeyHex, 'hex')
if (publicKey.length !== 32 || secretKey.length !== 64) return null
return {
publicKey,
secretKey,
publicKeyHex: data.devicePublicKeyHex,
createdAt: data.createdAt || Date.now(),
source: 'linked',
rootPubkeyHex: data.rootPubkeyHex,
chain: data.chainB64,
label: data.label || null
}
}
/**
* Load or create identity via hyperident at its default vault location.
*
* Returns the device keypair for signing AND — when an attestation
* chain is present — the chain encoded as base64 plus the root
* identity pubkey. The chain is what pear-git-v3's envelope verifier
* (Phase 2b) uses to resolve a device signature back to its identity.
*
* Shape of the returned `chain`:
* base64 of the Buffer returned by IdentityKey.bootstrap() /
* attestDevice() — ready to embed verbatim in a signed envelope's
* `chain` field.
*/
async function loadOrCreateHyperident (storagePath) {
const { IdentityManager } = await import('hyperident/lib/identity-manager.js')
// Do NOT override FileManager.setBasePath — hyperident's default
// (~/.hyperident/) is user-level identity, shared across daemon
// storage migrations and across other apps using hyperident.
let identity
try {
identity = await IdentityManager.loadIdentity()
} catch {
// No identity yet — generate one
identity = await IdentityManager.generateIdentity(null, 'pear-git-v3')
// Reload to get the stored form
identity = await IdentityManager.loadIdentity()
}
// Extract the device keypair for signing
const deviceChain = await IdentityManager.loadDeviceChain()
if (!deviceChain || !deviceChain.keyPair) {
throw new Error('hyperident identity has no device keypair')
}
const publicKey = Buffer.isBuffer(deviceChain.keyPair.publicKey)
? deviceChain.keyPair.publicKey
: b4a.from(deviceChain.keyPair.publicKey, 'hex')
const secretKey = Buffer.isBuffer(deviceChain.keyPair.secretKey)
? deviceChain.keyPair.secretKey
: b4a.from(deviceChain.keyPair.secretKey, 'hex')
// Attestation chain + root identity pubkey — required by envelope
// verifiers (Phase 2b). Chain is the full ProofEncoding buffer that
// keet-identity-key's IdentityKey.verify() consumes.
const proof = deviceChain.proof || null
const chainB64 = proof ? b4a.toString(proof, 'base64') : null
const rootPubkeyHex = identity.publicKey
? (typeof identity.publicKey === 'string'
? identity.publicKey
: b4a.toString(identity.publicKey, 'hex'))
: null
return {
publicKey,
secretKey,
publicKeyHex: b4a.toString(publicKey, 'hex'),
createdAt: identity.created ? new Date(identity.created).getTime() : Date.now(),
source: 'hyperident',
masterPublicKey: identity.publicKey || null,
rootPubkeyHex,
chain: chainB64,
mnemonic: false // never expose mnemonic
}
}
/**
* Try to load a legacy identity.json (pre-hyperident format).
*/
async function tryLoadLegacy (storagePath) {
const file = join(storagePath, IDENTITY_FILE)
try {
const text = await readFile(file, 'utf8')
const data = JSON.parse(text)
const publicKey = b4a.from(data.publicKey, 'hex')
const secretKey = b4a.from(data.secretKey, 'hex')
if (publicKey.length !== 32 || secretKey.length !== 64) {
return null
}
return {
publicKey,
secretKey,
publicKeyHex: b4a.toString(publicKey, 'hex'),
createdAt: data.createdAt,
source: 'legacy',
rootPubkeyHex: null,
chain: null
}
} catch {
return null
}
}
/**
* Create a raw Ed25519 keypair as final fallback.
*/
async function createRawIdentity (storagePath) {
const file = join(storagePath, IDENTITY_FILE)
const kp = crypto.keyPair()
const createdAt = Date.now()
const payload = JSON.stringify({
publicKey: b4a.toString(kp.publicKey, 'hex'),
secretKey: b4a.toString(kp.secretKey, 'hex'),
createdAt
}, null, 2) + '\n'
await writeFile(file, payload, { mode: 0o600 })
try { await chmod(file, 0o600) } catch {}
return {
publicKey: kp.publicKey,
secretKey: kp.secretKey,
publicKeyHex: b4a.toString(kp.publicKey, 'hex'),
createdAt,
source: 'raw',
rootPubkeyHex: null,
chain: null
}
}