OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / test / anproto.test.js

246 lines · javascript ref:
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')
})