rspace-online/src/encryptid/eoa-derivation.ts

110 lines
3.6 KiB
TypeScript

/**
* 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);
}