OverviewFilesCommitsIssuesDocsReleasesPeers

anproto / test / anproto.test.js

151 lines · javascript ref:
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()
})