OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / identity.js

507 lines · javascript ref:
/**
 * Identity — user's root keypair with BIP-39 seed phrase backup.
 *
 * Phase 1 ticket 3 of the Holepunch alignment plan. Matches the Keet-style
 * identity model: one root keypair per user that can be exported as a seed
 * phrase and restored on a new device.
 *
 * Storage:
 *   - The entropy (raw 16/32 bytes for BIP-39 128/256-bit) is persisted at
 *     `<storagePath>/identity.json`. Not encrypted at rest by default — we're
 *     inside the app's sandbox — but SEC0 (see below) can wrap it in an
 *     argon2id+secretbox vault.
 *   - The derived keypair is ed25519 (matches noise-curve25519 / Hyperswarm
 *     keypair shape).
 *
 * BIP-39 layer (v2, 2026-07 — standards-compliant):
 *   - 256-bit entropy → 24-word mnemonic, via Holepunch's `bip39-mnemonic`
 *     (pure JS over sodium-universal, so it runs under Bare).
 *   - The seed is the REAL BIP-39 PBKDF2-HMAC-SHA512 output (not the pre-v2
 *     SHA-512(entropy) shortcut), so a phrase minted here restores in Keet,
 *     hardware wallets, and any BIP-39 tool. Portable by construction.
 *   - HARD CUTOVER from v1: the same phrase now derives DIFFERENT pubkeys than
 *     the old SHA-512 path, so pre-v2 identities must be regenerated.
 */

const fs = require('bare-fs')
const path = require('bare-path')
const crypto = require('bare-crypto')
const b4a = require('b4a')
const bip39 = require('bip39-mnemonic')

// sodium-universal is already in the dependency graph via hyperswarm /
// autobase. We use it for ed25519 detached signing so pages can sign
// arbitrary payloads with the user's root identity.
let sodium = null
try { sodium = require('sodium-universal') } catch (_) { /* optional */ }

// --- BIP-39 helpers (standards-compliant, via Holepunch bip39-mnemonic) ------
//
// Thin wrappers so the rest of the file (and external importers) keep the same
// names, but every call now routes through the canonical BIP-39 implementation
// used by Keet/keet-identity-key. Mnemonics interoperate with any BIP-39 tool.

function entropyToMnemonic (entropyBytes) {
  if (!b4a.isBuffer(entropyBytes) && !(entropyBytes instanceof Uint8Array)) {
    throw new TypeError('entropy must be a Buffer/Uint8Array')
  }
  return bip39.entropyToMnemonic(b4a.from(entropyBytes))
}

function mnemonicToEntropy (mnemonic) {
  if (typeof mnemonic !== 'string') throw new TypeError('mnemonic must be a string')
  // bip39-mnemonic normalizes + validates the checksum, throwing on a bad phrase.
  return b4a.from(bip39.mnemonicToEntropy(mnemonic))
}

function validateMnemonic (mnemonic) {
  try { return bip39.validateMnemonic(mnemonic) } catch (_) { return false }
}

/**
 * Derive the 32-byte ed25519 seed from BIP-39 entropy — the REAL BIP-39 path:
 * entropy → mnemonic → PBKDF2-HMAC-SHA512 → 64-byte seed, of which we take the
 * first 32 bytes as the ed25519 / Corestore-namespace seed.
 *
 * ASYNC (PBKDF2 is intentionally slow). Callers cache the result on this._seed
 * so getSeed()/getAppKeypair()/sign() stay synchronous once ready() resolves.
 */
async function entropyToSeed (entropyBytes) {
  const mnemonic = bip39.entropyToMnemonic(b4a.from(entropyBytes))
  const seed = await bip39.mnemonicToSeed(mnemonic)
  return b4a.from(seed).slice(0, 32)
}

// --- Identity manager ---

class Identity {
  constructor (storagePath) {
    if (!storagePath) throw new Error('Identity requires a storagePath')
    this.storagePath = storagePath
    this.file = path.join(storagePath, 'identity.json')
    this._entropy = null
    this._seed = null
    // SEC0 — seed-at-rest encryption. When the on-disk seed is an encrypted vault
    // the identity boots LOCKED (entropy unavailable until unlock(passphrase));
    // _passphrase is held in memory so persist/rotate can re-encrypt.
    this._passphrase = null
    this._locked = false
    this._encryptedVault = null
  }

