/** * Internal utilities for YubiKey signing operations */ import { secp256k1 } from '@noble/curves/secp256k1'; import { keccak256, hexToBytes, bytesToHex } from 'viem'; import createDebug from 'debug'; const debug = createDebug('viem-account-yubikey:utils'); // secp256k1 curve order const N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); const HALF_N = N / 2n; /** * Normalize s value to lower half of curve order (canonical form required by Ethereum) */ export function normalizeS(s: Uint8Array): Uint8Array { const sHex = bytesToHex(s); let sBigInt = BigInt(sHex); if (sBigInt > HALF_N) { debug(`Normalizing s to lower half of curve order`); sBigInt = N - sBigInt; const normalizedHex = `0x${sBigInt.toString(16).padStart(64, '0')}` as `0x${string}`; return hexToBytes(normalizedHex); } return s; } /** * Recover the v value by trying both recovery IDs * @param hash The message hash that was signed * @param r The r component of the signature * @param s The s component of the signature (must be normalized) * @param expectedAddress The Ethereum address we expect to recover * @returns The recovery ID (0 or 1) */ export function recoverV( hash: Uint8Array, r: Uint8Array, s: Uint8Array, expectedAddress: `0x${string}` ): number { debug(`Recovering v value for address: ${expectedAddress}`); const rHex = bytesToHex(r); const sHex = bytesToHex(s); const hashHex = bytesToHex(hash); const rBigInt = BigInt(rHex); const sBigInt = BigInt(sHex); debug(`r: ${rHex}`); debug(`s: ${sHex}`); debug(`hash: ${hashHex}`); // Try both recovery IDs (0 and 1) for (let recoveryId = 0; recoveryId <= 1; recoveryId++) { try { debug(`Trying recovery ID: ${recoveryId}`); // Create signature with recovery bit const signature = new secp256k1.Signature(rBigInt, sBigInt).addRecoveryBit(recoveryId); // Recover public key - need to pass hash without 0x prefix const hashForRecovery = hashHex.startsWith('0x') ? hashHex.slice(2) : hashHex; const publicKey = signature.recoverPublicKey(hashForRecovery); const publicKeyBytes = publicKey.toRawBytes(false); // uncompressed (65 bytes with 0x04 prefix) debug(`Recovered public key (${publicKeyBytes.length} bytes): ${bytesToHex(publicKeyBytes).slice(0, 20)}...`); // Derive address from public key (skip first byte which is 0x04 for uncompressed key) const publicKeyUncompressed = publicKeyBytes.slice(1); // Remove 0x04 prefix (now 64 bytes) // Hash public key with keccak256 const publicKeyHex = bytesToHex(publicKeyUncompressed); const addressHash = keccak256(publicKeyHex); // Take last 20 bytes (40 hex chars + 0x prefix = 42 chars total) const derivedAddress = ('0x' + addressHash.slice(-40)).toLowerCase(); debug(`Recovered address: ${derivedAddress}`); debug(`Expected address: ${expectedAddress.toLowerCase()}`); if (derivedAddress === expectedAddress.toLowerCase()) { debug(`✓ Match found with recovery ID ${recoveryId}`); return recoveryId; } } catch (error) { debug(`Error with recovery ID ${recoveryId}:`, error); continue; } } throw new Error( `Failed to recover correct v value for signature. Neither recovery ID produced the expected address: ${expectedAddress}` ); } /** * Convert hex string to Uint8Array */ export function hexToUint8Array(hex: string): Uint8Array { const withPrefix = hex.startsWith('0x') ? hex : `0x${hex}`; return hexToBytes(withPrefix as `0x${string}`); } /** * Convert Uint8Array to hex string */ export function uint8ArrayToHex(bytes: Uint8Array): `0x${string}` { return bytesToHex(bytes); } /** * Ensure hex string has 0x prefix */ export function ensureHex(value: string): `0x${string}` { return value.startsWith('0x') ? (value as `0x${string}`) : `0x${value}`; } /** * Remove 0x prefix from hex string */ export function stripHex(value: string): string { return value.startsWith('0x') ? value.slice(2) : value; }