| … | … | @@ -0,0 +1,142 @@ |
| 1 | // Attestation — the one place that knows keet-identity-key. |
| 2 | // |
| 3 | // A device proves it belongs to an identity by carrying a CHAIN PROOF: a signed path from the |
| 4 | // identity root down to the device's own public key. Verifying one is a pure function of the proof's |
| 5 | // bytes — no disk, no network, no clock — which is the property that lets it run inside `apply`. |
| 6 | // |
| 7 | // ── Why this file exists rather than calling IdentityKey directly ───────────────────────────── |
| 8 | // |
| 9 | // 1. ⛔ `IdentityKey.verify` THROWS on malformed input. Measured, not assumed: an empty buffer and |
| 10 | // a truncated one both raise "Out of bounds", and random bytes raise "uint is greater than the |
| 11 | // maximum safe integer". apply must be TOTAL — a throw mid-replay wedges the base and the op |
| 12 | // that caused it is already durable, so there is no version of "fix it and retry". Every call |
| 13 | // is wrapped here, once, instead of at each site. |
| 14 | // |
| 15 | // 2. It returns the CHAIN, which `verify` does not. `verify` gives back |
| 16 | // `{ receipt, identityPublicKey, devicePublicKey }` and drops the ancestry — but the ancestry is |
| 17 | // exactly what revocation needs, because `attestDevice(publicKey, parent, proof)` lets any |
| 18 | // device mint further devices using only its own keypair and its own proof. No mnemonic. So a |
| 19 | // compromised device can have descendants, and revoking it has to reach them. |
| 20 | // ⚠ That means decoding `ProofEncoding` from the package's `lib/`, which is internal. It is |
| 21 | // reachable (keet-identity-key declares no `exports` map), and 3.1↔3.2 are wire-identical, but |
| 22 | // it is a coupling and it lives HERE so there is one place to fix if it moves. |
| 23 | // |
| 24 | // 3. Everything comes back as hex STRINGS. The view stores strings, apply compares strings, and a |
| 25 | // Buffer that compared by identity instead of by value would be a silent authorisation bug. |
| 26 | |
| 27 | import IdentityKey from 'keet-identity-key' |
| 28 | import { ProofEncoding } from 'keet-identity-key/lib/encoding' |
| 29 | import c from 'compact-encoding' |
| 30 | import b4a from 'b4a' |
| 31 | |
| 32 | const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex')) |
| 33 | const isKeyHex = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/i.test(s) |
| 34 | |
| 35 | /** |
| 36 | * readAttested(proof, attestedData, expectedRootHex) -> { root, device, chain } | null |
| 37 | * |
| 38 | * root hex identity public key the chain resolves to — the ANCHOR |
| 39 | * device hex DEVICE key this proof attests — a real Ed25519 keypair, one that can sign |
| 40 | * chain hex device keys from the root's first child down to `device`. The ANCESTRY. |
| 41 | * |
| 42 | * The general form: verify that some identity attested `attestedData`, optionally pinning WHICH |
| 43 | * identity. One `verify` call settles both halves — it walks root → … → device checking each |
| 44 | * link's signature, then checks `proof.data.signature` against the LAST device in that chain. So a |
| 45 | * non-null return means exactly: *this root attested this device, and that device signed these |
| 46 | * bytes.* Neither half is trusted separately, and there is no second primitive to keep in step. |
| 47 | * |
| 48 | * ⛔ `expectedRootHex` is pinned INSIDE verify (`opts.expectedIdentity`) rather than string-compared |
| 49 | * afterwards. keet does the same at `keet-core/lib/core/invitations/index.js:122`. A comparison the |
| 50 | * caller has to remember to write is a comparison someone eventually forgets. |
| 51 | * |
| 52 | * ⚠ `attestedData` is not optional for a proof that carries `data`: `verify` on one, called without |
| 53 | * it, throws inside blake2b ("input must be a typed array") rather than returning null. Guarded |
| 54 | * here, because the caller is apply and apply may not throw for any reason at all. |
| 55 | * |
| 56 | * TOTAL: returns null for anything it cannot verify, and never throws. `proof` may be a Buffer or |
| 57 | * base64 — a daemon stores it as text and hands it back over a socket, so both arrive in practice. |
| 58 | */ |
| 59 | export function readAttested (proof, attestedData, expectedRootHex) { |
| 60 | if (!proof) return null |
| 61 | |
| 62 | let buf |
| 63 | try { |
| 64 | buf = typeof proof === 'string' ? b4a.from(proof, 'base64') : proof |
| 65 | } catch { |
| 66 | return null |
| 67 | } |
| 68 | if (!buf || buf.length === 0) return null |
| 69 | |
| 70 | if (attestedData !== undefined && (!attestedData || attestedData.length === 0)) return null |
| 71 | |
| 72 | const opts = {} |
| 73 | if (expectedRootHex !== undefined) { |
| 74 | if (!isKeyHex(expectedRootHex)) return null |
| 75 | opts.expectedIdentity = b4a.from(expectedRootHex, 'hex') |
| 76 | } |
| 77 | |
| 78 | // Two independent throwers: verify() on malformed bytes, and decode() on bytes that verify |
| 79 | // happened to accept. Both are inside the same guard. |
| 80 | try { |
| 81 | const verified = attestedData === undefined |
| 82 | ? IdentityKey.verify(buf, null, opts) |
| 83 | : IdentityKey.verify(buf, attestedData, opts) |
| 84 | if (!verified) return null |
| 85 | |
| 86 | const decoded = c.decode(ProofEncoding, buf) |
| 87 | if (!decoded || !Array.isArray(decoded.chain)) return null |
| 88 | |
| 89 | return { |
| 90 | root: hex(verified.identityPublicKey), |
| 91 | device: hex(verified.devicePublicKey), |
| 92 | chain: decoded.chain.map((link) => hex(link.publicKey)) |
| 93 | } |
| 94 | } catch { |
| 95 | return null |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * readProof(proof, writerKeyHex) -> { root, device, chain } | null |
| 101 | * |
| 102 | * The admission case: `writerKeyHex` is the hypercore writer key being admitted, verified as |
| 103 | * ATTESTED DATA. That indirection is the whole design: |
| 104 | * |
| 105 | * - A writer core key is MANIFEST-derived. Nobody holds its secret half, so it can never sign, |
| 106 | * so a chain naming it can never be extended — measured: attesting one and then trying to |
| 107 | * attest from it is REFUSED. |
| 108 | * - A device key is an ordinary keypair. It CAN sign, so an already-linked device can attest the |
| 109 | * next one with no mnemonic — which is exactly what pear-git-v3's DeviceLinker does over |
| 110 | * blind-pairing, shipping { deviceSecretKey, deviceProof } and never the root. |
| 111 | * |
| 112 | * So the device key carries the identity, and the writer key is bound to it as data. |
| 113 | */ |
| 114 | export function readProof (proof, writerKeyHex) { |
| 115 | if (writerKeyHex === undefined) return readAttested(proof, undefined) |
| 116 | if (typeof writerKeyHex !== 'string' || writerKeyHex.length === 0) return null |
| 117 | |
| 118 | let attested |
| 119 | try { attested = b4a.from(writerKeyHex, 'hex') } catch { return null } |
| 120 | if (attested.length === 0) return null |
| 121 | |
| 122 | return readAttested(proof, attested) |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * Sign arbitrary bytes as this device, carrying the chain that proves who it is. |
| 127 | * |
| 128 | * The inverse of `readAttested`. `attestData` re-encodes the credential's proof with a `data` |
| 129 | * signature over `hash(bytes)` made by the device's own keypair — so the result is a self-contained |
| 130 | * "identity R, via device D, signed these bytes", verifiable with no lookup and no state. |
| 131 | * |
| 132 | * ⚠ It OVERWRITES `proof.data`, so one credential proof yields one signature per call and each call |
| 133 | * must start from the stored credential rather than from a previous result. Decoding from the |
| 134 | * stored base64 each time is what keeps that true. |
| 135 | */ |
| 136 | export function signAttested (bytes, cred) { |
| 137 | if (!cred || !cred.keyPair || !cred.proof) throw new Error('signing needs a device credential { keyPair, proof }') |
| 138 | const proof = typeof cred.proof === 'string' ? b4a.from(cred.proof, 'base64') : cred.proof |
| 139 | return b4a.toString(IdentityKey.attestData(bytes, cred.keyPair, proof), 'base64') |
| 140 | } |
| 141 | |
| 142 | export default { readProof, readAttested, signAttested } |