  /**
   * Load or generate the user's identity.
   * On first run: generates fresh 16-byte entropy and persists.
   * On subsequent runs: loads the saved entropy.
   */
  async ready () {
    try {
      const raw = fs.readFileSync(this.file, 'utf-8')
      const data = JSON.parse(raw)
      // SEC0: an encrypted-at-rest vault — boot LOCKED, never regenerate (that
      // would destroy the user's seed). unlock(passphrase) loads it.
      if (data && data.v === 2 && data.enc === 'argon2id-secretbox') {
        this._encryptedVault = data
        this._locked = true
        return
      }
      if (data && typeof data.entropy === 'string') {
        this._entropy = b4a.from(data.entropy, 'hex')
        if (this._entropy.length !== 16 && this._entropy.length !== 32) {
          throw new Error('Persisted entropy has invalid length')
        }
        this._seed = await entropyToSeed(this._entropy)
        return
      }
    } catch (err) {
      if (err && err.code !== 'ENOENT') {
        console.warn('[Identity] load failed, regenerating:', err.message)
      }
    }
    // Generate fresh 256-bit entropy (24-word mnemonic — the Keet standard).
    this._entropy = crypto.randomBytes(32)
    this._seed = await entropyToSeed(this._entropy)
    this._persist()
  }

  _persist () {
    try {
      if (!fs.existsSync(this.storagePath)) fs.mkdirSync(this.storagePath, { recursive: true })
    } catch (_) {}
    let payload
    if (this._passphrase) {
      // SEC0: encrypt the seed at rest (argon2id + secretbox).
      const sv = require('./seed-vault.cjs')
      payload = { ...sv.encryptEntropy(this._entropy, this._passphrase), createdAt: Date.now() }
    } else {
      payload = { version: 1, entropy: b4a.toString(this._entropy, 'hex'), createdAt: Date.now() }
    }
    fs.writeFileSync(this.file, JSON.stringify(payload))
  }

  /** Is the on-disk seed encrypted (SEC0)? */
  isEncryptedAtRest () { return !!this._passphrase || !!this._encryptedVault }

  /** Is the identity locked (encrypted on disk, not yet unlocked this session)? */
  isLocked () { return !!this._locked }

  /**
   * SEC0 — encrypt the seed at rest under `passphrase`. The seed must already be
   * unlocked/loaded. Rewrites identity.json as an argon2id+secretbox vault and
   * holds the passphrase in memory so rotate/save re-encrypt.
   */
  encryptSeedAtRest (passphrase) {
    if (this._locked || !this._entropy) throw new Error('Identity must be unlocked to encrypt')
    if (typeof passphrase !== 'string' || passphrase.length === 0) throw new Error('passphrase required')
    this._passphrase = passphrase
    this._persist()
  }

  /**
   * Unlock an encrypted-at-rest identity with `passphrase`. Fails CLOSED (throws)
   * on the wrong passphrase. No-op if already unlocked.
   */
  async unlock (passphrase) {
    if (!this._locked) return true
    const sv = require('./seed-vault.cjs')
    const entropy = sv.decryptEntropy(this._encryptedVault, passphrase)
    if (entropy.length !== 16 && entropy.length !== 32) throw new Error('decrypted entropy has invalid length')
    this._entropy = entropy
    this._seed = await entropyToSeed(entropy)
    this._passphrase = passphrase
    this._locked = false
    this._encryptedVault = null
    return true
  }

  /** Raw seed bytes (32 bytes). Pass into Corestore.namespace(seed). */
  getSeed () {
    if (!this._seed) throw new Error('Identity not ready')
    return this._seed
  }

  /** Raw entropy bytes (16 or 32 bytes). */
  getEntropy () {
    if (!this._entropy) throw new Error('Identity not ready')
    return b4a.from(this._entropy)
  }

  /** 12-word (or 24-word) mnemonic backup phrase. */
  getMnemonic () {
    if (!this._entropy) throw new Error('Identity not ready')
    return entropyToMnemonic(this._entropy)
  }

  /** Public key derived from seed (placeholder — actual pubkey comes from Corestore). */
  getPublicKeyHex (corestore) {
    if (corestore && corestore.primaryKey) {
      return b4a.toString(corestore.primaryKey, 'hex')
    }
    // Fallback: hash of the seed
    return b4a.toString(crypto.createHash('sha256').update(this._seed).digest(), 'hex')
  }

