OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / test-sig-compat.js

181 lines · javascript ref:
#!/usr/bin/env node

/**
 * Test signature compatibility between hypercore-crypto (server) and @noble/ed25519 (browser)
 *
 * This validates that the browser's signing approach produces signatures
 * the server can verify, and vice versa.
 *
 * Browser flow:
 *   1. Server generates keypair via crypto.keyPair(seed)
 *   2. Keypair stored as base64: publicKey(44 chars) + secretKey(88 chars)
 *   3. Browser signs with noble-ed25519 using first 32 bytes of secretKey (= seed)
 *   4. Server verifies with hypercore-crypto
 *
 * Challenge flow:
 *   1. Server creates challenge: crypto.randomBytes(32).toString('base64')
 *   2. Browser encodes challenge with TextEncoder (UTF-8 bytes of the base64 string)
 *   3. Server encodes challenge with b4a.from(challenge, 'utf8')
 *   4. Both should produce identical byte arrays
 */

import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import * as ed25519 from '@noble/ed25519'
import { sha512 } from '@noble/hashes/sha2.js'
import { ANProto } from '../lib/anproto.js'

// Configure noble-ed25519 with sha512 (same as browser does)
ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m))
ed25519.etc.sha512Async = async (...m) => sha512(ed25519.etc.concatBytes(...m))

let passed = 0
let failed = 0

function assert (condition, message) {
  if (condition) {
    console.log('  ✓', message)
    passed++
  } else {
    console.error('  ✗', message)
    failed++
  }
}

// Helper: simulate browser's base64 encoding
function bytesToBase64 (bytes) {
  return b4a.toString(Buffer.from(bytes), 'base64')
}

function base64ToBytes (base64) {
  return new Uint8Array(b4a.from(base64, 'base64'))
}

