112 lines
4.7 KiB
TypeScript
112 lines
4.7 KiB
TypeScript
/**
|
|
* test-eoa-derivation.ts — Verify EOA key derivation from PRF output.
|
|
*
|
|
* Tests:
|
|
* 1. Determinism: same PRF input → same EOA address every time
|
|
* 2. Domain separation: different from what encryption/signing keys would produce
|
|
* 3. Valid Ethereum address format (EIP-55 checksummed)
|
|
* 4. Different PRF inputs → different addresses
|
|
* 5. Private key zeroing works
|
|
*
|
|
* Usage:
|
|
* bun run scripts/test-eoa-derivation.ts
|
|
*/
|
|
|
|
import { deriveEOAFromPRF, zeroPrivateKey } from '../src/encryptid/eoa-derivation';
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function assert(condition: boolean, msg: string) {
|
|
if (condition) {
|
|
console.log(` ✓ ${msg}`);
|
|
passed++;
|
|
} else {
|
|
console.error(` ✗ ${msg}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
function toHex(bytes: Uint8Array): string {
|
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
async function main() {
|
|
console.log('=== EOA Key Derivation Tests ===\n');
|
|
|
|
// Fixed PRF output (simulates 32 bytes from WebAuthn PRF extension)
|
|
const prfOutput = new Uint8Array(32);
|
|
for (let i = 0; i < 32; i++) prfOutput[i] = i + 1;
|
|
|
|
// Test 1: Basic derivation works
|
|
console.log('[1] Basic derivation');
|
|
const result1 = deriveEOAFromPRF(prfOutput);
|
|
assert(result1.privateKey.length === 32, 'Private key is 32 bytes');
|
|
assert(result1.publicKey.length === 65, 'Public key is 65 bytes (uncompressed)');
|
|
assert(result1.publicKey[0] === 0x04, 'Public key starts with 0x04');
|
|
assert(result1.address.startsWith('0x'), 'Address starts with 0x');
|
|
assert(result1.address.length === 42, 'Address is 42 chars (0x + 40 hex)');
|
|
console.log(` Address: ${result1.address}`);
|
|
console.log(` PubKey: 0x${toHex(result1.publicKey).slice(0, 20)}...`);
|
|
|
|
// Test 2: Determinism — same input always gives same output
|
|
console.log('\n[2] Determinism');
|
|
const result2 = deriveEOAFromPRF(prfOutput);
|
|
assert(result2.address === result1.address, 'Same PRF → same address');
|
|
assert(toHex(result2.privateKey) === toHex(result1.privateKey), 'Same PRF → same private key');
|
|
|
|
// Run it 5 more times to be sure
|
|
for (let i = 0; i < 5; i++) {
|
|
const r = deriveEOAFromPRF(prfOutput);
|
|
assert(r.address === result1.address, `Deterministic on iteration ${i + 3}`);
|
|
}
|
|
|
|
// Test 3: Different input → different address
|
|
console.log('\n[3] Different inputs produce different addresses');
|
|
const prfOutput2 = new Uint8Array(32);
|
|
for (let i = 0; i < 32; i++) prfOutput2[i] = 32 - i;
|
|
const result3 = deriveEOAFromPRF(prfOutput2);
|
|
assert(result3.address !== result1.address, 'Different PRF → different address');
|
|
console.log(` Address 1: ${result1.address}`);
|
|
console.log(` Address 2: ${result3.address}`);
|
|
|
|
// Test 4: EIP-55 checksum validity
|
|
console.log('\n[4] EIP-55 checksum format');
|
|
const hasUppercase = /[A-F]/.test(result1.address.slice(2));
|
|
const hasLowercase = /[a-f]/.test(result1.address.slice(2));
|
|
const isAllHex = /^0x[0-9a-fA-F]{40}$/.test(result1.address);
|
|
assert(isAllHex, 'Address is valid hex');
|
|
assert(hasUppercase || hasLowercase, 'Address has mixed case (EIP-55 checksum)');
|
|
|
|
// Test 5: Private key zeroing
|
|
console.log('\n[5] Private key zeroing');
|
|
const result4 = deriveEOAFromPRF(prfOutput);
|
|
const keyBefore = toHex(result4.privateKey);
|
|
assert(keyBefore !== '00'.repeat(32), 'Key is non-zero before wipe');
|
|
zeroPrivateKey(result4.privateKey);
|
|
const keyAfter = toHex(result4.privateKey);
|
|
assert(keyAfter === '00'.repeat(32), 'Key is all zeros after wipe');
|
|
|
|
// Test 6: Domain separation — verify the address is different from what
|
|
// you'd get with different HKDF params (we can't test WebCrypto HKDF here
|
|
// but we can verify our specific salt/info combo produces a unique result)
|
|
console.log('\n[6] Domain separation');
|
|
// Manually derive with same input but different params using @noble/hashes
|
|
const { hkdf } = await import('@noble/hashes/hkdf');
|
|
const { sha256 } = await import('@noble/hashes/sha256');
|
|
const encoder = new TextEncoder();
|
|
const encryptionKeyBytes = hkdf(sha256, prfOutput, encoder.encode('encryptid-encryption-key-v1'), encoder.encode('AES-256-GCM'), 32);
|
|
const signingKeyBytes = hkdf(sha256, prfOutput, encoder.encode('encryptid-signing-key-v1'), encoder.encode('ECDSA-P256-seed'), 32);
|
|
const eoaKeyBytes = hkdf(sha256, prfOutput, encoder.encode('encryptid-eoa-key-v1'), encoder.encode('secp256k1-signing-key'), 32);
|
|
|
|
assert(toHex(encryptionKeyBytes) !== toHex(eoaKeyBytes), 'EOA key ≠ encryption key material');
|
|
assert(toHex(signingKeyBytes) !== toHex(eoaKeyBytes), 'EOA key ≠ signing key material');
|
|
assert(toHex(result1.privateKey) === toHex(eoaKeyBytes), 'EOA key matches expected HKDF output');
|
|
|
|
// Summary
|
|
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
main();
|