/**
* 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 crypto from 'hypercore-crypto'
import process from '#node:process'
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 LINKED_DEVICE_FILE = join(homedir(), '.pgh', 'linked-device.json')
/**
* Load or create the daemon's identity.
*
* Strategy:
* 1. If hyperident vault exists at <storage>/.hyperident/ → load from it
* 2. If legacy identity.json exists → load it (backward compat)
* 3. If neither → create new hyperident identity
* 4. 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 }>}
*/
export async function loadOrCreateIdentity (storagePath, opts = {}) {
if (!storagePath) throw new Error('loadOrCreateIdentity requires a storage path')
await mkdir(storagePath, { recursive: true })
// A pgh device-link payload at ~/.pgh/linked-device.json takes
// precedence WHEN THE CALLER OPTS IN (opts.useLinkedDevice === true).
// Opt-in to keep tests hermetic (they use fresh tmp storage but run
// on machines that may have a user-level linked-device file). The
// daemon entrypoint (server.js) turns this on when a mirror bundle
// is present.
if (opts.useLinkedDevice) {
const linked = await tryLoadLinkedDevice()
if (linked) return linked
}
// PGV3_USE_RAW_IDENTITY (env or opts) forces per-storage raw identity
// and skips hyperident entirely. The identity lives at
// <storage>/identity.json — one file, completely isolated from any
// user-level ~/.hyperident vault. Used by experimental daemons so
// multiple pgv3 instances can coexist on one host with distinct
// identities (and so each fresh storage dir = a fresh "device" for
// testing purposes).
if (opts.useRawIdentity || process.env.PGV3_USE_RAW_IDENTITY) {
const legacy = await tryLoadLegacy(storagePath)
if (legacy) return legacy
return createRawIdentity(storagePath)
}
// Try hyperident next
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)
}
}
async function tryLoadLinkedDevice () {
let text
try { text = await readFile(LINKED_DEVICE_FILE, '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.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,
rootPubkeyHex: data.rootPubkeyHex,
createdAt: data.createdAt || Date.now(),
source: 'linked',
label: data.label || null,
chain: data.chainB64 || null
}
}
/**
* Load or create identity via hyperident at its default vault location.
*/
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')
// rootPubkeyHex — the identity root pubkey (hex). Used by the intrinsic-
// owner check in auth: envelopes whose chain resolves to this root are
// treated as owner without any registry record required. Matches the
// pgh side's identity shape so the same field exists on every node.
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,
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'
}
} 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'
}
}