async function run () {
  console.log('\n=== Ed25519 Signature Compatibility Test ===\n')

  // 1. Generate keypair (server-side, same as WebIdentityProvider.generateIdentity)
  console.log('1. Keypair generation')
  const seed = crypto.randomBytes(32)
  const keypair = crypto.keyPair(seed)
  const publicKeyB64 = b4a.toString(keypair.publicKey, 'base64')
  const secretKeyB64 = b4a.toString(keypair.secretKey, 'base64')

  assert(publicKeyB64.length === 44, `Public key base64 length = ${publicKeyB64.length} (expected 44)`)
  assert(secretKeyB64.length === 88, `Secret key base64 length = ${secretKeyB64.length} (expected 88)`)
  assert(keypair.publicKey.length === 32, `Public key buffer length = ${keypair.publicKey.length} (expected 32)`)
  assert(keypair.secretKey.length === 64, `Secret key buffer length = ${keypair.secretKey.length} (expected 64)`)

  // 2. Challenge generation (same as ChallengeManager.generate)
  console.log('\n2. Challenge encoding')
  const challenge = crypto.randomBytes(32).toString('base64')
  console.log('  Challenge string:', challenge.substring(0, 20) + '...')

  // Server encoding
  const serverChallengeBytes = b4a.from(challenge, 'utf8')
  // Browser encoding (TextEncoder)
  const browserChallengeBytes = new TextEncoder().encode(challenge)

  assert(
    serverChallengeBytes.length === browserChallengeBytes.length,
    `Challenge byte length match: server=${serverChallengeBytes.length} browser=${browserChallengeBytes.length}`
  )

  let bytesMatch = true
  for (let i = 0; i < serverChallengeBytes.length; i++) {
    if (serverChallengeBytes[i] !== browserChallengeBytes[i]) {
      bytesMatch = false
      break
    }
  }
  assert(bytesMatch, 'Challenge bytes are identical (UTF-8 encoding match)')

  // 3. Browser-side signing (noble-ed25519 with seed)
  console.log('\n3. Signing compatibility')
  const secretKeyBytes = base64ToBytes(secretKeyB64)
  assert(secretKeyBytes.length === 64, `Decoded secret key length = ${secretKeyBytes.length} (expected 64)`)

  const privateKey = secretKeyBytes.slice(0, 32) // Browser uses first 32 bytes = seed
  const nobleSignature = await ed25519.signAsync(browserChallengeBytes, privateKey)
  const nobleSignatureB64 = bytesToBase64(nobleSignature)

  assert(nobleSignature.length === 64, `Noble signature length = ${nobleSignature.length} (expected 64)`)
  assert(nobleSignatureB64.length === 88, `Noble signature base64 length = ${nobleSignatureB64.length} (expected 88)`)

  // 4. Server-side verification (hypercore-crypto)
  console.log('\n4. Cross-library verification')

  // Server verifies browser signature (the critical path)
  const serverVerifiesNoble = crypto.verify(
    serverChallengeBytes,
    Buffer.from(nobleSignature),
    keypair.publicKey
  )
  assert(serverVerifiesNoble, 'hypercore-crypto verifies noble-ed25519 signature')

  // Also test via ANProto.verify (the actual server code path)
  const anprotoVerifies = ANProto.verify(challenge, nobleSignatureB64, publicKeyB64)
  assert(anprotoVerifies, 'ANProto.verify() verifies noble-ed25519 signature')

  // 5. Server-side signing (hypercore-crypto)
  console.log('\n5. Reverse verification')
  const hcSignature = crypto.sign(serverChallengeBytes, keypair.secretKey)
  const hcSignatureB64 = b4a.toString(hcSignature, 'base64')

  // Noble verifies hypercore-crypto signature
  const nobleVerifiesHC = await ed25519.verifyAsync(
    hcSignature,
    browserChallengeBytes,
    new Uint8Array(keypair.publicKey)
  )
  assert(nobleVerifiesHC, 'noble-ed25519 verifies hypercore-crypto signature')

  // 6. Signature equality (both libs should produce identical signatures)
  console.log('\n6. Signature determinism')
  let sigsIdentical = true
  for (let i = 0; i < 64; i++) {
    if (nobleSignature[i] !== hcSignature[i]) {
      sigsIdentical = false
      break
    }
  }
  assert(sigsIdentical, 'Signatures are byte-identical (deterministic ed25519)')

  // 7. Full round-trip (simulate complete browser → server flow)
  console.log('\n7. Full round-trip simulation')
  const testChallenge = crypto.randomBytes(32).toString('base64')
  const testChallengeId = crypto.randomBytes(16).toString('hex')

  // Browser signs
  const testChallengeBytes = new TextEncoder().encode(testChallenge)
  const testSig = await ed25519.signAsync(testChallengeBytes, privateKey)
  const testSigB64 = bytesToBase64(testSig)

  // Server verifies (exactly as WebIdentityProvider.authenticate does)
  const serverCBuf = b4a.from(testChallenge, 'utf8')
  const serverSigBuf = b4a.from(testSigB64, 'base64')
  const serverPubBuf = b4a.from(publicKeyB64, 'base64')

  assert(serverSigBuf.length === 64, `Server decoded signature length = ${serverSigBuf.length}`)
  assert(serverPubBuf.length === 32, `Server decoded public key length = ${serverPubBuf.length}`)

  const roundTripResult = crypto.verify(serverCBuf, serverSigBuf, serverPubBuf)
  assert(roundTripResult, 'Full round-trip: browser sign → server verify')

  // Summary
  console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`)
  if (failed > 0) {
    console.error('\n❌ SIGNATURE INCOMPATIBILITY DETECTED')
    console.error('   Browser signatures will NOT verify on server')
    process.exit(1)
  } else {
    console.log('\n✅ All compatibility checks passed')
    console.log('   noble-ed25519 (browser) ↔ hypercore-crypto (server) are fully compatible')
  }
}

run().catch(err => {
  console.error('Test error:', err)
  process.exit(1)
})