OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / envelope.test.js

242 lines · javascript ref:
import test from 'brittle'
import { an } from 'anproto'

import {
  verifyEnvelope,
  signTestEnvelope,
  EnvelopeError,
  FRESHNESS_WINDOW_MS,
  PUBKEY_B64_LEN
} from '../lib/auth/envelope.js'
import { ReplayCache } from '../lib/auth/replay-cache.js'

// Phase 2a tests — signed envelope verification + replay protection.

// anproto's gen/sign/hash/open are all async (return promises).
// Our envelope + signTestEnvelope are async too.

test('envelope: happy path — valid signed envelope verifies', async (t) => {
  const kp = await an.gen()
  const env = await signTestEnvelope({
    keypair: kp,
    method: 'create-issue',
    params: { repo: 'test', title: 'hi' }
  })

  const result = await verifyEnvelope(env)
  t.is(result.method, 'anproto', 'self-rooted → method anproto')
  t.is(result.devicePubkey.length, 64, 'devicePubkey is 64-char hex')
  t.is(result.rootPubkey, result.devicePubkey, 'rootPubkey equals devicePubkey')
  t.is(result.decoded.method, 'create-issue', 'method decoded')
  t.is(result.decoded.params.repo, 'test', 'params preserved')
  t.ok(result.decoded.nonce, 'nonce present')
})

test('envelope: tampered payload is rejected', async (t) => {
  const kp = await an.gen()
  const env = await signTestEnvelope({
    keypair: kp,
    method: 'create-issue',
    params: { repo: 'test' }
  })
  const tampered = { ...env, payload: env.payload.replace('test', 'other') }
  try {
    await verifyEnvelope(tampered)
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err instanceof EnvelopeError, 'EnvelopeError thrown')
    t.is(err.code, 'HASH_MISMATCH', 'hash mismatch detected')
  }
})

test('envelope: malformed sig is rejected', async (t) => {
  const env = {
    payload: JSON.stringify({ method: 'x', params: {}, nonce: 'abcdefgh' }),
    sig: 'garbage'
  }
  try {
    await verifyEnvelope(env)
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err instanceof EnvelopeError, 'EnvelopeError thrown')
    t.is(err.code, 'BAD_SIG', 'bad sig code')
  }
})

test('envelope: old ts is rejected', async (t) => {
  const kp = await an.gen()
  const env = await signTestEnvelope({
    keypair: kp,
    method: 'x',
    params: {},
    ts: Date.now() - FRESHNESS_WINDOW_MS - 10_000
  })
  try {
    await verifyEnvelope(env)
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.code, 'TS_OLD', 'old ts rejected')
  }
})

test('envelope: future ts beyond window is rejected', async (t) => {
  const kp = await an.gen()
  const env = await signTestEnvelope({
    keypair: kp,
    method: 'x',
    params: {},
    ts: Date.now() + FRESHNESS_WINDOW_MS + 10_000
  })
  try {
    await verifyEnvelope(env)
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.code, 'TS_FUTURE', 'future ts rejected')
  }
})

test('envelope: missing nonce rejected', async (t) => {
  const kp = await an.gen()
  const payload = JSON.stringify({ method: 'x', params: {} })
  const hash = await an.hash(payload)
  const sig = await an.sign(hash, kp)
  try {
    await verifyEnvelope({ payload, sig })
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.code, 'NO_NONCE', 'missing nonce rejected')
  }
})

test('envelope: short nonce rejected', async (t) => {
  const kp = await an.gen()
  const payload = JSON.stringify({ method: 'x', params: {}, nonce: 'abc' })
  const hash = await an.hash(payload)
  const sig = await an.sign(hash, kp)
  try {
    await verifyEnvelope({ payload, sig })
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.code, 'NO_NONCE', 'short nonce rejected')
  }
})

test('envelope: chain with no verifier rejected', async (t) => {
  const kp = await an.gen()
  const env = await signTestEnvelope({
    keypair: kp, method: 'x', params: {}
  })
  env.chain = 'some-chain-blob'
  try {
    await verifyEnvelope(env)
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.code, 'NO_KEET_VERIFIER', 'chain requires keetVerifier')
  }
})