  /**
   * Derive the ROOT ed25519 keypair from the seed. Cached. Returns
   * `{ publicKey: Buffer(32), secretKey: Buffer(64) }`.
   *
   * The root keypair NEVER leaves the worklet. Pages interact via
   * per-app sub-keypairs (see getAppKeypair()) — that's what they
   * see and sign with.
   */
  getSigningKeypair () {
    if (this._keypair) return this._keypair
    if (!sodium) throw new Error('sodium-universal not available — ed25519 signing disabled')
    if (!this._seed) throw new Error('Identity not ready')

    const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
    const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
    sodium.crypto_sign_seed_keypair(publicKey, secretKey, this._seed)
    this._keypair = { publicKey, secretKey }
    return this._keypair
  }

  /**
   * Derive a deterministic per-app ed25519 sub-keypair from the root
   * seed + drive key. Same user + same app = same pubkey, forever,
   * across all devices that share the root seed — but different apps
   * see DIFFERENT pubkeys for the same user, so apps can't correlate
   * users across each other without explicit consent.
   *
   * Derivation:  subSeed = SHA-256(rootSeed || "pear-app-v1:" || driveKey)
   *              (subPub, subPriv) = ed25519.seed_keypair(subSeed)
   *
   * `driveKeyHex` is the hex-encoded 32-byte Hyperdrive key of the app
   * as served by PearBrowser's proxy. For the "browser-itself" context
   * (e.g. Settings screen), pass the literal string "browser".
   *
   * Phase A of the identity plan — see docs/IDENTITY_PLAN.md.
   */
  getAppKeypair (driveKeyHex) {
    if (!sodium) throw new Error('sodium-universal not available')
    if (!this._seed) throw new Error('Identity not ready')
    if (typeof driveKeyHex !== 'string' || driveKeyHex.length === 0) {
      throw new Error('driveKeyHex must be a non-empty string')
    }
    if (!this._appKeypairs) this._appKeypairs = new Map()
    const cached = this._appKeypairs.get(driveKeyHex)
    if (cached) return cached

    const hash = crypto.createHash('sha256')
    hash.update(this._seed)
    hash.update('pear-app-v1:')
    hash.update(driveKeyHex)
    const subSeed = hash.digest()

    const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
    const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
    sodium.crypto_sign_seed_keypair(publicKey, secretKey, subSeed)

    const keypair = { publicKey, secretKey, driveKeyHex }
    this._appKeypairs.set(driveKeyHex, keypair)
    return keypair
  }

  /**
   * Derive a deterministic ed25519 sub-keypair under the root seed for a domain
   * `tag` + `input`:  subSeed = SHA-256(rootSeed || tag || input). Domain-
   * separated so the SAME input under DIFFERENT tags (app / invoice / session)
   * yields UNLINKABLE keys. Pure: same seed + tag + input ⇒ same keypair on every
   * device, forever; the root secret never leaves the worklet.
   */
  _deriveSubkey (tag, input) {
    if (!sodium) throw new Error('sodium-universal not available')
    if (!this._seed) throw new Error('Identity not ready')
    if (typeof input !== 'string' || input.length === 0) throw new Error('input must be a non-empty string')
    const hash = crypto.createHash('sha256')
    hash.update(this._seed)
    hash.update(tag)
    hash.update(input)
    const subSeed = hash.digest()
    const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
    const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
    sodium.crypto_sign_seed_keypair(publicKey, secretKey, subSeed)
    return { publicKey, secretKey }
  }

  /**
   * Per-invoice keypair (Payments / Privacy-routing PRIV0). Deterministic from
   * the sale nonce so buyer + seller can both re-derive it for the same sale, yet
   * UNLINKABLE across invoices and from the root/app keys. Zero network cost.
   */
  getInvoiceKeypair (saleNonce) {
    return { ...this._deriveSubkey('pear-invoice-v1:', String(saleNonce == null ? '' : saleNonce)), saleNonce }
  }

  /**
   * Per-session keypair (Nostr / PRIV0). Deterministic from a session salt the
   * caller picks at random — session isolation + unlinkability across sessions,
   * recoverable within one.
   */
  getSessionKeypair (sessionSalt) {
    return { ...this._deriveSubkey('pear-session-v1:', String(sessionSalt == null ? '' : sessionSalt)), sessionSalt }
  }

  // --- Nostr (secp256k1 / BIP-340) — NOSTR0 -----------------------------------
  // Derived deterministically from the root seed via the Bare-loadable
  // secp256k1-bundle.cjs (SPIKE-SCHNORR-BARE). The nsec (secret) stays SEALED in
  // here — callers only get the public key + sign/verify, never the secret.

  _nostrSecretHex () {
    if (!this._seed) throw new Error('Identity not ready')
    const hash = crypto.createHash('sha256')
    hash.update(this._seed)
    hash.update('pear-nostr-v1:')
    return b4a.toString(hash.digest(), 'hex')
  }

