| … | … | @@ -0,0 +1,165 @@ |
| 1 | /** |
| 2 | * Binding a release to the identity that owns the name (forge org #144 step 3). |
| 3 | * |
| 4 | * A release signature proves the entry was signed by the device key inside it. |
| 5 | * It does not prove that device may publish under this app NAME — nothing |
| 6 | * connected the release registry to the name registry. This is that link: |
| 7 | * resolve the entry's device to its identity root via the attestation chain, |
| 8 | * and require that root to be the one the NameRegistry claim is anchored to. |
| 9 | * |
| 10 | * It is also the payoff for #142. Anchored to a device key, this check would |
| 11 | * refuse every release published from any other device the same identity owns. |
| 12 | * |
| 13 | * Transitional: a missing chain or a pre-#142 claim is INDETERMINATE, not a |
| 14 | * refusal — no entry in the wild carries a chain yet, and all 13 live claims |
| 15 | * are still anchored to a device key. Only a present-and-contradictory pair is |
| 16 | * refused. Enforcement is a later flip. |
| 17 | */ |
| 18 | |
| 19 | import test from 'brittle' |
| 20 | import IdentityKey from 'keet-identity-key' |
| 21 | import crypto from 'hypercore-crypto' |
| 22 | import b4a from 'b4a' |
| 23 | |
| 24 | import { |
| 25 | checkPublisherTrust, |
| 26 | assertPublisherTrust, |
| 27 | TRUST_OK, |
| 28 | TRUST_INDETERMINATE, |
| 29 | TRUST_REFUSED |
| 30 | } from '../lib/publisher-trust.js' |
| 31 | |
| 32 | // A root identity with one or more attested devices. |
| 33 | async function makeIdentity () { |
| 34 | const mnemonic = IdentityKey.generateMnemonic() |
| 35 | const identityKey = await IdentityKey.from({ mnemonic }) |
| 36 | const root = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex') |
| 37 | |
| 38 | async function attest () { |
| 39 | const device = crypto.keyPair() |
| 40 | const proof = await identityKey.bootstrap(device.publicKey) |
| 41 | return { |
| 42 | devicePubkey: b4a.toString(device.publicKey, 'hex'), |
| 43 | chain: b4a.toString(proof, 'base64') |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | return { root, attest } |
| 48 | } |
| 49 | |
| 50 | function entryFor (device, overrides = {}) { |
| 51 | return { |
| 52 | name: 'demo', |
| 53 | version: '1.0.0', |
| 54 | treeHash: 'a'.repeat(64), |
| 55 | publisherDevicePubkey: device.devicePubkey, |
| 56 | publisherChain: device.chain, |
| 57 | ...overrides |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | const claimFor = (root) => ({ name: 'demo', publisher: { identityRoot: root } }) |
| 62 | |
| 63 | test('a release from the owning identity is accepted', async (t) => { |
| 64 | const owner = await makeIdentity() |
| 65 | const device = await owner.attest() |
| 66 | |
| 67 | const r = checkPublisherTrust(entryFor(device), claimFor(owner.root)) |
| 68 | t.is(r.status, TRUST_OK, r.reason) |
| 69 | t.is(r.root, owner.root, 'resolved to the owning root') |
| 70 | }) |
| 71 | |
| 72 | test('a SECOND device of the same identity is accepted — the point of #142', async (t) => { |
| 73 | const owner = await makeIdentity() |
| 74 | const laptop = await owner.attest() |
| 75 | const server = await owner.attest() |
| 76 | |
| 77 | t.not(laptop.devicePubkey, server.devicePubkey, 'genuinely different devices') |
| 78 | |
| 79 | // The name was claimed from one device; the release was published from the |
| 80 | // other. Anchored to a device key this would be refused. |
| 81 | t.is(checkPublisherTrust(entryFor(laptop), claimFor(owner.root)).status, TRUST_OK) |
| 82 | t.is(checkPublisherTrust(entryFor(server), claimFor(owner.root)).status, TRUST_OK) |
| 83 | }) |
| 84 | |
| 85 | test('a release from a DIFFERENT identity is refused', async (t) => { |
| 86 | const owner = await makeIdentity() |
| 87 | const attacker = await makeIdentity() |
| 88 | const attackerDevice = await attacker.attest() |
| 89 | |
| 90 | const r = checkPublisherTrust(entryFor(attackerDevice), claimFor(owner.root)) |
| 91 | t.is(r.status, TRUST_REFUSED) |
| 92 | t.ok(/is owned by/.test(r.reason), r.reason) |
| 93 | }) |
| 94 | |
| 95 | test('a chain lifted from someone else is refused (it must attest THIS device)', async (t) => { |
| 96 | const owner = await makeIdentity() |
| 97 | const ownerDevice = await owner.attest() |
| 98 | const other = await owner.attest() |
| 99 | |
| 100 | // Correct root, valid chain — but the chain describes a different device |
| 101 | // than the one that signed the entry. |
| 102 | const frankenstein = entryFor(ownerDevice, { publisherChain: other.chain }) |
| 103 | |
| 104 | const r = checkPublisherTrust(frankenstein, claimFor(owner.root)) |
| 105 | t.is(r.status, TRUST_REFUSED) |
| 106 | t.ok(/but the entry was signed by/.test(r.reason), r.reason) |
| 107 | }) |
| 108 | |
| 109 | test('a garbage chain is refused, not treated as missing', async (t) => { |
| 110 | const owner = await makeIdentity() |
| 111 | const device = await owner.attest() |
| 112 | |
| 113 | const r = checkPublisherTrust( |
| 114 | entryFor(device, { publisherChain: b4a.toString(b4a.from('not a proof'), 'base64') }), |
| 115 | claimFor(owner.root) |
| 116 | ) |
| 117 | t.is(r.status, TRUST_REFUSED, r.reason) |
| 118 | }) |
| 119 | |
| 120 | test('indeterminate: no claim, no chain, or a pre-#142 device-anchored claim', async (t) => { |
| 121 | const owner = await makeIdentity() |
| 122 | const device = await owner.attest() |
| 123 | |
| 124 | t.is( |
| 125 | checkPublisherTrust(entryFor(device), null).status, |
| 126 | TRUST_INDETERMINATE, |
| 127 | 'unclaimed name' |
| 128 | ) |
| 129 | |
| 130 | t.is( |
| 131 | checkPublisherTrust(entryFor(device, { publisherChain: null }), claimFor(owner.root)).status, |
| 132 | TRUST_INDETERMINATE, |
| 133 | 'entry predates signing' |
| 134 | ) |
| 135 | |
| 136 | // What every live claim looks like today: anchored to the device key. |
| 137 | const legacyClaim = claimFor(device.devicePubkey) |
| 138 | t.is( |
| 139 | checkPublisherTrust(entryFor(device, { publisherChain: null }), legacyClaim).status, |
| 140 | TRUST_INDETERMINATE, |
| 141 | 'pre-#142 claim anchored to a device key' |
| 142 | ) |
| 143 | }) |
| 144 | |
| 145 | test('assertPublisherTrust throws only on a contradiction', async (t) => { |
| 146 | const owner = await makeIdentity() |
| 147 | const attacker = await makeIdentity() |
| 148 | const device = await owner.attest() |
| 149 | const attackerDevice = await attacker.attest() |
| 150 | |
| 151 | t.is(assertPublisherTrust(entryFor(device), claimFor(owner.root)).status, TRUST_OK) |
| 152 | |
| 153 | // Indeterminate must pass — otherwise nothing installs until every publisher |
| 154 | // has re-published and every claim has been re-anchored. |
| 155 | t.is( |
| 156 | assertPublisherTrust(entryFor(device, { publisherChain: null }), claimFor(owner.root)).status, |
| 157 | TRUST_INDETERMINATE |
| 158 | ) |
| 159 | |
| 160 | t.exception( |
| 161 | () => assertPublisherTrust(entryFor(attackerDevice), claimFor(owner.root), { name: 'demo' }), |
| 162 | /refusing demo@1\.0\.0/, |
| 163 | 'contradiction throws' |
| 164 | ) |
| 165 | }) |