OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / identity.js

211 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 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'
  }
}