// admission.js — proving that a caller CONTROLS the identity it is speaking for.
//
// ⛔ SHARED, because there are two bridges and there must not be two ceremonies. ap-bridge asks it
// about NAMES and atproto-bridge asks it about CARRYING an identity; both questions are the same
// question underneath, and a second copy would be the one that forgot the nonce is single-use. It
// lives here for the reason this package exists at all — its own header: "EXTRACTED SO THERE IS ONE
// COPY".
//
// ⛔ WHY THIS IS A SEPARATE FILE. `registrations.js` holds the registry and says, in its own header,
// that it deliberately does not do this: "it does not verify the chain proof: that is
// favela-identity's `readAttested`… it belongs at the route where the challenge lives. Here a `root`
// is required and its SHAPE checked; proving the caller controls it is the caller's job and must
// happen before claim() is reached." No route had a challenge, so that sentence described a thing
// that did not exist, and `readAttested` appeared in this package only inside that comment.
//
// What that cost: an identity root is PUBLIC. `claim()` accepted any 64-hex string, so anyone could
// take a name under anyone's identity; and `release()` compared a bare root, so anyone could BURN
// someone's actor — permanently, because the generation counter deliberately makes a reused name a
// different actor and there is no way back.
//
// ⛔ A CHALLENGE IS REQUIRED, NOT DECORATION. `readAttested` is a pure function of the proof's bytes
// — no disk, no network, NO CLOCK — which is exactly the property that lets it run inside an
// autobase apply. A verifier with no clock cannot detect replay. Freshness therefore has to come
// from something the bridge issues and remembers, and there is nowhere else to put it.
//
// ⛔ AND THE REQUEST IS INSIDE THE SIGNED BYTES. A proof covering only the nonce could be captured
// and replayed to claim a DIFFERENT name under the signer's root — consent proven, control proven,
// and the wrong name registered. Binding username (and feed, on a claim) is what stops that.
//
// ⚠ TOTAL. This runs on a request path fed by strangers, so every path returns { ok, error } and
// nothing throws: `readAttested` is already guarded that way for the same reason.
import crypto from 'bare-crypto'
import b4a from 'b4a'
import { readAttested } from './attestation.js'
import { claimBytes, releaseBytes, frontBytes, collectBytes } from './bridge-claim.js'
const HEX64 = /^[0-9a-f]{64}$/i
// Deliberately the same shape registrations.js enforces, so bytes signed here can never describe a
// name the registry would reject.
const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/
const DEFAULT_TTL = 120_000 // two minutes — a round trip, not a session
export class Admission {
constructor ({ ttlMs = DEFAULT_TTL, now = Date.now } = {}) {
this._ttl = ttlMs
this._now = now
this._open = new Map() // nonce -> expiresAt
}
/**
* Mint a single-use challenge.
*
* ⛔ From a CSPRNG. `registrations.js`'s invite token used `Math.random()`, which on an invite-only
* bridge is the entire admission gate; a guessable nonce here would be the same mistake one layer
* down. bare-crypto is already a dependency.
*/
challenge () {
this._sweep()
const nonce = b4a.toString(crypto.randomBytes(32), 'hex')
const expiresAt = this._now() + this._ttl
this._open.set(nonce, expiresAt)
return { nonce, expiresAt }
}
/** The exact bytes a claimant must sign. Public, because the caller has to produce them too. */
claimBytes (params) { return claimBytes(params) }
/** The exact bytes a releaser must sign. */
releaseBytes (params) { return releaseBytes(params) }
verifyClaim ({ nonce, username, feedKey, root, proof } = {}) {
if (badName(username)) return fail('bad username')
if (feedKey !== null && feedKey !== undefined && !HEX64.test(String(feedKey))) return fail('bad feed key')
return this._verify(nonce, root, proof, () => this.claimBytes({ nonce, username, feedKey }))
}
verifyRelease ({ nonce, username, root, proof } = {}) {
if (badName(username)) return fail('bad username')
return this._verify(nonce, root, proof, () => this.releaseBytes({ nonce, username }))
}
/**
* Asking a bridge to CARRY an identity, rather than to name one.
*
* ⚠ `id` is opaque here, and deliberately: this package does not know what network names it, and
* the bridge doing the verifying is the only thing that needs to. What is checked is the same
* question in both cases — does this caller control the identity it is speaking for.
*/
verifyFront ({ nonce, id, root, proof } = {}) {
if (typeof id !== 'string' || !id || id.length > 256 || id.includes(':::')) return fail('bad identifier')
return this._verify(nonce, root, proof, () => frontBytes({ nonce, id }))
}
frontBytes (params) { return frontBytes(params) }
/**
* Asking a bridge for what it has HELD for a name.
*
* ⚠ THIS PROVES ONE HALF ONLY. It answers "does this caller control this root" — it says nothing
* about whether that root holds that name, because this package has no registry and must not grow
* one. The bridge owns the second half and it is the half that matters here: roots are public, so a
* caller who proved control of their own while naming somebody else's username would otherwise be
* handed that person's entire inbound social graph. See ap-bridge's `collect.js`.
*/
verifyCollect ({ nonce, username, since, root, proof } = {}) {
if (badName(username)) return fail('bad username')
return this._verify(nonce, root, proof, () => this.collectBytes({ nonce, username, since }))
}
collectBytes (params) { return collectBytes(params) }
/**
* ⛔ THE NONCE IS BURNED WHETHER OR NOT THE PROOF VERIFIES. Consuming only on success would leave a
* live challenge for an attacker to keep trying proofs against; consuming always costs a legitimate
* caller one extra round trip in the rare case they got it wrong, and costs an attacker everything.
*/
_verify (nonce, root, proof, bytesOf) {
if (typeof nonce !== 'string' || !this._open.has(nonce)) return fail('unknown or expired challenge')
const expiresAt = this._open.get(nonce)
this._open.delete(nonce)
if (this._now() > expiresAt) return fail('unknown or expired challenge')
if (typeof root !== 'string' || !HEX64.test(root)) return fail('identity root required')
// ⛔ The root is pinned INSIDE verify rather than compared afterwards. attestation.js: "a
// comparison the caller has to remember to write is a comparison someone eventually forgets."
// A non-null return means exactly: this root attested this device, and that device signed these
// bytes. Neither half is trusted separately.
const seen = readAttested(proof, bytesOf(), root.toLowerCase())
if (!seen) return fail('proof does not attest that identity')
return { ok: true, root: seen.root, device: seen.device }
}
/** Expired challenges are not evidence of anything — drop them rather than grow a map for ever. */
_sweep () {
const now = this._now()
for (const [nonce, expiresAt] of this._open) if (now > expiresAt) this._open.delete(nonce)
}
get open () { return this._open.size }
}
const badName = (s) => typeof s !== 'string' || !NAME.test(s)
const fail = (error) => ({ ok: false, error })
export default { Admission }