| … | … | @@ -4,7 +4,7 @@ |
| 4 | 4 | import '../lib/bare-globals.js' |
| 5 | 5 | import test from 'brittle' |
| 6 | 6 | import b4a from 'b4a' |
| 7 | | import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js' |
| 7 | import { makeAttest, makeVerifyAttest, attestDeviceChain, attestDeviceViaDevice, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js' |
| 8 | 8 | |
| 9 | 9 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' |
| 10 | 10 | const 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) => { |
| 40 | 40 | const proof = await attest(signData) |
| 41 | 41 | t.absent(await verify(proof, signData, dev.publicKey), 'device attested by a different root is not a member') |
| 42 | 42 | }) |
| 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. |
| 53 | test('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 | }) |