OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 4195ec0

admission is re-exported from favela-identity
The implementation moved; this path is imported by index.js and by the tests that prove the gate, and relocating an implementation is not a reason to rewrite its callers. 256/256 unchanged, which is the evidence the move was behaviour-free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4195ec0381fd9ed1c493d6a19b5e0996377d237e
by plankatron <fleeky@gmail.com> · 2026-09-11T16:34:42.000Z

Changed files (3) +48 -146

.pgh-lock.json +23 -33
……@@ -1,9 +1,9 @@
11{
22 "name": "ap-bridge",
3 "version": "0.9.84",
3 "version": "0.9.89",
44 "runtime": "bare",
5 "rootTreeHash": "56aa2c4ac316ad8079e304e67d3c11883811150e7112b6fd21cd06d8a9168787",
6 "publishedAt": "2026-09-09T01:31:20.229Z",
5 "rootTreeHash": "c27a6c758f36eae72d6c6b162df7867b77694bd953a4ade1240f8e9156214ed4",
6 "publishedAt": "2026-09-10T00:03:50.089Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -1918,9 +1918,9 @@
19181918 },
19191919 {
19201920 "name": "favela-markdown",
1921 "version": "0.1.6",
1922 "tree": "97d64cb2674377910d3ec28f3f4246d7e3fbc1b9b06b8b9d1ef6360cc0765569",
1923 "normHash": "853f6fe2c24cea0c956653eaa7d5a13b7feca966cad5c9a6164c2dc1ff324881",
1921 "version": "0.2.0",
1922 "tree": "e980c91c65708beb2d24964f4f8bce19d01f9783d1d93e4eff8674851df64611",
1923 "normHash": "2b6a91ad3e40196bc8a2bed0be0d54af70e365e9159504e3cb86e73f034b8c5f",
19241924 "workspace": true
19251925 },
19261926 {
……@@ -2395,9 +2395,9 @@
23952395 },
23962396 {
23972397 "name": "favela-peer",
2398 "version": "0.0.48",
2399 "tree": "5c3a76b7f78ee8a7acfe03eb902a411579e73959a1604bf02c6f65a606046c94",
2400 "normHash": "73a8554a41d9df47276e4ef8f47d5e8c6149897a3877dacf7e0926d04eecb629",
2398 "version": "0.0.50",
2399 "tree": "d6422e37ce071e93589035c85bf6aea0db341b785fc763f6b6c77c3a3860586f",
2400 "normHash": "5c2a74403164d3d3a449fa1ac5898f98f408c40984a62780cbc84ac12864a54c",
24012401 "workspace": true
24022402 },
24032403 {
……@@ -3921,15 +3921,10 @@
39213921 "appName": "favela-web",
39223922 "version": "0.0.3",
39233923 "tree": "38f44af48b2d59b46f1b5db31b81d02f3ca8b8bad6bc0fb9fffff698241f5e37"
3924 },
3925 {
3926 "appName": "hyper-proto-peer",
3927 "version": "0.0.3",
3928 "tree": "1aa7863120ed4d861c4ba8230a8ba551d94d0a922e691697ff7ac77ec9ec7f9a"
39293924 }
39303925 ],
3931 "reason": "version-splits only",
3932 "acceptedAt": "2026-09-09T01:31:20.241Z"
3926 "reason": "dep version-splits only; 0 code-differs",
3927 "acceptedAt": "2026-09-10T00:03:50.127Z"
39333928 },
39343929 {
39353930 "type": "VERSION_CONFLICT",
……@@ -3943,8 +3938,8 @@
39433938 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
39443939 }
39453940 ],
3946 "reason": "version-splits only",
3947 "acceptedAt": "2026-09-09T01:31:20.241Z"
3941 "reason": "dep version-splits only; 0 code-differs",
3942 "acceptedAt": "2026-09-10T00:03:50.127Z"
39483943 },
39493944 {
39503945 "type": "VERSION_CONFLICT",
……@@ -3968,8 +3963,8 @@
39683963 "tree": "bdebba5a532ce4846b49f1e5ffad774becfc44182d0c3737f7e441ab42f4c43d"
39693964 }
39703965 ],
3971 "reason": "version-splits only",
3972 "acceptedAt": "2026-09-09T01:31:20.241Z"
3966 "reason": "dep version-splits only; 0 code-differs",
3967 "acceptedAt": "2026-09-10T00:03:50.127Z"
39733968 },
39743969 {
39753970 "type": "VERSION_CONFLICT",
……@@ -3983,8 +3978,8 @@
39833978 "tree": "c5f18b3620514f54714aeb351f074adface216a335242e323e33d28d261376fe"
39843979 }
39853980 ],
3986 "reason": "version-splits only",
3987 "acceptedAt": "2026-09-09T01:31:20.241Z"
3981 "reason": "dep version-splits only; 0 code-differs",
3982 "acceptedAt": "2026-09-10T00:03:50.127Z"
39883983 },
39893984 {
39903985 "type": "VERSION_CONFLICT",
……@@ -3998,8 +3993,8 @@
39983993 "tree": "e92100a4f821a638ac99c1407e4c7e784f8eb9661e6d9119ecbec4faae77cc7e"
39993994 }
40003995 ],
4001 "reason": "version-splits only",
4002 "acceptedAt": "2026-09-09T01:31:20.241Z"
3996 "reason": "dep version-splits only; 0 code-differs",
3997 "acceptedAt": "2026-09-10T00:03:50.127Z"
40033998 },
40043999 {
40054000 "type": "VERSION_CONFLICT",
……@@ -4013,8 +4008,8 @@
40134008 "tree": "fc36977bda7dd2c7467653aa2ef520890b3a0aae16870a9269f0f71691673dce"
40144009 }
40154010 ],
4016 "reason": "version-splits only",
4017 "acceptedAt": "2026-09-09T01:31:20.241Z"
4011 "reason": "dep version-splits only; 0 code-differs",
4012 "acceptedAt": "2026-09-10T00:03:50.127Z"
40184013 },
40194014 {
40204015 "type": "VERSION_CONFLICT",
……@@ -4026,15 +4021,10 @@
40264021 "appName": "pgh",
40274022 "version": "0.3.12",
40284023 "tree": "10193a86e2c6f80c2d4377379a0aae4a959f8a40fd9e852b85fee081bfe6675a"
4029 },
4030 {
4031 "appName": "hyper-proto-peer",
4032 "version": "0.3.12",
4033 "tree": "92424acff1a50468df203528e41739f44f602904abc4beb34500ce172c6d0ad2"
40344024 }
40354025 ],
4036 "reason": "version-splits only",
4037 "acceptedAt": "2026-09-09T01:31:20.241Z"
4026 "reason": "dep version-splits only; 0 code-differs",
4027 "acceptedAt": "2026-09-10T00:03:50.127Z"
40384028 }
40394029 ]
40404030}
lib/admission.js +12 -112
……@@ -1,115 +1,15 @@
1// admission.js — proving that a caller CONTROLS the identity it claims a name under.
1// admission.js — re-exported from favela-identity, which is where it lives now.
22//
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.
3// ⛔ ONE CEREMONY, TWO BRIDGES. This file held the implementation. atproto-bridge needs the same
4// thing — a single-use challenge and a chain proof pinned to the root it names — because "may this
5// caller speak for this identity" is the same question whether the answer is a NAME on a domain or
6// an identity CARRIED onto a network. A second copy would be the one that forgot the nonce is
7// single-use, or that the root must be pinned inside verify rather than compared afterwards.
98//
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.
9// favela-identity is where it belongs: it already owns `readAttested` and the bytes both sides sign,
10// and its own header says it was "EXTRACTED SO THERE IS ONE COPY".
1411//
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
27import crypto from 'bare-crypto'
28import b4a from 'b4a'
29import { readAttested } from 'favela-identity/lib/attestation.js'
30// ⛔ THE BYTES COME FROM THE SHARED PACKAGE, not from here. The claimant builds them too, and a
31// disagreement of one byte surfaces as "proof does not attest that identity" — indistinguishable
32// from a forged proof. One definition, next to the verifier that consumes it.
33import { claimBytes, releaseBytes } from 'favela-identity/lib/bridge-claim.js'
34
35const HEX64 = /^[0-9a-f]{64}$/i
36// Deliberately the same shape registrations.js enforces, so bytes signed here can never describe a
37// name the registry would reject.
38const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/
39
40
41const DEFAULT_TTL = 120_000 // two minutes — a round trip, not a session
42
43export class Admission {
44 constructor ({ ttlMs = DEFAULT_TTL, now = Date.now } = {}) {
45 this._ttl = ttlMs
46 this._now = now
47 this._open = new Map() // nonce -> expiresAt
48 }
49
50 /**
51 * Mint a single-use challenge.
52 *
53 * ⛔ From a CSPRNG. `registrations.js`'s invite token used `Math.random()`, which on an invite-only
54 * bridge is the entire admission gate; a guessable nonce here would be the same mistake one layer
55 * down. bare-crypto is already a dependency.
56 */
57 challenge () {
58 this._sweep()
59 const nonce = b4a.toString(crypto.randomBytes(32), 'hex')
60 const expiresAt = this._now() + this._ttl
61 this._open.set(nonce, expiresAt)
62 return { nonce, expiresAt }
63 }
64
65 /** The exact bytes a claimant must sign. Public, because the caller has to produce them too. */
66 claimBytes (params) { return claimBytes(params) }
67
68 /** The exact bytes a releaser must sign. */
69 releaseBytes (params) { return releaseBytes(params) }
70
71 verifyClaim ({ nonce, username, feedKey, root, proof } = {}) {
72 if (badName(username)) return fail('bad username')
73 if (feedKey !== null && feedKey !== undefined && !HEX64.test(String(feedKey))) return fail('bad feed key')
74 return this._verify(nonce, root, proof, () => this.claimBytes({ nonce, username, feedKey }))
75 }
76
77 verifyRelease ({ nonce, username, root, proof } = {}) {
78 if (badName(username)) return fail('bad username')
79 return this._verify(nonce, root, proof, () => this.releaseBytes({ nonce, username }))
80 }
81
82 /**
83 * ⛔ THE NONCE IS BURNED WHETHER OR NOT THE PROOF VERIFIES. Consuming only on success would leave a
84 * live challenge for an attacker to keep trying proofs against; consuming always costs a legitimate
85 * caller one extra round trip in the rare case they got it wrong, and costs an attacker everything.
86 */
87 _verify (nonce, root, proof, bytesOf) {
88 if (typeof nonce !== 'string' || !this._open.has(nonce)) return fail('unknown or expired challenge')
89 const expiresAt = this._open.get(nonce)
90 this._open.delete(nonce)
91 if (this._now() > expiresAt) return fail('unknown or expired challenge')
92 if (typeof root !== 'string' || !HEX64.test(root)) return fail('identity root required')
93
94 // ⛔ The root is pinned INSIDE verify rather than compared afterwards. attestation.js: "a
95 // comparison the caller has to remember to write is a comparison someone eventually forgets."
96 // A non-null return means exactly: this root attested this device, and that device signed these
97 // bytes. Neither half is trusted separately.
98 const seen = readAttested(proof, bytesOf(), root.toLowerCase())
99 if (!seen) return fail('proof does not attest that identity')
100 return { ok: true, root: seen.root, device: seen.device }
101 }
102
103 /** Expired challenges are not evidence of anything — drop them rather than grow a map for ever. */
104 _sweep () {
105 const now = this._now()
106 for (const [nonce, expiresAt] of this._open) if (now > expiresAt) this._open.delete(nonce)
107 }
108
109 get open () { return this._open.size }
110}
111
112const badName = (s) => typeof s !== 'string' || !NAME.test(s)
113const fail = (error) => ({ ok: false, error })
114
115export default { Admission }
12// ⚠ Kept as a re-export rather than deleted — this path is imported by index.js and by the tests
13// that prove the gate, and relocating an implementation is not a reason to rewrite its callers.
14export { Admission } from 'favela-identity/lib/admission.js'
15export { default } from 'favela-identity/lib/admission.js'
lib/control.js +13 -1
……@@ -82,7 +82,7 @@ export function maintainSidecarRegistration ({ udsPath, name, runtime = 'bare',
8282//
8383// onRelease(version) is invoked AFTER the response is queued, via setImmediate, so the caller owns
8484// the shutdown sequence and exit code.
85export async function startControl ({ udsPath, name, version = null, status = null, onRelease = null, onReproject = null, onNotifications = null, log = console.log } = {}) {
85export async function startControl ({ udsPath, name, version = null, status = null, onRelease = null, onReproject = null, onNotifications = null, onInvite = null, log = console.log } = {}) {
8686 try {
8787 const { Service } = await import('hyper-svc')
8888 // A stale socket from a killed process blocks bind. We only reach here after the supervisor
……@@ -104,6 +104,18 @@ export async function startControl ({ udsPath, name, version = null, status = nu
104104 }))
105105 }
106106
107 // ⛔ MINTING AN INVITE IS AN OPERATOR ACT, so it lives on the owner-gated unix socket and nowhere
108 // else. `registrations.js` has had `invite()`, single-use hashes and an `invite` policy since it
109 // was written, and NOTHING has ever called it — so a bridge could be switched to invite-only and
110 // then no invite could be created for it. The token type existed; the door did not.
111 //
112 // ⚠ The token is returned ONCE and stored nowhere: the log holds only its hash, exactly as the
113 // page that displays one already says. A control surface that could list tokens would defeat
114 // that, so this mints and forgets.
115 if (onInvite) {
116 svc.handle('invite', async () => onInvite())
117 }
118
107119 if (onReproject) {
108120 svc.handle('reproject', async (ctx, params = {}) => onReproject({
109121 rkey: params.rkey || null,