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