import test from 'brittle'
import { ANProto } from '../lib/anproto.js'
import b4a from 'b4a'
test('gen() produces valid keypair', (t) => {
const kp = ANProto.gen()
t.is(kp.publicKey.length, 32, 'publicKey is 32 bytes')
t.is(kp.secretKey.length, 64, 'secretKey is 64 bytes')
t.is(typeof kp.keypair, 'string', 'keypair is a string')
t.is(kp.keypair.length, 132, 'keypair is 132 chars (44 pub + 88 sec)')
})
test('loadKeypair() round-trips with gen()', (t) => {
const original = ANProto.gen()
const loaded = ANProto.loadKeypair(original.keypair)
t.ok(b4a.equals(loaded.publicKey, original.publicKey), 'publicKey matches')
t.ok(b4a.equals(loaded.secretKey, original.secretKey), 'secretKey matches')
})
test('loadKeypair() rejects invalid lengths', (t) => {
t.exception(() => ANProto.loadKeypair('too-short'))
t.exception(() => ANProto.loadKeypair('a'.repeat(131)))
t.exception(() => ANProto.loadKeypair('a'.repeat(133)))
})
test('getPublicKey() extracts base64 pubkey', (t) => {
const kp = ANProto.gen()
const pubkey = ANProto.getPublicKey(kp.keypair)
t.is(pubkey.length, 44, '44-char base64')
t.is(pubkey, kp.keypair.substring(0, 44), 'matches first 44 chars')
})
// --- Challenge-Response Mode ---
test('sign/verify challenge-response round-trip (canonical format)', (t) => {
const kp = ANProto.gen()
const challenge = 'test-challenge-12345'
const sig = ANProto.sign(challenge, kp)
t.is(typeof sig, 'string', 'signature is string')
// Canonical format: 44-char pubkey + base64(combined envelope)
t.ok(sig.length > 44, 'signature is longer than just pubkey')
t.is(sig.substring(0, 44), b4a.toString(kp.publicKey, 'base64'), 'signature starts with pubkey')
const result = ANProto.verify(challenge, sig)
t.ok(result.valid, 'valid signature verifies')
t.is(result.publicKey, b4a.toString(kp.publicKey, 'base64'), 'extracted pubkey matches')
t.ok(result.timestamp > 0, 'has timestamp')
})
test('verify rejects wrong challenge', (t) => {
const kp = ANProto.gen()
const sig = ANProto.sign('correct-challenge', kp)
const result = ANProto.verify('wrong-challenge', sig)
t.absent(result.valid, 'wrong challenge fails')
t.is(result.error, 'hash mismatch', 'error is hash mismatch')
})
test('verify rejects wrong key (tampered signature)', (t) => {
const kp1 = ANProto.gen()
const kp2 = ANProto.gen()
const challenge = 'test'
const sig = ANProto.sign(challenge, kp1)
// Replace pubkey prefix with kp2's key — signature won't match
const tampered = b4a.toString(kp2.publicKey, 'base64') + sig.substring(44)
const result = ANProto.verify(challenge, tampered)
t.absent(result.valid, 'tampered signature fails')
})
test('verify handles Buffer inputs', (t) => {
const kp = ANProto.gen()
const challenge = b4a.from('buffer-challenge')
const sig = ANProto.sign(challenge, kp)
const result = ANProto.verify(challenge, sig)
t.ok(result.valid, 'Buffer inputs work')
})
// --- Content Signing Mode ---
test('hash() produces deterministic SHA-256', (t) => {
const h1 = ANProto.hash('hello world')
const h2 = ANProto.hash('hello world')
t.is(h1, h2, 'same input = same hash')
t.is(h1.length, 44, '44-char base64 (32-byte hash)')
const h3 = ANProto.hash('different')
t.not(h1, h3, 'different input = different hash')
})
test('hash() accepts Buffer', (t) => {
const h1 = ANProto.hash('test')
const h2 = ANProto.hash(b4a.from('test'))
t.is(h1, h2, 'string and buffer produce same hash')
})
test('signContent/open round-trip', (t) => {
const kp = ANProto.gen()
const content = 'Hello, Wiredove!'
const contentHash = ANProto.hash(content)
const signed = ANProto.signContent(contentHash, kp)
t.is(typeof signed, 'string', 'signed is string')
t.ok(signed.length > 44, 'longer than just pubkey')
t.is(signed.substring(0, 44), b4a.toString(kp.publicKey, 'base64'), 'starts with pubkey')
const opened = ANProto.open(signed)
t.ok(opened.verified, 'verified is true')
t.is(opened.hash, contentHash, 'hash matches')
t.is(opened.publicKey, b4a.toString(kp.publicKey, 'base64'), 'pubkey matches')
t.ok(opened.timestamp > 0, 'timestamp is positive')
t.ok(opened.timestamp <= Date.now(), 'timestamp is not in the future')
})
test('open rejects tampered signature', (t) => {
const kp = ANProto.gen()
const signed = ANProto.signContent(ANProto.hash('test'), kp)
// Tamper with the signed data
const tampered = signed.substring(0, 44) + 'A' + signed.substring(45)
t.exception(() => ANProto.open(tampered), 'tampered message throws')
})
test('open rejects too-short messages', (t) => {
t.exception(() => ANProto.open('short'))
t.exception(() => ANProto.open('a'.repeat(43)))
})
// --- Deterministic Stringify ---
test('deterministicStringify sorts keys', (t) => {
const a = ANProto.deterministicStringify({ b: 2, a: 1 })
const b = ANProto.deterministicStringify({ a: 1, b: 2 })
t.is(a, b, 'different key order = same output')
t.is(a, '{"a":1,"b":2}')
})
test('deterministicStringify handles nested objects', (t) => {
const result = ANProto.deterministicStringify({ z: { b: 2, a: 1 }, a: 'hello' })
t.is(result, '{"a":"hello","z":{"a":1,"b":2}}')
})
test('deterministicStringify handles arrays', (t) => {
const result = ANProto.deterministicStringify([3, 1, 2])
t.is(result, '[3,1,2]', 'arrays preserve order')
})
test('deterministicStringify handles null and primitives', (t) => {
t.is(ANProto.deterministicStringify(null), 'null')
t.is(ANProto.deterministicStringify(42), '42')
t.is(ANProto.deterministicStringify('hello'), '"hello"')
t.is(ANProto.deterministicStringify(true), 'true')
})
// --- High-Level Helpers ---
test('createMessage/verifyMessage round-trip', (t) => {
const kp = ANProto.gen()
const content = { text: 'Hello world', author: 'alice' }
const msg = ANProto.createMessage(content, kp)
t.ok(msg.anproto, 'has anproto signature')
t.ok(msg.hash, 'has content hash')
t.is(msg.content, content, 'content preserved')
const verified = ANProto.verifyMessage(msg)
t.ok(verified.verified, 'verified is true')
t.is(verified.author, b4a.toString(kp.publicKey, 'base64'), 'author matches')
t.ok(verified.timestamp > 0, 'has timestamp')
})
test('verifyMessage rejects tampered content', (t) => {
const kp = ANProto.gen()
const msg = ANProto.createMessage({ text: 'original' }, kp)
msg.content = { text: 'tampered' }
t.exception(() => ANProto.verifyMessage(msg), 'tampered content throws')
})
test('verifyMessage rejects missing signature', (t) => {
t.exception(() => ANProto.verifyMessage({ content: 'no sig' }))
t.exception(() => ANProto.verifyMessage(null))
})
// --- Cross-Mode Compatibility ---
test('same keypair works for both challenge-response and content signing', (t) => {
const kp = ANProto.gen()
// Challenge-response
const challenge = 'auth-challenge-xyz'
const sig = ANProto.sign(challenge, kp)
const pubkeyB64 = b4a.toString(kp.publicKey, 'base64')
t.ok(ANProto.verify(challenge, sig, pubkeyB64), 'challenge-response works')
// Content signing
const hash = ANProto.hash('post content')
const signed = ANProto.signContent(hash, kp)
const opened = ANProto.open(signed)
t.ok(opened.verified, 'content signing works')
t.is(opened.publicKey, pubkeyB64, 'same public key in both modes')
})
test('createMessage with string content', (t) => {
const kp = ANProto.gen()
const msg = ANProto.createMessage('plain string content', kp)
const verified = ANProto.verifyMessage(msg)
t.ok(verified.verified)
t.is(verified.content, 'plain string content')
})
// ⛔ REGRESSION: the timestamp match must be EXACTLY 13 digits.
//
// The signed message is `timestamp + base64(sha256(challenge))`. A greedy `\d+` swallows the
// hash's first character whenever it is a digit — 10 of base64's 64 symbols — so roughly one in
// six verifications failed as "hash mismatch". Measured 59/300 before the fix, 0/500 after.
//
// It is deterministic per challenge, not flaky: the same challenge always produces the same hash,
// so a given challenge either always worked or never did. That is what made it survive — it looks
// like a bad client rather than a broken parser.
//
// The canonical `anproto` package (lib/an.js:119) has always used `/^(\d{13})(.+)$/`, which is why
// pear-git-v3 was unaffected: it depends on that package, and this file is a copy that drifted.
test('a hash beginning with a digit still verifies', (t) => {
const kp = ANProto.gen()
// Find a challenge whose hash starts with a digit — the case that used to fail.
let challenge = null
for (let i = 0; i < 200 && !challenge; i++) {
const c = 'challenge-' + i
if (/^[0-9]/.test(ANProto.hash(c))) challenge = c
}
t.ok(challenge, 'found one — they are ~15% of all challenges')
const r = ANProto.verify(challenge, ANProto.sign(challenge, kp))
t.ok(r.valid, 'verifies: ' + (r.error || 'ok'))
t.is(ANProto.open(ANProto.sign(challenge, kp)).hash, ANProto.hash(challenge),
'and the parsed hash is whole, not one character short')
})
test('the timestamp is parsed as exactly 13 digits', (t) => {
const kp = ANProto.gen()
const opened = ANProto.open(ANProto.sign('x', kp))
t.is(String(opened.timestamp).length, 13, 'never 14 — a 14th digit means it ate the hash')
t.is(opened.hash.length, 44, 'base64 of sha256 is always 44 characters')
})