import test from 'brittle'
import { an, Challenges } from '../index.js'
// ── Round-trip tests (functionality) ─────────────────────────────────
test('an.gen() produces a 132-char keypair string', async (t) => {
const k = await an.gen()
t.is(typeof k, 'string')
t.is(k.length, 132, 'keypair is 132 chars (44 pubkey + 88 secret, base64)')
})
test('an.hash() produces deterministic 44-char base64 SHA-256', async (t) => {
const h1 = await an.hash('Hello World')
const h2 = await an.hash('Hello World')
t.is(h1, h2, 'same input produces same hash')
t.is(h1.length, 44, 'SHA-256 in base64 is 44 chars')
// Wire compat: this is the canonical hash for "Hello World"
// (verified against evbogue's reference implementation)
t.is(h1, 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=', 'matches canonical anproto hash')
})
test('an.sign() / an.open() round-trip', async (t) => {
const k = await an.gen()
const h = await an.hash('hello world')
const sig = await an.sign(h, k)
const opened = await an.open(sig)
t.ok(opened.endsWith(h), 'opened message ends with the hash')
t.is(opened.length, 13 + h.length, '13-digit timestamp followed by hash')
})
test('an.sign() output starts with the public key', async (t) => {
const k = await an.gen()
const h = await an.hash('test')
const sig = await an.sign(h, k)
t.is(sig.substring(0, 44), an.publicKey(k), 'first 44 chars of sig are pubkey')
})
test('an.open() rejects tampered signatures', async (t) => {
const k = await an.gen()
const h = await an.hash('test')
const sig = await an.sign(h, k)
// Flip a byte in the signature body
const tampered = sig.substring(0, 50) + 'X' + sig.substring(51)
await t.exception(an.open(tampered), /Invalid ANProto signature/, 'tampered sig rejected')
})
test('an.open() rejects truncated messages', async (t) => {
await t.exception(an.open('too-short'), /too short|invalid/i)
})
test('an.parse() splits opened message into timestamp + hash', async (t) => {
const k = await an.gen()
const h = await an.hash('test data')
const sig = await an.sign(h, k)
const opened = await an.open(sig)
const parsed = an.parse(opened)
t.is(typeof parsed.timestamp, 'number')
t.ok(parsed.timestamp > 1000000000000, 'timestamp is a valid millisecond epoch')
t.is(parsed.hash, h, 'hash round-trips')
})
// ── Wire compatibility with evbogue's canonical implementation ───────
//
// Test vector generated using the canonical evbogue/ANProto with a fixed
// keypair (derived from sha256("anproto-test-seed")) and a fixed message.
// Ed25519 is deterministic, so the same key + same message ALWAYS produces
// the same signature bytes — regardless of which library generates it.
//
// If our implementation produces different bytes, we are NOT wire-compatible.
const VECTORS = {
// Keypair derived from sha256("anproto-test-seed") via canonical impl
pubkey: 'M8HUU6LqOD/vKavtsNJfPyY4Z5cyhVjzjViAD+FdeZo=',
keypair: 'M8HUU6LqOD/vKavtsNJfPyY4Z5cyhVjzjViAD+FdeZo=Co+J2nXPQgNfn+S988mYWaEW7i3QBNdsvnpJtWOoCD4zwdRTouo4P+8pq+2w0l8/JjhnlzKFWPONWIAP4V15mg==',
helloWorldHash: 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=',
// Wire-format signature of the literal message "TEST_MESSAGE_FOR_WIRE_COMPAT"
// produced by canonical evbogue ANProto with the keypair above.
fixedMessage: 'TEST_MESSAGE_FOR_WIRE_COMPAT',
fixedSignature: 'M8HUU6LqOD/vKavtsNJfPyY4Z5cyhVjzjViAD+FdeZo=Z2+ybxNj4K0JqIrOyV6cS829hH/qJhbtCd5wMgVD1FYnq//Z8WWnMbu1SXOvtAQGY4/DGwAado+WKjzt+ihOAlRFU1RfTUVTU0FHRV9GT1JfV0lSRV9DT01QQVQ='
}
test('wire compat: hash("Hello World") matches canonical anproto', async (t) => {
const h = await an.hash('Hello World')
t.is(h, VECTORS.helloWorldHash)
})
test('wire compat: open() decodes a signature produced by canonical anproto', async (t) => {
// The fixedSignature was produced by evbogue's TweetNaCl-based impl.
// Our open() must decode it byte-for-byte to be wire-compatible.
const opened = await an.open(VECTORS.fixedSignature)
t.is(opened, VECTORS.fixedMessage, 'our open() reads canonical signatures')
})
test('wire compat: pubkey extraction matches canonical', async (t) => {
// The first 44 chars of the keypair are the pubkey, in both impls.
t.is(an.publicKey(VECTORS.keypair), VECTORS.pubkey)
})
// ── Challenges (replay protection) ───────────────────────────────────
test('Challenges: generate + consume single-use', (t) => {
const c = new Challenges({ ttl: 60000 })
const challenge = c.generate()
t.is(typeof challenge, 'string')
t.is(challenge.length, 44, 'random 32 bytes base64 = 44 chars')
t.ok(c.has(challenge), 'challenge is active')
t.is(c.size, 1)
t.ok(c.consume(challenge), 'first consume succeeds')
t.absent(c.consume(challenge), 'second consume fails (single-use)')
t.absent(c.has(challenge), 'consumed challenge no longer active')
t.is(c.size, 0)
c.destroy()
})
test('Challenges: unknown challenge rejected', (t) => {
const c = new Challenges()
t.absent(c.consume('not-a-real-challenge'), 'unknown challenge fails')
t.absent(c.has('not-a-real-challenge'))
c.destroy()
})
test('Challenges: expired challenges rejected', async (t) => {
const c = new Challenges({ ttl: 50, cleanupInterval: 10 })
const challenge = c.generate()
await new Promise(r => setTimeout(r, 80))
t.absent(c.consume(challenge), 'expired challenge fails')
c.destroy()
})
test('Challenges: many challenges, only consumed ones removed', (t) => {
const c = new Challenges()
const ch1 = c.generate()
const ch2 = c.generate()
const ch3 = c.generate()
t.is(c.size, 3)
c.consume(ch2)
t.is(c.size, 2)
t.ok(c.has(ch1))
t.absent(c.has(ch2))
t.ok(c.has(ch3))
c.destroy()
})