test('envelope: chain with verifier returns rootPubkey', async (t) => {
  const kp = await an.gen()
  const env = await signTestEnvelope({
    keypair: kp, method: 'x', params: {}
  })
  env.chain = 'fake-chain'

  let verifierGotChain = false
  let verifierGotDevice = false
  const fakeVerifier = (chain, devicePubkey) => {
    if (chain) verifierGotChain = true
    if (devicePubkey) verifierGotDevice = true
    return { rootPubkey: '0123456789abcdef'.repeat(4) }
  }

  const result = await verifyEnvelope(env, { keetVerifier: fakeVerifier })
  t.ok(verifierGotChain, 'verifier got chain')
  t.ok(verifierGotDevice, 'verifier got device pubkey')
  t.is(result.method, 'keet', 'method is keet')
  t.is(result.rootPubkey.length, 64, 'rootPubkey is 64 hex')
  t.not(result.rootPubkey, result.devicePubkey, 'root differs from device')
})

// --- Replay cache ---

test('replay-cache: first seen, then replay rejected', async (t) => {
  const cache = new ReplayCache({ ttlMs: 60_000 })
  const kp = await an.gen()
  const env = await signTestEnvelope({
    keypair: kp, method: 'x', params: {}
  })

  const r1 = await verifyEnvelope(env, { replayCache: cache })
  t.ok(r1, 'first call succeeds')

  try {
    await verifyEnvelope(env, { replayCache: cache })
    t.fail('should have thrown on replay')
  } catch (err) {
    t.is(err.code, 'NONCE_REPLAY', 'replay detected')
  }
  cache.close()
})

test('replay-cache: different nonces both succeed', async (t) => {
  const cache = new ReplayCache({ ttlMs: 60_000 })
  const kp = await an.gen()

  const e1 = await signTestEnvelope({ keypair: kp, method: 'x', params: {} })
  const e2 = await signTestEnvelope({ keypair: kp, method: 'x', params: {} })

  t.ok(await verifyEnvelope(e1, { replayCache: cache }), 'first verifies')
  t.ok(await verifyEnvelope(e2, { replayCache: cache }), 'second (different nonce) verifies')
  cache.close()
})

test('replay-cache: different pubkeys with same nonce both succeed', async (t) => {
  const cache = new ReplayCache({ ttlMs: 60_000 })
  const kpA = await an.gen()
  const kpB = await an.gen()
  const nonce = 'shared-nonce-12345'

  const eA = await signTestEnvelope({ keypair: kpA, method: 'x', params: {}, nonce })
  const eB = await signTestEnvelope({ keypair: kpB, method: 'x', params: {}, nonce })

  t.ok(await verifyEnvelope(eA, { replayCache: cache }), 'pubkey A verifies')
  t.ok(await verifyEnvelope(eB, { replayCache: cache }), 'pubkey B (same nonce, different user) verifies')
  cache.close()
})

test('replay-cache: TTL expiry clears entry', (t) => {
  const cache = new ReplayCache({ ttlMs: 50 })

  cache.add('pk', 'nonce1', Date.now())
  t.ok(cache.seen('pk', 'nonce1'), 'seen right after add')

  // Force expiry without waiting
  cache._entries.set('pk:nonce1', Date.now() - 1000)
  t.not(cache.seen('pk', 'nonce1'), 'expired entry not seen')
  cache.close()
})

test('replay-cache: maxEntries evicts oldest', (t) => {
  const cache = new ReplayCache({ ttlMs: 60_000, maxEntries: 3 })
  const now = Date.now()
  cache.add('pk', 'n1', now)
  cache.add('pk', 'n2', now + 1)
  cache.add('pk', 'n3', now + 2)
  cache.add('pk', 'n4', now + 3) // should evict n1
  t.is(cache.size, 3, 'capped at maxEntries')
  t.not(cache.seen('pk', 'n1'), 'oldest evicted')
  t.ok(cache.seen('pk', 'n4'), 'newest retained')
  cache.close()
})

// --- Constants sanity ---

test('constants: FRESHNESS_WINDOW_MS is 5 min', (t) => {
  t.is(FRESHNESS_WINDOW_MS, 5 * 60 * 1000)
})

test('constants: PUBKEY_B64_LEN is 44', (t) => {
  t.is(PUBKEY_B64_LEN, 44)
})