/** * EOA Key Derivation from WebAuthn PRF Output * * Derives a deterministic secp256k1 EOA keypair from the passkey's PRF output * using HKDF-SHA256. The derived EOA becomes an owner on the user's Gnosis Safe, * bridging passkey-based identity to EVM wallet operations. * * The key is: * - Deterministic: same passkey always produces the same EOA address * - Never stored: derived on-demand in the browser from PRF output * - Domain-separated: different HKDF salt/info from encryption/signing/DID keys */ import { secp256k1 } from '@noble/curves/secp256k1'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha256'; import { keccak_256 } from '@noble/hashes/sha3'; /** * Derive a secp256k1 EOA private key from PRF output using HKDF-SHA256. * * Uses domain-separated salt/info to ensure this key is independent from * the encryption, signing, and DID keys derived from the same PRF output. * * @param prfOutput - Raw PRF output from WebAuthn (typically 32 bytes) * @returns Object with privateKey (Uint8Array), publicKey (Uint8Array), and address (checksummed hex) */ export function deriveEOAFromPRF(prfOutput: Uint8Array): { privateKey: Uint8Array; publicKey: Uint8Array; address: string; } { const encoder = new TextEncoder(); // HKDF-SHA256 with domain-specific salt and info // These are deliberately different from the existing derivation salts: // encryption: 'encryptid-encryption-key-v1' / 'AES-256-GCM' // signing: 'encryptid-signing-key-v1' / 'ECDSA-P256-seed' // DID: 'encryptid-did-key-v1' / 'Ed25519-seed' const privateKey = hkdf( sha256, prfOutput, encoder.encode('encryptid-eoa-key-v1'), // salt encoder.encode('secp256k1-signing-key'), // info 32 // 32 bytes for secp256k1 ); // Validate the private key is in the valid range for secp256k1 // (between 1 and curve order - 1). HKDF output is uniformly random // so this is astronomically unlikely to fail, but we check anyway. if (!isValidPrivateKey(privateKey)) { throw new Error('Derived EOA private key is outside valid secp256k1 range'); } // Derive public key (uncompressed, 65 bytes: 04 || x || y) const publicKeyUncompressed = secp256k1.getPublicKey(privateKey, false); // Ethereum address = last 20 bytes of keccak256(publicKey[1:]) // Skip the 0x04 prefix byte const publicKeyBytes = publicKeyUncompressed.slice(1); const hash = keccak_256(publicKeyBytes); const addressBytes = hash.slice(12); // last 20 bytes const address = toChecksumAddress(addressBytes); return { privateKey, publicKey: publicKeyUncompressed, address }; } /** * Check if a 32-byte value is a valid secp256k1 private key. */ function isValidPrivateKey(key: Uint8Array): boolean { try { secp256k1.getPublicKey(key); return true; } catch { return false; } } /** * Convert raw address bytes to EIP-55 checksummed hex address. */ function toChecksumAddress(addressBytes: Uint8Array): string { const hex = Array.from(addressBytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); const hashHex = Array.from(keccak_256(new TextEncoder().encode(hex))) .map(b => b.toString(16).padStart(2, '0')) .join(''); let checksummed = '0x'; for (let i = 0; i < 40; i++) { checksummed += parseInt(hashHex[i], 16) >= 8 ? hex[i].toUpperCase() : hex[i]; } return checksummed; } /** * Zero out a private key buffer. * Call this after signing to minimize exposure window. */ export function zeroPrivateKey(key: Uint8Array): void { key.fill(0); }