  /** The user's Nostr x-only public key (hex). Stable across devices/restores. */
  getNostrPublicKey () {
    return require('./secp256k1-bundle.cjs').schnorrGetPublicKey(this._nostrSecretHex())
  }

  /** Schnorr-sign a 32-byte message hash with the Nostr key. We supply aux
   *  randomness from bare-crypto (the bundle's default uses a WebCrypto global
   *  that isn't guaranteed under Bare). */
  nostrSign (msg32Hex, auxRandHex) {
    const aux = auxRandHex || b4a.toString(crypto.randomBytes(32), 'hex')
    return require('./secp256k1-bundle.cjs').schnorrSign(msg32Hex, this._nostrSecretHex(), aux)
  }

  /** Verify ANY party's Nostr (BIP-340) signature — pure, needs only the pubkey. */
  nostrVerify (sigHex, msg32Hex, pubkeyHex) {
    return require('./secp256k1-bundle.cjs').schnorrVerify(sigHex, msg32Hex, pubkeyHex)
  }

  /** Sign a NIP-01 event { kind, created_at, tags, content } with our Nostr key —
   *  fills pubkey, computes the event id, attaches the signature. */
  nostrSignEvent (ev, auxRandHex) {
    const secp = require('./secp256k1-bundle.cjs')
    const aux = auxRandHex || b4a.toString(crypto.randomBytes(32), 'hex')
    return secp.nip01Sign({ ...ev, pubkey: this.getNostrPublicKey() }, this._nostrSecretHex(), aux)
  }

  /** NOSTR2 — mint a cross-curve binding linking our pear root ↔ our Nostr key,
   *  signed by BOTH (ed25519 root + secp256k1 nostr). `epoch` is monotonic. */
  makeNostrBinding (epoch, auxRandHex) {
    const nb = require('./nostr-bind.cjs')
    const rootPubkey = b4a.toString(this.getSigningKeypair().publicKey, 'hex')
    return nb.makeNostrBind(
      { rootPubkey, nostrPubkey: this.getNostrPublicKey(), epoch },
      (msg) => this.sign(msg).signature, // ed25519 root attestation
      (msg32Hex) => this.nostrSign(msg32Hex, auxRandHex) // secp256k1 nostr attestation
    )
  }

  /** Verify a peer's cross-curve binding against their (Contacts-held) root. */
  verifyNostrBinding (bind, expectedRootPubkey) {
    return require('./nostr-bind.cjs').verifyNostrBind(bind, expectedRootPubkey)
  }

  /** NOSTR2 — mint a ROOT-signed revocation of the binding at `epoch` (you can
   *  unilaterally unlink from your pear side; revoke-wins by epoch). Root-signed
   *  only — no nostr counter-signature needed to unlink. */
  makeNostrRevocation (epoch) {
    const nb = require('./nostr-bind.cjs')
    const rootPubkey = b4a.toString(this.getSigningKeypair().publicKey, 'hex')
    return nb.makeNostrRevoke(
      { rootPubkey, nostrPubkey: this.getNostrPublicKey(), epoch },
      (msg) => this.sign(msg).signature
    )
  }

  /**
   * Sign with the per-app sub-key (not the root). Safe to expose to
   * pages — the root remains sealed inside the worklet.
   *
   * `driveKeyHex` scopes the signing keypair; the payload is also
   * wrapped with a domain-separator tag so one app's signature can't
   * be replayed in another app's context.
   */
  signForApp (driveKeyHex, payload, namespace = '') {
    const { publicKey, secretKey } = this.getAppKeypair(driveKeyHex)
    const tag = `pear.app.${driveKeyHex}:${namespace}:`
    const message = b4a.concat([
      b4a.from(tag, 'utf-8'),
      typeof payload === 'string' ? b4a.from(payload, 'utf-8') : b4a.from(payload || []),
    ])
    if (message.length === 0) throw new Error('payload must be non-empty')
    if (message.length > 64 * 1024) throw new Error('payload too large (>64KB)')

    const signature = b4a.alloc(sodium.crypto_sign_BYTES)
    sodium.crypto_sign_detached(signature, message, secretKey)
    return {
      signature: b4a.toString(signature, 'hex'),
      publicKey: b4a.toString(publicKey, 'hex'),
      algorithm: 'ed25519',
      tag,
    }
  }

