OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / envelope.js

231 lines · javascript ref:
/**
 * Signed RPC envelope verification (Phase 2a).
 *
 * Every mutating RPC is a canonical anproto signed envelope:
 *
 *   {
 *     payload: <JSON bytes of { method, params, ts, nonce }>,
 *     sig:     <anproto envelope: devicePubkey[44] + sign(ts+hash(payload))>,
 *     chain?:  <optional keet device-chain attestation (Phase 2b)>
 *   }
 *
 * Server verification path:
 *   1. Extract devicePubkey from sig[0..44]
 *   2. an.open(sig) — verify signature math + return embedded ts+hash
 *   3. SHA256(payload) === embedded hash — payload not tampered
 *   4. ts within 5-minute freshness window
 *   5. (devicePubkey, payload.nonce) not in replay cache
 *   6. If chain present (Phase 2b): verify chain → root identity pubkey
 *      Else: root = devicePubkey (self-rooted)
 *   7. Caller looks up root in IdentityRegistry → userId + permissions
 *
 * Caller injects ctx.identity = { userId, root, device: devicePubkey,
 * method: chain ? 'keet' : 'anproto' }.
 *
 * Wire format rationale:
 *   - payload is raw bytes, not a parsed object, so the server hashes
 *     exactly what the client signed (no canonical-JSON gotchas)
 *   - client is responsible for making the payload stable; server
 *     parses it only AFTER signature passes
 */

import { an } from 'anproto'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

export const FRESHNESS_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
export const PUBKEY_B64_LEN = 44

export class EnvelopeError extends Error {
  constructor (message, code) {
    super(message)
    this.name = 'EnvelopeError'
    this.code = code || 'ENVELOPE_INVALID'
    this.status = 401
  }
}

/**
 * Verify a signed envelope.
 *
 * @param {object} envelope  - { payload: string, sig: string, chain?: string }
 * @param {object} opts
 *   opts.replayCache  — object with .seen(pubkey, nonce) → bool, .add(pubkey, nonce, ts)
 *   opts.now          — Date.now() override for tests
 *   opts.freshnessMs  — window override (default 5 min)
 *   opts.keetVerifier — (chain, devicePubkey) → { rootPubkey } for Phase 2b
 * @returns {object}
 *   {
 *     devicePubkey: 'hex',
 *     rootPubkey:   'hex',   // equal to devicePubkey if no chain
 *     method: 'anproto' | 'keet',
 *     decoded: { method, params, ts, nonce }   // parsed payload
 *   }
 * @throws {EnvelopeError}
 */
