OverviewFilesCommitsIssuesDocsReleasesPeers

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

216 lines · javascript ref:
/**
 * Transport-agnostic signed-envelope → ctx verification.
 *
 * Extracted from the /api/rpc HTTP handler (router.js) so the SAME
 * envelope-auth pipeline can back both HTTP and the swarm `verified-dispatch`
 * RPC. The shared ReplayCache + keet-verifier singletons live HERE so replay
 * protection spans every transport — a nonce burned over HTTP cannot be
 * replayed over the swarm and vice versa.
 *
 * What this owns (transport-agnostic auth):
 *   verifyEnvelope → revocation → lastSeenAt → user resolution + TOFU
 *   (owner gating via !svc._origin) → device registration → ctx.identity.
 *
 * What the CALLER keeps (transport-specific side effects):
 *   - building `origin` (HTTP headers vs swarm remotePublicKey)
 *   - becameOwner-gated SSH auto-import (HTTP owner convenience only)
 *   - session-cookie minting (HTTP/SSR only)
 *   - the actual dispatch + response framing
 */

import { verifyEnvelope, EnvelopeError } from './envelope.js'
import { ReplayCache } from './replay-cache.js'
import { createKeetVerifier } from './keet-verifier.js'

// Per-daemon replay cache for signed envelopes — shared across transports.
// One instance for the daemon's lifetime.
let __replayCache = null
export function getReplayCache () {
  if (!__replayCache) __replayCache = new ReplayCache()
  return __replayCache
}

// Keet device-chain verifier. Stateless, safe to share across requests.
// When an envelope carries a `chain`, resolves the device sig back to the
// root identity pubkey.
let __keetVerifier = null
export function getKeetVerifier () {
  if (!__keetVerifier) __keetVerifier = createKeetVerifier()
  return __keetVerifier
}

/**
 * Verify a signed envelope and build the authenticated ctx.
 *
 * @param {object} svc       — the running service (identityRegistry, identity, _origin…)
 * @param {object} envelope  — parsed { payload, sig, chain? }
 * @param {object} [opts]
 *   opts.origin    — { host, userAgent, remoteAddress } stamped onto new
 *                    Identity records + ctx.origin. Caller builds it.
 *   opts.transport — ctx.transport label (e.g. 'http', 'swarm-rpc').
 * @returns {Promise<{ ctx, verified, user, becameOwner }>}
 * @throws {EnvelopeError} on verify failure or revoked device (status/code set)
 */
