110 lines
3.6 KiB
TypeScript
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);
|
|
}
|