OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 540a467

0.2.15: re-export attestDeviceViaDevice + prove chained admission
Exposes atproto-identity 0.0.3's chained attestation through the membership barrel, and asserts it against the ACTUAL admission gate this module supplies to StateBaseV3 (makeAttest/makeVerifyAttest) rather than the library call. The load-bearing result: a device attested by ANOTHER DEVICE — no root mnemonic anywhere in the process — is admitted and resolves to the same root identity and epoch as a root-attested one. That is what makes adding a co-owner to a live room possible at all, given no root mnemonic is stored on any node by design. Also pinned: the parent's own proof still verifies after vouching (keet's attestDevice mutates a decoded chain, so passing the object rather than bytes would grow the parent's proof on every call), the child's proof cannot claim to be the parent, it stays bound to its signData, and chaining does not launder a foreign root — no privilege is created by extending a chain. 3/3 tests, 11/11 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
540a467fa5ca9ba9edc26f4f6fe2d498fbfed526
by plankatron <fleeky@gmail.com> · 2026-07-27T20:39:55.000Z

Changed files (3) +47 -4

lib/membership.js +2 -2
……@@ -13,7 +13,7 @@ import IdentityKey from 'keet-identity-key'
1313import keetEncoding from 'keet-identity-key/lib/encoding.js'
1414import c from 'compact-encoding'
1515import b4a from 'b4a'
16import { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-identity/lib/device-chain.js'
16import { attestDeviceChain, attestDeviceViaDevice, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-identity/lib/device-chain.js'
1717
1818const { ReceiptEncoding } = keetEncoding
1919
……@@ -50,4 +50,4 @@ export function makeVerifyAttest (rootIdentityPublicKey) {
5050 }
5151}
5252
53export { attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey }
53export { attestDeviceChain, attestDeviceViaDevice, deviceKeetKeyPair, rootIdentityPublicKey }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.14",
3 "version": "0.2.15",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",
test/membership.test.js +44 -1
……@@ -4,7 +4,7 @@
44import '../lib/bare-globals.js'
55import test from 'brittle'
66import b4a from 'b4a'
7import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
7import { makeAttest, makeVerifyAttest, attestDeviceChain, attestDeviceViaDevice, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
88
99const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
1010const DEVICE = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
……@@ -40,3 +40,46 @@ test('a device NOT attested by the root cannot prove membership', async (t) => {
4040 const proof = await attest(signData)
4141 t.absent(await verify(proof, signData, dev.publicKey), 'device attested by a different root is not a member')
4242})
43
44// ── CHAINED ATTESTATION: an existing device admits the next one, with NO root mnemonic ────────────
45// Adding a co-owner to a room needs a proof anchored at the room's ROOT identity, and attestDeviceChain
46// needs the root MNEMONIC — which is deliberately never stored on a node. Read alone that makes adding a
47// co-owner look impossible without recovering a secret nobody kept (measured: no root mnemonic exists on
48// any node in the fleet). It is not: keet's attestDevice lets an ALREADY-ATTESTED device extend the chain,
49// and the result passes the IDENTICAL admission gate because it resolves to the same root and epoch.
50//
51// This is the load-bearing check before provisioning a real co-owner: it exercises the actual admission
52// crypto (makeAttest/makeVerifyAttest, exactly what StateBaseV3 injects), not just the library call.
53test('a device attested BY ANOTHER DEVICE passes the same admission gate (no root mnemonic)', async (t) => {
54 const rootPub = await rootIdentityPublicKey(ROOT)
55
56 // The existing co-owner: root-attested, holds only its own mnemonic + proof (what a node actually has).
57 const parent = await deviceKeetKeyPair(DEVICE)
58 const parentProof = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: parent.publicKey })
59
60 // The NEW co-owner generates its own keypair; the parent vouches for it. No root secret involved.
61 const child = await deviceKeetKeyPair(OTHER)
62 const childProof = attestDeviceViaDevice({ parentKeyPair: parent, parentProof, devicePublicKey: child.publicKey })
63
64 const verify = makeVerifyAttest(rootPub)
65 const signData = b4a.from('localKey||baseKey admission bytes')
66
67 const childAttest = await makeAttest(child, childProof)(signData)
68 const r = await verify(childAttest, signData, child.publicKey)
69 t.ok(r, 'the chained device is admitted — root -> parent -> child resolves to the room root')
70 t.alike(b4a.from(r.identityPublicKey), b4a.from(rootPub), 'and resolves to the SAME root identity')
71 t.alike(b4a.from(r.devicePublicKey), b4a.from(child.publicKey), 'as the child device')
72
73 // The parent's own proof must be UNCHANGED — attestDevice mutates a decoded chain, so a wrapper that
74 // passed the object through would grow the parent's proof on every call and eventually break it.
75 const parentAttest = await makeAttest(parent, parentProof)(signData)
76 t.ok(await verify(parentAttest, signData, parent.publicKey), 'the parent still verifies after vouching')
77
78 // Identity binding still holds: the chained proof is bound to the CHILD, not reusable as the parent.
79 t.absent(await verify(childAttest, signData, parent.publicKey), 'the child proof cannot claim to be the parent')
80 t.absent(await verify(childAttest, b4a.from('tampered'), child.publicKey), 'and is still bound to its signData')
81
82 // And a chain under a DIFFERENT root is still rejected — no privilege is created by chaining.
83 const wrongVerify = makeVerifyAttest(await rootIdentityPublicKey('zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'))
84 t.absent(await wrongVerify(childAttest, signData, child.publicKey), 'chaining does not launder a foreign root')
85})