export async function verifyEnvelopeToCtx (svc, envelope, opts = {}) {
  const origin = opts.origin || { host: null, userAgent: null, remoteAddress: null }
  const transport = opts.transport || 'unknown'

  const verified = await verifyEnvelope(envelope, {
    replayCache: getReplayCache(),
    keetVerifier: getKeetVerifier()
  })

  // Reject revoked devices before any further processing. isRevoked failures
  // are non-fatal (registry not ready) and fall through; an actual revocation
  // throws EREVOKED.
  if (svc.identityRegistry.db) {
    try {
      const isRevoked = await svc.identityRegistry.isRevoked(verified.rootPubkey)
      if (isRevoked) throw new EnvelopeError('device has been revoked', 'EREVOKED')
      // For chain-bearing envelopes, also check device-level revocation.
      if (verified.method === 'keet' && verified.devicePubkey !== verified.rootPubkey) {
        const deviceRevoked = await svc.identityRegistry.isRevoked(verified.devicePubkey)
        if (deviceRevoked) throw new EnvelopeError('device has been revoked', 'EREVOKED')
      }
    } catch (err) {
      if (err instanceof EnvelopeError) throw err
      // isRevoked itself failed — non-fatal, continue.
    }
  }

  // Stamp lastSeenAt on the authenticating identity. Fire-and-forget — don't
  // block the RPC on a metadata write.
  if (svc.identityRegistry.db) {
    const stampKey = verified.method === 'keet' ? verified.devicePubkey : verified.rootPubkey
    svc.identityRegistry.getIdentity(stampKey).then(id => {
      if (id) {
        id.lastSeenAt = Date.now()
        svc.identityRegistry.db.put(`identity:${stampKey}`, id).catch(() => {})
      }
    }).catch(() => {})
  }

  // Resolve root pubkey → userId via IdentityRegistry. TOFU: first user
  // becomes owner. Subsequent users default to read-only; grant-permission
  // upgrades them.
  let user = null
  try {
    user = await svc.identityRegistry.getUserByIdentity(verified.rootPubkey)
  } catch {}

  let becameOwner = false
  if (!user) {
    // A miss might be a STALE VIEW — the registry autobase hadn't replicated
    // / caught up yet (right after a daemon restart or an identity re-issue),
    // NOT a genuinely new key. TOFU-creating now would mint a fresh read-only
    // user AND remap this key to it, silently downgrading a KNOWN user to a
    // new identity ("logged in and got a different, read-only identity").
    // Pull the latest replicated state and re-check before treating the key
    // as unknown. A known key is found here and never remapped; a truly-absent
    // key falls through to the create/pending branches below exactly once.
    try { await svc.identityRegistry.base.update() } catch {}
    try {
      user = await svc.identityRegistry.getUserByIdentity(verified.rootPubkey)
    } catch {}
  }

  if (!user) {
    // When the IdentityRegistry autobase isn't writable on this peer
    // (device-invite ADD_WRITER hasn't replicated in yet), we can't
    // TOFU-create a user. Synthesize a read-only ephemeral user bound to the
    // envelope's root — enough to render pages, but any write handler still
    // rejects via requireWriter because globalPermissions is just ['read'].
    if (!svc.identityRegistry.base.writable) {
      user = {
        userId: 'pending-' + verified.rootPubkey.slice(0, 12),
        name: 'Pending Writer',
        globalPermissions: ['read']
      }
    } else {
      // TOFU admin gating: a daemon with an origin configured (peer that's
      // replicating another peer's IdentityRegistry) must NOT create a
      // spurious local admin on first boot. It waits for IdentityRegistry
      // replication to bring in the primary's users. Only standalone daemons
      // (no origin) give the first user admin perms.
      const allUsers = await svc.identityRegistry.listUsers().catch(() => [])
      const isFirst = allUsers.length === 0 && !svc._origin
      user = await svc.identityRegistry.createUser({
        name: 'Anonymous',
        globalPermissions: isFirst ? ['admin', 'write', 'read'] : ['read']
      })
      await svc.identityRegistry.registerIdentity({
        identityId: verified.rootPubkey,
        method: verified.method,
        publicKey: verified.rootPubkey,
        credentials: { publicKey: verified.rootPubkey },
        userId: user.userId,
        createdAt: Date.now(),
        firstSeenOrigin: origin.host || null,
        firstSeenRemoteAddress: origin.remoteAddress || null,
        firstSeenUserAgent: origin.userAgent || null,
        lastSeenAt: Date.now()
      })
      await svc.identityRegistry.linkIdentityToUser(
        verified.rootPubkey, user.userId, { verified: true }
      )
      becameOwner = isFirst
    }
  }

  // Register the DEVICE pubkey as a secondary identity when a chain-bearing
  // envelope arrives from a linked device. Skip when the registry isn't
  // writable (same rationale as the TOFU skip — ADD_WRITER hasn't replicated).
  if (svc.identityRegistry.base.writable &&
      verified.method === 'keet' && verified.devicePubkey !== verified.rootPubkey) {
    try {
      const existing = await svc.identityRegistry.getIdentity(verified.devicePubkey)
      if (!existing) {
        const now = Date.now()
        await svc.identityRegistry.registerIdentity({
          identityId: verified.devicePubkey,
          method: 'keet',
          publicKey: verified.devicePubkey,
          credentials: { publicKey: verified.devicePubkey },
          userId: user.userId,
          label: null,
          rootPubkey: verified.rootPubkey,
          firstLinkedAt: now,
          lastSeenAt: now,
          firstSeenOrigin: origin.host || null,
          firstSeenRemoteAddress: origin.remoteAddress || null,
          firstSeenUserAgent: origin.userAgent || null,
          createdAt: now
        })
        await svc.identityRegistry.linkIdentityToUser(
          verified.devicePubkey, user.userId, { verified: true }
        )
      }
    } catch (err) {
      // eslint-disable-next-line no-console
      console.error('[pear-git-v3] device registration failed:', err.message)
    }
  }

  // Intrinsic owner: the key that runs the daemon IS the owner. When the
  // envelope's root matches the daemon's own service identity, short-circuit
  // requireAdmin/Writer — no registry record required.
  const ownerMatched =
    svc.identity && svc.identity.rootPubkeyHex &&
    svc.identity.rootPubkeyHex === verified.rootPubkey

  const ctx = {
    transport,
    identity: {
      publicKey: verified.rootPubkey,
      root: verified.rootPubkey,
      device: verified.devicePubkey,
      method: verified.method,
      userId: user.userId,
      ownerMatched
    },
    origin
  }

  return { ctx, verified, user, becameOwner }
}