OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity / lib / attestation.js

143 lines · javascript ref:
// Attestation — the one place that knows keet-identity-key.
//
// A device proves it belongs to an identity by carrying a CHAIN PROOF: a signed path from the
// identity root down to the device's own public key. Verifying one is a pure function of the proof's
// bytes — no disk, no network, no clock — which is the property that lets it run inside `apply`.
//
// ── Why this file exists rather than calling IdentityKey directly ─────────────────────────────
//
//  1. ⛔ `IdentityKey.verify` THROWS on malformed input. Measured, not assumed: an empty buffer and
//     a truncated one both raise "Out of bounds", and random bytes raise "uint is greater than the
//     maximum safe integer". apply must be TOTAL — a throw mid-replay wedges the base and the op
//     that caused it is already durable, so there is no version of "fix it and retry". Every call
//     is wrapped here, once, instead of at each site.
//
//  2. It returns the CHAIN, which `verify` does not. `verify` gives back
//     `{ receipt, identityPublicKey, devicePublicKey }` and drops the ancestry — but the ancestry is
//     exactly what revocation needs, because `attestDevice(publicKey, parent, proof)` lets any
//     device mint further devices using only its own keypair and its own proof. No mnemonic. So a
//     compromised device can have descendants, and revoking it has to reach them.
//     ⚠ That means decoding `ProofEncoding` from the package's `lib/`, which is internal. It is
//     reachable (keet-identity-key declares no `exports` map), and 3.1↔3.2 are wire-identical, but
//     it is a coupling and it lives HERE so there is one place to fix if it moves.
//
//  3. Everything comes back as hex STRINGS. The view stores strings, apply compares strings, and a
//     Buffer that compared by identity instead of by value would be a silent authorisation bug.

import IdentityKey from 'keet-identity-key'
import { ProofEncoding } from 'keet-identity-key/lib/encoding'
import c from 'compact-encoding'
import b4a from 'b4a'

const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
const isKeyHex = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/i.test(s)

/**
 * readAttested(proof, attestedData, expectedRootHex) -> { root, device, chain } | null
 *
 *   root    hex identity public key the chain resolves to — the ANCHOR
 *   device  hex DEVICE key this proof attests — a real Ed25519 keypair, one that can sign
 *   chain   hex device keys from the root's first child down to `device`. The ANCESTRY.
 *
 * The general form: verify that some identity attested `attestedData`, optionally pinning WHICH
 * identity. One `verify` call settles both halves — it walks root → … → device checking each
 * link's signature, then checks `proof.data.signature` against the LAST device in that chain. So a
 * non-null return means exactly: *this root attested this device, and that device signed these
 * bytes.* Neither half is trusted separately, and there is no second primitive to keep in step.
 *
 * ⛔ `expectedRootHex` is pinned INSIDE verify (`opts.expectedIdentity`) rather than string-compared
 * afterwards. keet does the same at `keet-core/lib/core/invitations/index.js:122`. A comparison the
 * caller has to remember to write is a comparison someone eventually forgets.
 *
 * ⚠ `attestedData` is not optional for a proof that carries `data`: `verify` on one, called without
 * it, throws inside blake2b ("input must be a typed array") rather than returning null. Guarded
 * here, because the caller is apply and apply may not throw for any reason at all.
 *
 * TOTAL: returns null for anything it cannot verify, and never throws. `proof` may be a Buffer or
 * base64 — a daemon stores it as text and hands it back over a socket, so both arrive in practice.
 */
export function readAttested (proof, attestedData, expectedRootHex) {
  if (!proof) return null

  let buf
  try {
    buf = typeof proof === 'string' ? b4a.from(proof, 'base64') : proof
  } catch {
    return null
  }
  if (!buf || buf.length === 0) return null

  if (attestedData !== undefined && (!attestedData || attestedData.length === 0)) return null

  const opts = {}
  if (expectedRootHex !== undefined) {
    if (!isKeyHex(expectedRootHex)) return null
    opts.expectedIdentity = b4a.from(expectedRootHex, 'hex')
  }

  // Two independent throwers: verify() on malformed bytes, and decode() on bytes that verify
  // happened to accept. Both are inside the same guard.
  try {
    const verified = attestedData === undefined
      ? IdentityKey.verify(buf, null, opts)
      : IdentityKey.verify(buf, attestedData, opts)
    if (!verified) return null

    const decoded = c.decode(ProofEncoding, buf)
    if (!decoded || !Array.isArray(decoded.chain)) return null

    return {
      root: hex(verified.identityPublicKey),
      device: hex(verified.devicePublicKey),
      chain: decoded.chain.map((link) => hex(link.publicKey))
    }
  } catch {
    return null
  }
}

/**
 * readProof(proof, writerKeyHex) -> { root, device, chain } | null
 *
 * The admission case: `writerKeyHex` is the hypercore writer key being admitted, verified as
 * ATTESTED DATA. That indirection is the whole design:
 *
 *   - A writer core key is MANIFEST-derived. Nobody holds its secret half, so it can never sign,
 *     so a chain naming it can never be extended — measured: attesting one and then trying to
 *     attest from it is REFUSED.
 *   - A device key is an ordinary keypair. It CAN sign, so an already-linked device can attest the
 *     next one with no mnemonic — which is exactly what pear-git-v3's DeviceLinker does over
 *     blind-pairing, shipping { deviceSecretKey, deviceProof } and never the root.
 *
 * So the device key carries the identity, and the writer key is bound to it as data.
 */
export function readProof (proof, writerKeyHex) {
  if (writerKeyHex === undefined) return readAttested(proof, undefined)
  if (typeof writerKeyHex !== 'string' || writerKeyHex.length === 0) return null

  let attested
  try { attested = b4a.from(writerKeyHex, 'hex') } catch { return null }
  if (attested.length === 0) return null

  return readAttested(proof, attested)
}

/**
 * Sign arbitrary bytes as this device, carrying the chain that proves who it is.
 *
 * The inverse of `readAttested`. `attestData` re-encodes the credential's proof with a `data`
 * signature over `hash(bytes)` made by the device's own keypair — so the result is a self-contained
 * "identity R, via device D, signed these bytes", verifiable with no lookup and no state.
 *
 * ⚠ It OVERWRITES `proof.data`, so one credential proof yields one signature per call and each call
 * must start from the stored credential rather than from a previous result. Decoding from the
 * stored base64 each time is what keeps that true.
 */
export function signAttested (bytes, cred) {
  if (!cred || !cred.keyPair || !cred.proof) throw new Error('signing needs a device credential { keyPair, proof }')
  const proof = typeof cred.proof === 'string' ? b4a.from(cred.proof, 'base64') : cred.proof
  return b4a.toString(IdentityKey.attestData(bytes, cred.keyPair, proof), 'base64')
}

export default { readProof, readAttested, signAttested }