OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / identity.js

276 lines · javascript ref:
/**
 * 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
  }
}