  /**
   * Sign a binary or UTF-8 string payload with the user's root keypair.
   * Returns `{ signature: <hex>, publicKey: <hex>, algorithm: 'ed25519' }`.
   *
   * NOTE: Pages that call `window.pear.identity.sign(data)` get this
   * signature back. Validate the payload namespace on the caller side
   * so a malicious page can't trick a user into signing on their behalf
   * for a different app. A future hardening step can prefix-wrap the
   * payload with a domain separator tag (e.g. `PEAR-APP-<driveKey>:`).
   */
  sign (payload) {
    const { publicKey, secretKey } = this.getSigningKeypair()
    const message = typeof payload === 'string'
      ? b4a.from(payload, 'utf-8')
      : b4a.from(payload || [])
    if (message.length === 0) throw new Error('payload must be non-empty')
    if (message.length > 64 * 1024) throw new Error('payload too large (>64KB)')

    const signature = b4a.alloc(sodium.crypto_sign_BYTES)
    sodium.crypto_sign_detached(signature, message, secretKey)
    return {
      signature: b4a.toString(signature, 'hex'),
      publicKey: b4a.toString(publicKey, 'hex'),
      algorithm: 'ed25519'
    }
  }

  /**
   * Verify an Ed25519 detached signature over a payload — the counterpart to
   * sign(). PURE: needs only the public key, never the root seed, so it can
   * verify ANY party's signature, not just our own. This is the anti-forgery
   * gate every signed-record consumer (naming, payments, nostr, routing) in
   * docs/research/ depends on (Phase P0 of IMPLEMENTATION-PLAN.md).
   *
   * Returns a boolean. Never throws on malformed / attacker-controlled input
   * (fail-closed → false); throws only if the crypto backend is missing, the
   * same hard-environment failure sign() raises.
   */
  verify (payload, signatureHex, publicKeyHex) {
    if (!sodium) throw new Error('sodium-universal not available — ed25519 verify disabled')
    try {
      const signature = b4a.from(String(signatureHex), 'hex')
      const publicKey = b4a.from(String(publicKeyHex), 'hex')
      if (signature.length !== sodium.crypto_sign_BYTES) return false
      if (publicKey.length !== sodium.crypto_sign_PUBLICKEYBYTES) return false
      const message = typeof payload === 'string' ? b4a.from(payload, 'utf-8') : b4a.from(payload || [])
      if (message.length === 0) return false
      return sodium.crypto_sign_verify_detached(signature, message, publicKey)
    } catch {
      return false
    }
  }

  /**
   * Verify a per-app signature produced by signForApp(). Reconstructs the
   * `pear.app.<driveKey>:<namespace>:` domain-separator tag (signForApp():264)
   * before verifying, so a signature minted for one (app, namespace) can't be
   * replayed in another. `signed` is the { signature, publicKey } shape
   * signForApp() returns.
   *
   * NOTE: this confirms the signature is valid for the PROVIDED publicKey; the
   * caller must still bind that pubkey to an expected identity (see the
   * IdentityBinding record in the naming/payments designs) to prevent a
   * forger from simply presenting their own key.
   */
  verifyForApp (driveKeyHex, payload, namespace = '', signed = {}) {
    const tag = `pear.app.${driveKeyHex}:${namespace}:`
    const message = b4a.concat([
      b4a.from(tag, 'utf-8'),
      typeof payload === 'string' ? b4a.from(payload, 'utf-8') : b4a.from(payload || []),
    ])
    return this.verify(message, signed.signature, signed.publicKey)
  }

  /**
   * Replace the current identity with one derived from a user-provided phrase.
   * The caller is responsible for closing the current Corestore and reopening
   * a new one after this — data stored under the old identity is NOT migrated.
   */
  async restoreFromMnemonic (mnemonic) {
    const entropy = mnemonicToEntropy(mnemonic)
    this._entropy = entropy
    this._seed = await entropyToSeed(entropy)
    this._persist()
  }

  /**
   * Generate a fresh identity and replace the persisted one.
   * Same caveat as restoreFromMnemonic — data from old identity is orphaned.
   */
  async rotate () {
    this._entropy = crypto.randomBytes(32)
    this._seed = await entropyToSeed(this._entropy)
    this._persist()
  }
}

module.exports = {
  Identity,
  entropyToMnemonic,
  mnemonicToEntropy,
  validateMnemonic,
  entropyToSeed, // NOTE: now async (standards-compliant BIP-39 PBKDF2)
  mnemonicToSeed: bip39.mnemonicToSeed,
  generateMnemonic: bip39.generateMnemonic,
}