/**
* 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 }
}