OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / admission.test.js

141 lines · javascript ref:
// admission.test.js — proving CONTROL, which this bridge has never done.
//
// ⛔ THE HOLE THIS CLOSES. `registrations.js` requires a `root` and checks only its SHAPE. Its own
// header says so — "it does not verify the chain proof: that is favela-identity's readAttested… it
// belongs at the route where the challenge lives" — and no route had a challenge, so `readAttested`
// appeared in this package only inside that comment. An identity root is PUBLIC. Presenting one
// proved nothing, so anyone could claim a name under anyone's identity, and `release()` compared a
// bare string, so anyone could BURN someone's actor — irreversibly, since the generation counter
// makes a reused name a different actor on purpose.
//
// hyper-social #65 §8's rule is that admission needs CONSENT and CONTROL and neither substitutes for
// the other. The policy half (open/invite) was built; this is the half that was only ever asserted.
//
// ⛔ WHY A CHALLENGE AND NOT JUST A SIGNATURE. `readAttested` is a pure function of the proof's bytes
// — no disk, no network, NO CLOCK — which is what lets it run inside an autobase apply. A verifier
// with no clock cannot detect replay, so freshness has to come from something the bridge issues.
// There is nowhere else to put it.
//
// ⛔ AND THE REQUEST IS INSIDE THE SIGNED BYTES. A proof covering only the nonce could be captured
// and replayed to claim a DIFFERENT name under the signer's root: consent proven, control proven,
// wrong name registered.

import test from 'brittle'
import b4a from 'b4a'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import { signAttested } from 'favela-identity/lib/attestation.js'
import { Admission } from '../lib/admission.js'

const FEED = 'f'.repeat(64)

// A throwaway identity with one device attested directly by its root — the shape a vault mints once.
async function identity () {
  const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
  const root = idk.identityKeyPair
  const kp = crypto.keyPair()
  const proof = await IdentityKey.bootstrap({ identity: root }, kp.publicKey)
  return {
    rootHex: b4a.toString(root.publicKey, 'hex'),
    cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') }
  }
}

test('★★ a claim signed by the root it names is admitted', async (t) => {
  const a = new Admission()
  const me = await identity()
  const { nonce } = a.challenge()
  const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)

  const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof })
  t.ok(r.ok, r.error || '')
})

test('⛔ a proof from a DIFFERENT identity is refused — this is the whole point', async (t) => {
  const a = new Admission()
  const me = await identity()
  const someoneElse = await identity()
  const { nonce } = a.challenge()
  // Signed by me, but claiming to be them. Their root is public, so this is the trivial attack.
  const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)

  const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: someoneElse.rootHex, proof })
  t.absent(r.ok, 'refused')
  t.is(r.error, 'proof does not attest that identity')
})

test('⛔ a captured proof cannot be replayed for a different name or feed', async (t) => {
  const me = await identity()

  let a = new Admission()
  let { nonce } = a.challenge()
  const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
  t.absent(a.verifyClaim({ nonce, username: 'bob', feedKey: FEED, root: me.rootHex, proof }).ok,
    'a different name is refused')

  a = new Admission()
  ;({ nonce } = a.challenge())
  const p2 = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
  t.absent(a.verifyClaim({ nonce, username: 'alice', feedKey: 'e'.repeat(64), root: me.rootHex, proof: p2 }).ok,
    'a different feed is refused')
})

test('⛔ a nonce is single-use, so a proof cannot be replayed at all', async (t) => {
  const a = new Admission()
  const me = await identity()
  const { nonce } = a.challenge()
  const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)

  t.ok(a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof }).ok, 'first use')
  const again = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof })
  t.absent(again.ok, 'second use refused')
  t.is(again.error, 'unknown or expired challenge')
})

test('⛔ an expired challenge is refused', async (t) => {
  let now = 1000
  const a = new Admission({ ttlMs: 100, now: () => now })
  const me = await identity()
  const { nonce } = a.challenge()
  const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
  now += 101
  t.is(a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof }).error,
    'unknown or expired challenge')
})

test('⛔ releasing needs the same proof — a burned actor cannot be undone', async (t) => {
  const a = new Admission()
  const me = await identity()
  const other = await identity()

  let { nonce } = a.challenge()
  const good = signAttested(a.releaseBytes({ nonce, username: 'alice' }), me.cred)
  t.ok(a.verifyRelease({ nonce, username: 'alice', root: me.rootHex, proof: good }).ok, 'the holder may')

  ;({ nonce } = a.challenge())
  const bad = signAttested(a.releaseBytes({ nonce, username: 'alice' }), other.cred)
  t.absent(a.verifyRelease({ nonce, username: 'alice', root: me.rootHex, proof: bad }).ok,
    'somebody else may NOT — knowing a public root used to be enough')
})

test('⚠ a malformed proof is refused rather than throwing — this runs on a request path', async (t) => {
  const a = new Admission()
  const me = await identity()
  for (const bad of ['', 'not-base64!!', b4a.toString(b4a.alloc(8), 'base64'), null]) {
    const { nonce } = a.challenge()
    const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof: bad })
    t.absent(r.ok, 'refused: ' + JSON.stringify(bad))
  }
})

test('★ nonces are unguessable and distinct', (t) => {
  const a = new Admission()
  const seen = new Set()
  for (let i = 0; i < 200; i++) {
    const { nonce } = a.challenge()
    t.ok(/^[0-9a-f]{64}$/.test(nonce), 'looks like 32 random bytes')
    seen.add(nonce)
  }
  t.is(seen.size, 200, 'no collisions')
})