export async function verifyEnvelope (envelope, opts = {}) {
  if (!envelope || typeof envelope !== 'object') {
    throw new EnvelopeError('envelope missing', 'NO_ENVELOPE')
  }

  const { payload, sig, chain } = envelope
  if (typeof payload !== 'string' || payload.length === 0) {
    throw new EnvelopeError('payload must be a non-empty string', 'BAD_PAYLOAD')
  }
  if (typeof sig !== 'string' || sig.length < PUBKEY_B64_LEN + 8) {
    throw new EnvelopeError('sig missing or malformed', 'BAD_SIG')
  }

  const now = opts.now || Date.now()
  const freshness = opts.freshnessMs || FRESHNESS_WINDOW_MS

  // 1. Extract device pubkey (first 44 chars of the canonical anproto sig).
  const devicePubkeyB64 = sig.slice(0, PUBKEY_B64_LEN)

  // 2. an.open() verifies signature math and returns "{timestamp}{hash}".
  //    Throws if the signature doesn't check out.
  let opened
  try {
    opened = await an.open(sig)
  } catch (err) {
    throw new EnvelopeError('signature verify failed: ' + err.message, 'SIG_INVALID')
  }
  if (!opened) {
    throw new EnvelopeError('signature did not verify', 'SIG_INVALID')
  }

  // 3. an.parse() splits opened into { timestamp, hash }.
  let parsed
  try {
    parsed = an.parse(opened)
  } catch (err) {
    throw new EnvelopeError('could not parse opened envelope: ' + err.message, 'PARSE')
  }
  if (!parsed || typeof parsed.timestamp !== 'number' || typeof parsed.hash !== 'string') {
    throw new EnvelopeError('opened envelope missing timestamp/hash', 'PARSE')
  }

  // 4. Freshness window — reject signatures older than freshnessMs or from
  //    the future (clock-skew tolerance = freshness window itself).
  if (parsed.timestamp > now + freshness) {
    throw new EnvelopeError('envelope ts in future', 'TS_FUTURE')
  }
  if (parsed.timestamp < now - freshness) {
    throw new EnvelopeError('envelope ts too old', 'TS_OLD')
  }

  // 5. Payload integrity — hash what the client sent, match against the
  //    signed hash.
  const actualHash = await an.hash(payload)
  if (actualHash !== parsed.hash) {
    throw new EnvelopeError('payload hash mismatch', 'HASH_MISMATCH')
  }

  // 6. Parse the JSON payload. After this point we trust payload.method
  //    and payload.params — they came from a signed envelope.
  let decoded
  try {
    decoded = JSON.parse(payload)
  } catch (err) {
    throw new EnvelopeError('payload is not valid JSON: ' + err.message, 'PAYLOAD_JSON')
  }
  if (!decoded || typeof decoded.method !== 'string') {
    throw new EnvelopeError('payload.method required', 'NO_METHOD')
  }
  if (typeof decoded.nonce !== 'string' || decoded.nonce.length < 8) {
    throw new EnvelopeError('payload.nonce required (>= 8 chars)', 'NO_NONCE')
  }

  // 7. Replay protection — same nonce seen recently from this pubkey → reject.
  if (opts.replayCache) {
    if (opts.replayCache.seen(devicePubkeyB64, decoded.nonce)) {
      throw new EnvelopeError('nonce replayed', 'NONCE_REPLAY')
    }
    opts.replayCache.add(devicePubkeyB64, decoded.nonce, parsed.timestamp)
  }

  // 8. Chain resolution (Phase 2b hook) — if chain present, the device
  //    pubkey must be a device on a chain that terminates at a root.
  //    Phase 2a ships the hook point; actual keet verification wires in 2b.
  let rootPubkey = devicePubkeyB64
  let method = 'anproto'
  if (chain) {
    if (!opts.keetVerifier) {
      throw new EnvelopeError('chain provided but no keetVerifier configured', 'NO_KEET_VERIFIER')
    }
    let chainResult
    try {
      chainResult = opts.keetVerifier(chain, devicePubkeyB64)
    } catch (err) {
      throw new EnvelopeError('chain verify failed: ' + err.message, 'CHAIN_INVALID')
    }
    if (!chainResult || !chainResult.rootPubkey) {
      throw new EnvelopeError('chain result missing rootPubkey', 'CHAIN_NO_ROOT')
    }
    rootPubkey = chainResult.rootPubkey
    method = 'keet'
  }

  return {
    devicePubkey: b64ToHex(devicePubkeyB64),
    rootPubkey: method === 'keet' ? rootPubkey : b64ToHex(rootPubkey),
    method,
    decoded
  }
}

// ====== HELPERS ======

function b64ToHex (b64) {
  try {
    return b4a.toString(b4a.from(b64, 'base64'), 'hex')
  } catch {
    return b64
  }
}

/**
 * Convenience: generate a fresh envelope for test fixtures.
 *
 * @param {object} opts
 *   opts.keypair  — canonical anproto keypair (from an.gen())
 *   opts.method   — RPC method name
 *   opts.params   — object, serialized as part of payload
 *   opts.nonce    — optional; generated if omitted
 *   opts.ts       — optional; Date.now() if omitted
 * @returns { payload, sig }
 */
export async function signTestEnvelope (opts) {
  const keypair = opts.keypair
  if (!keypair) throw new Error('signTestEnvelope requires a keypair')

  // ts is generated by anproto at sign-time (embedded in the signed
  // envelope). For test control we can override it by monkey-patching
  // Date.now — but for determinism we just let anproto stamp.
  const nonce = opts.nonce || b4a.toString(crypto.randomBytes(8), 'hex')

  const payloadObj = {
    method: opts.method,
    params: opts.params || {},
    nonce
  }
  const payload = JSON.stringify(payloadObj)
  const hash = await an.hash(payload)

  // If caller wanted a specific ts, temporarily pin Date.now.
  let sig
  if (opts.ts != null) {
    const origNow = Date.now
    Date.now = () => opts.ts
    try {
      sig = await an.sign(hash, keypair)
    } finally {
      Date.now = origNow
    }
  } else {
    sig = await an.sign(hash, keypair)
  }

  return { payload, sig }
}