| … | … | @@ -108,10 +108,16 @@ async function main () { |
| 108 | 108 | // ids fix resolution; distinct key material is what makes the separation real, since a signature |
| 109 | 109 | // is what proves who acted. |
| 110 | 110 | const userKeys = registrations ? new UserKeys({ dir: join(dataDir, 'user-keys') }) : null |
| 111 | // ⛔ The control half of admission. registrations.js holds the registry and says in its own header | |
| 112 | // that proving control "belongs at the route where the challenge lives" — this is that challenge. | |
| 113 | // Without it an identity root, which is public, was accepted as proof of holding it. | |
| 114 | const { Admission } = await import('./lib/admission.js') | |
| 115 | const admission = registrations ? new Admission() : null | |
| 111 | 116 | |
| 112 | 117 | let seq = 0 |
| 113 | 118 | const ctx = { |
| 114 | 119 | registrations, |
| 120 | admission, | |
| 115 | 121 | config, |
| 116 | 122 | publicKeyPem, |
| 117 | 123 | followers, |
| … | … | @@ -487,6 +493,10 @@ async function main () { |
| 487 | 493 | // OTA + control surface. Best-effort: if it cannot come up we keep federating (see lib/control.js). |
| 488 | 494 | const { startControl } = await import('./lib/control.js') |
| 489 | 495 | await startControl({ |
| 496 | // ⛔ The operator's half of registration. `/ap/register` handles the HOLDER's side; minting the | |
| 497 | // token that gates it is not something an HTTP caller should be able to assert, which is the same | |
| 498 | // split the release path already makes. | |
| 499 | onInvite: registrations ? (() => registrations.invite()) : null, | |
| 490 | 500 | udsPath: process.env.AP_SOCK || join(config.dataDir, 'svc.sock'), |
| 491 | 501 | name: process.env.PGH_APP_NAME || 'ap-bridge', |
| 492 | 502 | version: pkgVersion(), |
| … | … | @@ -0,0 +1,121 @@ |
| 1 | // admission.js — proving that a caller CONTROLS the identity it claims a name under. | |
| 2 | // | |
| 3 | // ⛔ WHY THIS IS A SEPARATE FILE. `registrations.js` holds the registry and says, in its own header, | |
| 4 | // that it deliberately does not do this: "it does not verify the chain proof: that is | |
| 5 | // favela-identity's `readAttested`… it belongs at the route where the challenge lives. Here a `root` | |
| 6 | // is required and its SHAPE checked; proving the caller controls it is the caller's job and must | |
| 7 | // happen before claim() is reached." No route had a challenge, so that sentence described a thing | |
| 8 | // that did not exist, and `readAttested` appeared in this package only inside that comment. | |
| 9 | // | |
| 10 | // What that cost: an identity root is PUBLIC. `claim()` accepted any 64-hex string, so anyone could | |
| 11 | // take a name under anyone's identity; and `release()` compared a bare root, so anyone could BURN | |
| 12 | // someone's actor — permanently, because the generation counter deliberately makes a reused name a | |
| 13 | // different actor and there is no way back. | |
| 14 | // | |
| 15 | // ⛔ A CHALLENGE IS REQUIRED, NOT DECORATION. `readAttested` is a pure function of the proof's bytes | |
| 16 | // — no disk, no network, NO CLOCK — which is exactly the property that lets it run inside an | |
| 17 | // autobase apply. A verifier with no clock cannot detect replay. Freshness therefore has to come | |
| 18 | // from something the bridge issues and remembers, and there is nowhere else to put it. | |
| 19 | // | |
| 20 | // ⛔ AND THE REQUEST IS INSIDE THE SIGNED BYTES. A proof covering only the nonce could be captured | |
| 21 | // and replayed to claim a DIFFERENT name under the signer's root — consent proven, control proven, | |
| 22 | // and the wrong name registered. Binding username (and feed, on a claim) is what stops that. | |
| 23 | // | |
| 24 | // ⚠ TOTAL. This runs on a request path fed by strangers, so every path returns { ok, error } and | |
| 25 | // nothing throws: `readAttested` is already guarded that way for the same reason. | |
| 26 | ||
| 27 | import crypto from 'bare-crypto' | |
| 28 | import b4a from 'b4a' | |
| 29 | import { readAttested } from 'favela-identity/lib/attestation.js' | |
| 30 | ||
| 31 | const HEX64 = /^[0-9a-f]{64}$/i | |
| 32 | // Deliberately the same shape registrations.js enforces, so bytes signed here can never describe a | |
| 33 | // name the registry would reject. | |
| 34 | const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/ | |
| 35 | ||
| 36 | // ⚠ VERSIONED AND PREFIXED. The prefix keeps a claim proof from ever verifying as a release proof; | |
| 37 | // the version means a future change to what is bound can be introduced without silently accepting | |
| 38 | // both shapes. Colons are safe separators: a username cannot contain one (NAME), and the nonce and | |
| 39 | // feed key are hex. | |
| 40 | const CLAIM = 'favela-bridge:v1:claim' | |
| 41 | const RELEASE = 'favela-bridge:v1:release' | |
| 42 | ||
| 43 | const DEFAULT_TTL = 120_000 // two minutes — a round trip, not a session | |
| 44 | ||
| 45 | export class Admission { | |
| 46 | constructor ({ ttlMs = DEFAULT_TTL, now = Date.now } = {}) { | |
| 47 | this._ttl = ttlMs | |
| 48 | this._now = now | |
| 49 | this._open = new Map() // nonce -> expiresAt | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Mint a single-use challenge. | |
| 54 | * | |
| 55 | * ⛔ From a CSPRNG. `registrations.js`'s invite token used `Math.random()`, which on an invite-only | |
| 56 | * bridge is the entire admission gate; a guessable nonce here would be the same mistake one layer | |
| 57 | * down. bare-crypto is already a dependency. | |
| 58 | */ | |
| 59 | challenge () { | |
| 60 | this._sweep() | |
| 61 | const nonce = b4a.toString(crypto.randomBytes(32), 'hex') | |
| 62 | const expiresAt = this._now() + this._ttl | |
| 63 | this._open.set(nonce, expiresAt) | |
| 64 | return { nonce, expiresAt } | |
| 65 | } | |
| 66 | ||
| 67 | /** The exact bytes a claimant must sign. Public, because the caller has to produce them too. */ | |
| 68 | claimBytes ({ nonce, username, feedKey }) { | |
| 69 | return b4a.from([CLAIM, nonce, username, feedKey || ''].join(':'), 'utf8') | |
| 70 | } | |
| 71 | ||
| 72 | /** The exact bytes a releaser must sign. No feed: releasing says nothing about where a feed is. */ | |
| 73 | releaseBytes ({ nonce, username }) { | |
| 74 | return b4a.from([RELEASE, nonce, username].join(':'), 'utf8') | |
| 75 | } | |
| 76 | ||
| 77 | verifyClaim ({ nonce, username, feedKey, root, proof } = {}) { | |
| 78 | if (badName(username)) return fail('bad username') | |
| 79 | if (feedKey !== null && feedKey !== undefined && !HEX64.test(String(feedKey))) return fail('bad feed key') | |
| 80 | return this._verify(nonce, root, proof, () => this.claimBytes({ nonce, username, feedKey })) | |
| 81 | } | |
| 82 | ||
| 83 | verifyRelease ({ nonce, username, root, proof } = {}) { | |
| 84 | if (badName(username)) return fail('bad username') | |
| 85 | return this._verify(nonce, root, proof, () => this.releaseBytes({ nonce, username })) | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * ⛔ THE NONCE IS BURNED WHETHER OR NOT THE PROOF VERIFIES. Consuming only on success would leave a | |
| 90 | * live challenge for an attacker to keep trying proofs against; consuming always costs a legitimate | |
| 91 | * caller one extra round trip in the rare case they got it wrong, and costs an attacker everything. | |
| 92 | */ | |
| 93 | _verify (nonce, root, proof, bytesOf) { | |
| 94 | if (typeof nonce !== 'string' || !this._open.has(nonce)) return fail('unknown or expired challenge') | |
| 95 | const expiresAt = this._open.get(nonce) | |
| 96 | this._open.delete(nonce) | |
| 97 | if (this._now() > expiresAt) return fail('unknown or expired challenge') | |
| 98 | if (typeof root !== 'string' || !HEX64.test(root)) return fail('identity root required') | |
| 99 | ||
| 100 | // ⛔ The root is pinned INSIDE verify rather than compared afterwards. attestation.js: "a | |
| 101 | // comparison the caller has to remember to write is a comparison someone eventually forgets." | |
| 102 | // A non-null return means exactly: this root attested this device, and that device signed these | |
| 103 | // bytes. Neither half is trusted separately. | |
| 104 | const seen = readAttested(proof, bytesOf(), root.toLowerCase()) | |
| 105 | if (!seen) return fail('proof does not attest that identity') | |
| 106 | return { ok: true, root: seen.root, device: seen.device } | |
| 107 | } | |
| 108 | ||
| 109 | /** Expired challenges are not evidence of anything — drop them rather than grow a map for ever. */ | |
| 110 | _sweep () { | |
| 111 | const now = this._now() | |
| 112 | for (const [nonce, expiresAt] of this._open) if (now > expiresAt) this._open.delete(nonce) | |
| 113 | } | |
| 114 | ||
| 115 | get open () { return this._open.size } | |
| 116 | } | |
| 117 | ||
| 118 | const badName = (s) => typeof s !== 'string' || !NAME.test(s) | |
| 119 | const fail = (error) => ({ ok: false, error }) | |
| 120 | ||
| 121 | export default { Admission } |
| … | … | @@ -31,6 +31,8 @@ |
| 31 | 31 | // proving the caller controls it is the caller's job and must happen before claim() is reached. |
| 32 | 32 | |
| 33 | 33 | import fs from 'bare-fs' |
| 34 | import crypto from 'bare-crypto' | |
| 35 | import b4a from 'b4a' | |
| 34 | 36 | import { dirname } from 'bare-path' |
| 35 | 37 | |
| 36 | 38 | const HEX64 = /^[0-9a-f]{64}$/i |
| … | … | @@ -127,10 +129,15 @@ class BaseRegistrations { |
| 127 | 129 | } |
| 128 | 130 | } |
| 129 | 131 | |
| 132 | /** | |
| 133 | * ⛔ A CSPRNG, because on an `invite` bridge this token IS the admission gate. | |
| 134 | * | |
| 135 | * This was four draws from `Math.random()`, which is not cryptographically secure and is seeded and | |
| 136 | * advanced predictably — so the one secret standing between a stranger and a name on somebody's | |
| 137 | * domain was guessable. bare-crypto was already a dependency of this package. | |
| 138 | */ | |
| 130 | 139 | function randomToken () { |
| 131 | let s = '' | |
| 132 | for (let i = 0; i < 4; i++) s += Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0') | |
| 133 | return s | |
| 140 | return b4a.toString(crypto.randomBytes(16), 'hex') | |
| 134 | 141 | } |
| 135 | 142 | |
| 136 | 143 | export class Registrations extends BaseRegistrations { |
| … | … | @@ -37,9 +37,17 @@ export async function handleRequest (req, ctx) { |
| 37 | 37 | // Off unless the operator turned it on: no registry means 404, never a silent accept. |
| 38 | 38 | // GET is discovery — a peer asks whether this bridge is open before trying, rather than guessing |
| 39 | 39 | // from a refusal. |
| 40 | // ⛔ THE CHALLENGE. `readAttested` has no clock, so it cannot detect replay; freshness has to come | |
| 41 | // from something this bridge issues and remembers. See lib/admission.js. | |
| 42 | if (path === '/ap/register/challenge') { | |
| 43 | if (!ctx.registrations || !ctx.admission) return text(404, 'not found') | |
| 44 | if (method !== 'GET' && method !== 'POST') return text(405, 'GET or POST only') | |
| 45 | return json(200, ctx.admission.challenge()) | |
| 46 | } | |
| 47 | ||
| 40 | 48 | if (path === '/ap/register') { |
| 41 | 49 | if (!ctx.registrations) return text(404, 'not found') |
| 42 | if (method === 'GET') return json(200, { policy: ctx.registrations.policy }) | |
| 50 | if (method === 'GET') return json(200, { policy: ctx.registrations.policy, proof: !!ctx.admission }) | |
| 43 | 51 | /** |
| 44 | 52 | * ⛔ RELEASE — the holder's half. The operator's half goes through the control socket, because |
| 45 | 53 | * "any name on this bridge" is not a thing an HTTP caller should be able to assert. |
| … | … | @@ -51,6 +59,16 @@ export async function handleRequest (req, ctx) { |
| 51 | 59 | if (method === 'DELETE') { |
| 52 | 60 | let del |
| 53 | 61 | try { del = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) } |
| 62 | // ⛔ PROVED, because releasing BURNS the identity. The generation counter deliberately makes a | |
| 63 | // reused name a different actor, so there is no way back — and this used to compare a bare | |
| 64 | // `root`, which is public. Anyone who could read a root could destroy the actor behind it. | |
| 65 | // ⛔ NOT OPTIONAL. A conditional gate is a gate that is absent wherever the condition is, and | |
| 66 | // "the tests passed" would then mean "the tests ran without it". | |
| 67 | if (!ctx.admission) return json(503, { error: 'registration is not configured on this bridge' }) | |
| 68 | { | |
| 69 | const ok = ctx.admission.verifyRelease({ nonce: del.nonce, username: del.username, root: del.root, proof: del.proof }) | |
| 70 | if (!ok.ok) return json(403, { error: ok.error }) | |
| 71 | } | |
| 54 | 72 | const rel = await ctx.registrations.release(del.username, { root: del.root }) |
| 55 | 73 | if (!rel.ok) return json(rel.error === 'not registered' ? 404 : 403, { error: rel.error }) |
| 56 | 74 | if (ctx.onReleased) { try { await ctx.onReleased(del.username) } catch {} } |
| … | … | @@ -76,6 +94,21 @@ export async function handleRequest (req, ctx) { |
| 76 | 94 | let req_ |
| 77 | 95 | try { req_ = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) } |
| 78 | 96 | |
| 97 | // ⛔ CONTROL, before consent. hyper-social #65 §8: admission needs both and neither substitutes | |
| 98 | // for the other. The policy half (open/invite) was built; this half was only ever asserted in a | |
| 99 | // comment, so an identity root — which is PUBLIC — was accepted as proof of holding it. | |
| 100 | if (!ctx.admission) return json(503, { error: 'registration is not configured on this bridge' }) | |
| 101 | { | |
| 102 | const ok = ctx.admission.verifyClaim({ | |
| 103 | nonce: req_.nonce, | |
| 104 | username: req_.username, | |
| 105 | feedKey: req_.feedKey || null, | |
| 106 | root: req_.root, | |
| 107 | proof: req_.proof | |
| 108 | }) | |
| 109 | if (!ok.ok) return json(403, { error: ok.error }) | |
| 110 | } | |
| 111 | ||
| 79 | 112 | const r = await ctx.registrations.claim({ |
| 80 | 113 | username: req_.username, |
| 81 | 114 | root: req_.root, |
| … | … | @@ -0,0 +1,140 @@ |
| 1 | // admission.test.js — proving CONTROL, which this bridge has never done. | |
| 2 | // | |
| 3 | // ⛔ THE HOLE THIS CLOSES. `registrations.js` requires a `root` and checks only its SHAPE. Its own | |
| 4 | // header says so — "it does not verify the chain proof: that is favela-identity's readAttested… it | |
| 5 | // belongs at the route where the challenge lives" — and no route had a challenge, so `readAttested` | |
| 6 | // appeared in this package only inside that comment. An identity root is PUBLIC. Presenting one | |
| 7 | // proved nothing, so anyone could claim a name under anyone's identity, and `release()` compared a | |
| 8 | // bare string, so anyone could BURN someone's actor — irreversibly, since the generation counter | |
| 9 | // makes a reused name a different actor on purpose. | |
| 10 | // | |
| 11 | // hyper-social #65 §8's rule is that admission needs CONSENT and CONTROL and neither substitutes for | |
| 12 | // the other. The policy half (open/invite) was built; this is the half that was only ever asserted. | |
| 13 | // | |
| 14 | // ⛔ WHY A CHALLENGE AND NOT JUST A SIGNATURE. `readAttested` is a pure function of the proof's bytes | |
| 15 | // — no disk, no network, NO CLOCK — which is what lets it run inside an autobase apply. A verifier | |
| 16 | // with no clock cannot detect replay, so freshness has to come from something the bridge issues. | |
| 17 | // There is nowhere else to put it. | |
| 18 | // | |
| 19 | // ⛔ AND THE REQUEST IS INSIDE THE SIGNED BYTES. A proof covering only the nonce could be captured | |
| 20 | // and replayed to claim a DIFFERENT name under the signer's root: consent proven, control proven, | |
| 21 | // wrong name registered. | |
| 22 | ||
| 23 | import test from 'brittle' | |
| 24 | import b4a from 'b4a' | |
| 25 | import IdentityKey from 'keet-identity-key' | |
| 26 | import crypto from 'hypercore-crypto' | |
| 27 | import { signAttested } from 'favela-identity/lib/attestation.js' | |
| 28 | import { Admission } from '../lib/admission.js' | |
| 29 | ||
| 30 | const FEED = 'f'.repeat(64) | |
| 31 | ||
| 32 | // A throwaway identity with one device attested directly by its root — the shape a vault mints once. | |
| 33 | async function identity () { | |
| 34 | const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() }) | |
| 35 | const root = idk.identityKeyPair | |
| 36 | const kp = crypto.keyPair() | |
| 37 | const proof = await IdentityKey.bootstrap({ identity: root }, kp.publicKey) | |
| 38 | return { | |
| 39 | rootHex: b4a.toString(root.publicKey, 'hex'), | |
| 40 | cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | test('★★ a claim signed by the root it names is admitted', async (t) => { | |
| 45 | const a = new Admission() | |
| 46 | const me = await identity() | |
| 47 | const { nonce } = a.challenge() | |
| 48 | const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 49 | ||
| 50 | const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof }) | |
| 51 | t.ok(r.ok, r.error || '') | |
| 52 | }) | |
| 53 | ||
| 54 | test('⛔ a proof from a DIFFERENT identity is refused — this is the whole point', async (t) => { | |
| 55 | const a = new Admission() | |
| 56 | const me = await identity() | |
| 57 | const someoneElse = await identity() | |
| 58 | const { nonce } = a.challenge() | |
| 59 | // Signed by me, but claiming to be them. Their root is public, so this is the trivial attack. | |
| 60 | const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 61 | ||
| 62 | const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: someoneElse.rootHex, proof }) | |
| 63 | t.absent(r.ok, 'refused') | |
| 64 | t.is(r.error, 'proof does not attest that identity') | |
| 65 | }) | |
| 66 | ||
| 67 | test('⛔ a captured proof cannot be replayed for a different name or feed', async (t) => { | |
| 68 | const me = await identity() | |
| 69 | ||
| 70 | let a = new Admission() | |
| 71 | let { nonce } = a.challenge() | |
| 72 | const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 73 | t.absent(a.verifyClaim({ nonce, username: 'bob', feedKey: FEED, root: me.rootHex, proof }).ok, | |
| 74 | 'a different name is refused') | |
| 75 | ||
| 76 | a = new Admission() | |
| 77 | ;({ nonce } = a.challenge()) | |
| 78 | const p2 = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 79 | t.absent(a.verifyClaim({ nonce, username: 'alice', feedKey: 'e'.repeat(64), root: me.rootHex, proof: p2 }).ok, | |
| 80 | 'a different feed is refused') | |
| 81 | }) | |
| 82 | ||
| 83 | test('⛔ a nonce is single-use, so a proof cannot be replayed at all', async (t) => { | |
| 84 | const a = new Admission() | |
| 85 | const me = await identity() | |
| 86 | const { nonce } = a.challenge() | |
| 87 | const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 88 | ||
| 89 | t.ok(a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof }).ok, 'first use') | |
| 90 | const again = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof }) | |
| 91 | t.absent(again.ok, 'second use refused') | |
| 92 | t.is(again.error, 'unknown or expired challenge') | |
| 93 | }) | |
| 94 | ||
| 95 | test('⛔ an expired challenge is refused', async (t) => { | |
| 96 | let now = 1000 | |
| 97 | const a = new Admission({ ttlMs: 100, now: () => now }) | |
| 98 | const me = await identity() | |
| 99 | const { nonce } = a.challenge() | |
| 100 | const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred) | |
| 101 | now += 101 | |
| 102 | t.is(a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof }).error, | |
| 103 | 'unknown or expired challenge') | |
| 104 | }) | |
| 105 | ||
| 106 | test('⛔ releasing needs the same proof — a burned actor cannot be undone', async (t) => { | |
| 107 | const a = new Admission() | |
| 108 | const me = await identity() | |
| 109 | const other = await identity() | |
| 110 | ||
| 111 | let { nonce } = a.challenge() | |
| 112 | const good = signAttested(a.releaseBytes({ nonce, username: 'alice' }), me.cred) | |
| 113 | t.ok(a.verifyRelease({ nonce, username: 'alice', root: me.rootHex, proof: good }).ok, 'the holder may') | |
| 114 | ||
| 115 | ;({ nonce } = a.challenge()) | |
| 116 | const bad = signAttested(a.releaseBytes({ nonce, username: 'alice' }), other.cred) | |
| 117 | t.absent(a.verifyRelease({ nonce, username: 'alice', root: me.rootHex, proof: bad }).ok, | |
| 118 | 'somebody else may NOT — knowing a public root used to be enough') | |
| 119 | }) | |
| 120 | ||
| 121 | test('⚠ a malformed proof is refused rather than throwing — this runs on a request path', async (t) => { | |
| 122 | const a = new Admission() | |
| 123 | const me = await identity() | |
| 124 | for (const bad of ['', 'not-base64!!', b4a.toString(b4a.alloc(8), 'base64'), null]) { | |
| 125 | const { nonce } = a.challenge() | |
| 126 | const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof: bad }) | |
| 127 | t.absent(r.ok, 'refused: ' + JSON.stringify(bad)) | |
| 128 | } | |
| 129 | }) | |
| 130 | ||
| 131 | test('★ nonces are unguessable and distinct', (t) => { | |
| 132 | const a = new Admission() | |
| 133 | const seen = new Set() | |
| 134 | for (let i = 0; i < 200; i++) { | |
| 135 | const { nonce } = a.challenge() | |
| 136 | t.ok(/^[0-9a-f]{64}$/.test(nonce), 'looks like 32 random bytes') | |
| 137 | seen.add(nonce) | |
| 138 | } | |
| 139 | t.is(seen.size, 200, 'no collisions') | |
| 140 | }) |
| … | … | @@ -5,26 +5,55 @@ |
| 5 | 5 | // operator sent it. |
| 6 | 6 | |
| 7 | 7 | import test from 'brittle' |
| 8 | import b4a from 'b4a' | |
| 9 | import IdentityKey from 'keet-identity-key' | |
| 10 | import crypto from 'hypercore-crypto' | |
| 11 | import { signAttested } from 'favela-identity/lib/attestation.js' | |
| 8 | 12 | import { handleRequest as route } from '../lib/router.js' |
| 9 | 13 | import { MemoryRegistrations } from '../lib/registrations.js' |
| 14 | import { Admission } from '../lib/admission.js' | |
| 15 | ||
| 16 | // ⛔ A REAL IDENTITY, not a placeholder root. These tests used `'a'.repeat(64)` and passed, which is | |
| 17 | // exactly the defect: an identity root is PUBLIC, so accepting one as proof of holding it let anyone | |
| 18 | // claim a name under anyone's identity. The ceremony below is what a peer actually performs. | |
| 19 | let ME = null | |
| 20 | async function me () { | |
| 21 | if (ME) return ME | |
| 22 | const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() }) | |
| 23 | const root = idk.identityKeyPair | |
| 24 | const kp = crypto.keyPair() | |
| 25 | const proof = await IdentityKey.bootstrap({ identity: root }, kp.publicKey) | |
| 26 | ME = { rootHex: b4a.toString(root.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } } | |
| 27 | return ME | |
| 28 | } | |
| 10 | 29 | |
| 11 | const ROOT = 'a'.repeat(64) | |
| 12 | 30 | const ctxFor = (policy) => ({ |
| 13 | 31 | config: { host: 'ap.7rnx.net', username: 'primary' }, |
| 14 | 32 | registrations: new MemoryRegistrations({ policy }), |
| 33 | admission: new Admission(), | |
| 15 | 34 | log: () => {} |
| 16 | 35 | }) |
| 17 | 36 | const post = (body) => ({ method: 'POST', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify(body) }) |
| 18 | 37 | |
| 38 | /** The claim a peer sends: ask for a challenge, sign the exact request, send both. */ | |
| 39 | async function claimBody (ctx, { username, feedKey = 'f'.repeat(64), invite = null }) { | |
| 40 | const who = await me() | |
| 41 | const { nonce } = ctx.admission.challenge() | |
| 42 | const proof = signAttested(ctx.admission.claimBytes({ nonce, username, feedKey }), who.cred) | |
| 43 | const body = { username, root: who.rootHex, feedKey, nonce, proof } | |
| 44 | if (invite) body.invite = invite | |
| 45 | return body | |
| 46 | } | |
| 47 | ||
| 19 | 48 | test('★★ an open bridge grants a name on request — nobody in the loop', async (t) => { |
| 20 | 49 | const ctx = ctxFor('open') |
| 21 | const res = await route(post({ username: 'ashatron', root: ROOT, feedKey: 'f'.repeat(64) }), ctx) | |
| 50 | const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx) | |
| 22 | 51 | |
| 23 | 52 | t.is(res.status, 200) |
| 24 | 53 | const doc = JSON.parse(res.body) |
| 25 | 54 | t.is(doc.username, 'ashatron') |
| 26 | 55 | t.is(doc.actor, 'https://ap.7rnx.net/ap/users/ashatron/actor', 'and it says where the actor now lives') |
| 27 | t.is((await ctx.registrations.get('ashatron')).root, ROOT) | |
| 56 | t.is((await ctx.registrations.get('ashatron')).root, (await me()).rootHex) | |
| 28 | 57 | }) |
| 29 | 58 | |
| 30 | 59 | test('★ a peer can ask whether a bridge is open before trying', async (t) => { |
| … | … | @@ -36,7 +65,7 @@ test('★ a peer can ask whether a bridge is open before trying', async (t) => { |
| 36 | 65 | |
| 37 | 66 | test('⛔ a closed bridge refuses, and says what is needed', async (t) => { |
| 38 | 67 | const ctx = ctxFor('invite') |
| 39 | const res = await route(post({ username: 'ashatron', root: ROOT }), ctx) | |
| 68 | const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx) | |
| 40 | 69 | |
| 41 | 70 | t.is(res.status, 403) |
| 42 | 71 | t.is(JSON.parse(res.body).error, 'invite required') |
| … | … | @@ -46,39 +75,48 @@ test('★ with the invite the operator sent, the same request works', async (t) |
| 46 | 75 | const ctx = ctxFor('invite') |
| 47 | 76 | const invite = await ctx.registrations.invite() |
| 48 | 77 | |
| 49 | const res = await route(post({ username: 'ashatron', root: ROOT, invite }), ctx) | |
| 78 | const res = await route(post(await claimBody(ctx, { username: 'ashatron', invite })), ctx) | |
| 50 | 79 | t.is(res.status, 200) |
| 51 | 80 | }) |
| 52 | 81 | |
| 53 | 82 | test('⛔ a taken name is refused, not silently reassigned', async (t) => { |
| 54 | 83 | const ctx = ctxFor('open') |
| 55 | await route(post({ username: 'ashatron', root: ROOT }), ctx) | |
| 56 | const res = await route(post({ username: 'ashatron', root: 'b'.repeat(64) }), ctx) | |
| 84 | await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx) | |
| 85 | const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx) | |
| 57 | 86 | |
| 58 | 87 | t.is(res.status, 409) |
| 59 | 88 | t.is(JSON.parse(res.body).error, 'taken') |
| 60 | 89 | }) |
| 61 | 90 | |
| 62 | 91 | test('⛔ a bridge with no registry does not silently accept', async (t) => { |
| 63 | const res = await route(post({ username: 'x', root: ROOT }), { config: { host: 'h' }, log: () => {} }) | |
| 92 | const res = await route(post({ username: 'x', root: 'a'.repeat(64) }), { config: { host: 'h' }, log: () => {} }) | |
| 64 | 93 | t.is(res.status, 404, 'registration is off unless the operator turned it on') |
| 65 | 94 | }) |
| 66 | 95 | |
| 67 | 96 | test('⛔ garbage in is refused, not guessed at', async (t) => { |
| 68 | 97 | const ctx = ctxFor('open') |
| 69 | 98 | t.is((await route({ ...post({}), body: 'not json' }, ctx)).status, 400) |
| 70 | t.is((await route(post({ username: 'x' }), ctx)).status, 400, 'a name with no identity behind it') | |
| 71 | t.is((await route({ ...post({ username: 'x', root: ROOT }), method: 'PUT' }, ctx)).status, 405) | |
| 99 | // ⚠ 403, not 400: the proof gate runs BEFORE the registry examines the request's shape. An | |
| 100 | // unproven caller is turned away without its request being inspected at all, which is the correct | |
| 101 | // order — the bridge owes a stranger no diagnosis of a request it will not consider. | |
| 102 | t.is((await route(post({ username: 'x' }), ctx)).status, 403, 'a name with no identity behind it') | |
| 103 | t.is((await route({ ...post({ username: 'x', root: 'a'.repeat(64) }), method: 'PUT' }, ctx)).status, 405) | |
| 72 | 104 | }) |
| 73 | 105 | |
| 74 | 106 | test('★ the holder can release their name over the same endpoint', async (t) => { |
| 75 | 107 | const ctx = ctxFor('open') |
| 76 | await route(post({ username: 'ashatron', root: ROOT }), ctx) | |
| 108 | await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx) | |
| 109 | const who = await me() | |
| 77 | 110 | |
| 78 | 111 | const del = (body) => route({ method: 'DELETE', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify(body) }, ctx) |
| 79 | 112 | |
| 113 | // ⚠ A stranger now fails on the PROOF, not on a string comparison — which is the difference between | |
| 114 | // "you are not the holder" and "you cannot demonstrate that you are". | |
| 80 | 115 | t.is((await del({ username: 'ashatron', root: 'b'.repeat(64) })).status, 403, 'a stranger cannot') |
| 81 | t.is((await del({ username: 'ashatron', root: ROOT })).status, 200, 'the holder can') | |
| 116 | ||
| 117 | const { nonce } = ctx.admission.challenge() | |
| 118 | const proof = signAttested(ctx.admission.releaseBytes({ nonce, username: 'ashatron' }), who.cred) | |
| 119 | t.is((await del({ username: 'ashatron', root: who.rootHex, nonce, proof })).status, 200, 'the holder can') | |
| 82 | 120 | t.absent(await ctx.registrations.get('ashatron')) |
| 83 | 121 | }) |
| 84 | 122 | |
| … | … | @@ -86,9 +124,74 @@ test('★ the holder can release their name over the same endpoint', async (t) = |
| 86 | 124 | // federated reference to the last holder cannot resolve to the next one. |
| 87 | 125 | test('⛔ a re-claimed name is a new generation', async (t) => { |
| 88 | 126 | const ctx = ctxFor('open') |
| 89 | await route(post({ username: 'ashatron', root: ROOT }), ctx) | |
| 90 | await route({ method: 'DELETE', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify({ username: 'ashatron', root: ROOT }) }, ctx) | |
| 127 | const who = await me() | |
| 128 | await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx) | |
| 91 | 129 | |
| 92 | const again = await route(post({ username: 'ashatron', root: 'c'.repeat(64) }), ctx) | |
| 130 | const { nonce } = ctx.admission.challenge() | |
| 131 | const proof = signAttested(ctx.admission.releaseBytes({ nonce, username: 'ashatron' }), who.cred) | |
| 132 | await route({ method: 'DELETE', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify({ username: 'ashatron', root: who.rootHex, nonce, proof }) }, ctx) | |
| 133 | ||
| 134 | const again = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx) | |
| 93 | 135 | t.is(JSON.parse(again.body).generation, 2) |
| 94 | 136 | }) |
| 137 | ||
| 138 | // ⛔ THE REGRESSION THIS FILE EXISTED WITHOUT. Every test above used to pass while sending no proof | |
| 139 | // at all, because there was none to send: an identity root is public and the route accepted it as | |
| 140 | // evidence of holding it. These pin the refusals, so the gate cannot quietly stop applying. | |
| 141 | test('⛔⛔ a claim with no proof is refused — a public root is not evidence', async (t) => { | |
| 142 | const ctx = ctxFor('open') | |
| 143 | const who = await me() | |
| 144 | const res = await route(post({ username: 'ashatron', root: who.rootHex, feedKey: 'f'.repeat(64) }), ctx) | |
| 145 | ||
| 146 | t.is(res.status, 403) | |
| 147 | t.is(JSON.parse(res.body).error, 'unknown or expired challenge') | |
| 148 | t.is(await ctx.registrations.get('ashatron'), null, 'and nothing was registered') | |
| 149 | }) | |
| 150 | ||
| 151 | test('⛔⛔ a claim proved by a DIFFERENT identity cannot take your name', async (t) => { | |
| 152 | const ctx = ctxFor('open') | |
| 153 | const victim = await me() | |
| 154 | ||
| 155 | // An attacker with their own valid identity, naming the victim's public root. | |
| 156 | const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() }) | |
| 157 | const kp = crypto.keyPair() | |
| 158 | const attacker = { keyPair: kp, proof: b4a.toString(await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey), 'base64') } | |
| 159 | ||
| 160 | const { nonce } = ctx.admission.challenge() | |
| 161 | const proof = signAttested(ctx.admission.claimBytes({ nonce, username: 'ashatron', feedKey: 'f'.repeat(64) }), attacker) | |
| 162 | const res = await route(post({ username: 'ashatron', root: victim.rootHex, feedKey: 'f'.repeat(64), nonce, proof }), ctx) | |
| 163 | ||
| 164 | t.is(res.status, 403) | |
| 165 | t.is(JSON.parse(res.body).error, 'proof does not attest that identity') | |
| 166 | }) | |
| 167 | ||
| 168 | // ⛔ RELEASE BURNS THE ACTOR. The generation counter makes a reused name a different actor on | |
| 169 | // purpose, so there is no way back — and this compared a bare root until now. | |
| 170 | test('⛔⛔ a release with no proof cannot burn somebody\'s actor', async (t) => { | |
| 171 | const ctx = ctxFor('open') | |
| 172 | await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx) | |
| 173 | const who = await me() | |
| 174 | ||
| 175 | const res = await route({ | |
| 176 | method: 'DELETE', path: '/ap/register', headers: {}, query: {}, | |
| 177 | body: JSON.stringify({ username: 'ashatron', root: who.rootHex }) | |
| 178 | }, ctx) | |
| 179 | ||
| 180 | t.is(res.status, 403) | |
| 181 | t.ok(await ctx.registrations.get('ashatron'), 'the name is still held') | |
| 182 | }) | |
| 183 | ||
| 184 | test('★ a peer can fetch a challenge, and each one is single-use', async (t) => { | |
| 185 | const ctx = ctxFor('open') | |
| 186 | const res = await route({ method: 'GET', path: '/ap/register/challenge', headers: {}, query: {} }, ctx) | |
| 187 | t.is(res.status, 200) | |
| 188 | const { nonce, expiresAt } = JSON.parse(res.body) | |
| 189 | t.ok(/^[0-9a-f]{64}$/.test(nonce), 'a nonce to sign') | |
| 190 | t.ok(expiresAt > Date.now(), 'with a deadline') | |
| 191 | ||
| 192 | const who = await me() | |
| 193 | const proof = signAttested(ctx.admission.claimBytes({ nonce, username: 'ashatron', feedKey: 'f'.repeat(64) }), who.cred) | |
| 194 | const body = { username: 'ashatron', root: who.rootHex, feedKey: 'f'.repeat(64), nonce, proof } | |
| 195 | t.is((await route(post(body), ctx)).status, 200, 'the first use works') | |
| 196 | t.is((await route(post({ ...body, username: 'other' }), ctx)).status, 403, 'and it cannot be used again') | |
| 197 | }) |