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)
})