/**
* Binding a release to the identity that owns the name (forge org #144 step 3).
*
* A release signature proves the entry was signed by the device key inside it.
* It does not prove that device may publish under this app NAME — nothing
* connected the release registry to the name registry. This is that link:
* resolve the entry's device to its identity root via the attestation chain,
* and require that root to be the one the NameRegistry claim is anchored to.
*
* It is also the payoff for #142. Anchored to a device key, this check would
* refuse every release published from any other device the same identity owns.
*
* Transitional: a missing chain or a pre-#142 claim is INDETERMINATE, not a
* refusal — no entry in the wild carries a chain yet, and all 13 live claims
* are still anchored to a device key. Only a present-and-contradictory pair is
* refused. Enforcement is a later flip.
*/
import test from 'brittle'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import {
checkPublisherTrust,
assertPublisherTrust,
TRUST_OK,
TRUST_INDETERMINATE,
TRUST_REFUSED
} from '../lib/publisher-trust.js'
// A root identity with one or more attested devices.
async function makeIdentity () {
const mnemonic = IdentityKey.generateMnemonic()
const identityKey = await IdentityKey.from({ mnemonic })
const root = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')
async function attest () {
const device = crypto.keyPair()
const proof = await identityKey.bootstrap(device.publicKey)
return {
devicePubkey: b4a.toString(device.publicKey, 'hex'),
chain: b4a.toString(proof, 'base64')
}
}
return { root, attest }
}
function entryFor (device, overrides = {}) {
return {
name: 'demo',
version: '1.0.0',
treeHash: 'a'.repeat(64),
publisherDevicePubkey: device.devicePubkey,
publisherChain: device.chain,
...overrides
}
}
const claimFor = (root) => ({ name: 'demo', publisher: { identityRoot: root } })
test('a release from the owning identity is accepted', async (t) => {
const owner = await makeIdentity()
const device = await owner.attest()
const r = checkPublisherTrust(entryFor(device), claimFor(owner.root))
t.is(r.status, TRUST_OK, r.reason)
t.is(r.root, owner.root, 'resolved to the owning root')
})
test('a SECOND device of the same identity is accepted — the point of #142', async (t) => {
const owner = await makeIdentity()
const laptop = await owner.attest()
const server = await owner.attest()
t.not(laptop.devicePubkey, server.devicePubkey, 'genuinely different devices')
// The name was claimed from one device; the release was published from the
// other. Anchored to a device key this would be refused.
t.is(checkPublisherTrust(entryFor(laptop), claimFor(owner.root)).status, TRUST_OK)
t.is(checkPublisherTrust(entryFor(server), claimFor(owner.root)).status, TRUST_OK)
})
test('a release from a DIFFERENT identity is refused', async (t) => {
const owner = await makeIdentity()
const attacker = await makeIdentity()
const attackerDevice = await attacker.attest()
const r = checkPublisherTrust(entryFor(attackerDevice), claimFor(owner.root))
t.is(r.status, TRUST_REFUSED)
t.ok(/is owned by/.test(r.reason), r.reason)
})
test('a chain lifted from someone else is refused (it must attest THIS device)', async (t) => {
const owner = await makeIdentity()
const ownerDevice = await owner.attest()
const other = await owner.attest()
// Correct root, valid chain — but the chain describes a different device
// than the one that signed the entry.
const frankenstein = entryFor(ownerDevice, { publisherChain: other.chain })
const r = checkPublisherTrust(frankenstein, claimFor(owner.root))
t.is(r.status, TRUST_REFUSED)
t.ok(/but the entry was signed by/.test(r.reason), r.reason)
})
test('a garbage chain is refused, not treated as missing', async (t) => {
const owner = await makeIdentity()
const device = await owner.attest()
const r = checkPublisherTrust(
entryFor(device, { publisherChain: b4a.toString(b4a.from('not a proof'), 'base64') }),
claimFor(owner.root)
)
t.is(r.status, TRUST_REFUSED, r.reason)
})
test('indeterminate: no claim, no chain, or a pre-#142 device-anchored claim', async (t) => {
const owner = await makeIdentity()
const device = await owner.attest()
t.is(
checkPublisherTrust(entryFor(device), null).status,
TRUST_INDETERMINATE,
'unclaimed name'
)
t.is(
checkPublisherTrust(entryFor(device, { publisherChain: null }), claimFor(owner.root)).status,
TRUST_INDETERMINATE,
'entry predates signing'
)
// What every live claim looks like today: anchored to the device key.
const legacyClaim = claimFor(device.devicePubkey)
t.is(
checkPublisherTrust(entryFor(device, { publisherChain: null }), legacyClaim).status,
TRUST_INDETERMINATE,
'pre-#142 claim anchored to a device key'
)
})
test('assertPublisherTrust throws only on a contradiction', async (t) => {
const owner = await makeIdentity()
const attacker = await makeIdentity()
const device = await owner.attest()
const attackerDevice = await attacker.attest()
t.is(assertPublisherTrust(entryFor(device), claimFor(owner.root)).status, TRUST_OK)
// Indeterminate must pass — otherwise nothing installs until every publisher
// has re-published and every claim has been re-anchored.
t.is(
assertPublisherTrust(entryFor(device, { publisherChain: null }), claimFor(owner.root)).status,
TRUST_INDETERMINATE
)
t.exception(
() => assertPublisherTrust(entryFor(attackerDevice), claimFor(owner.root), { name: 'demo' }),
/refusing demo@1\.0\.0/,
'contradiction throws'
)
})