// @pdsjs/core/crypto - Cryptographic utilities for AT Protocol // P-256 signing, encoding, JWK thumbprints // P-256 curve order N (for low-S signature normalization) const P256_N = BigInt( '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', ); const P256_N_DIV_2 = P256_N / 2n; // Multicodec prefix for P-256 public keys (0x1200 varint-encoded) export const P256_MULTICODEC = new Uint8Array([0x80, 0x24]); // Multicodec prefix for secp256k1 public keys (0xe7 varint-encoded) export const SECP256K1_MULTICODEC = new Uint8Array([0xe7, 0x01]); /** * Encode a private key for storage, tagging its curve. P-256 keys are stored * as the bare 32-byte scalar (the historical format); secp256k1 keys carry * their multicodec prefix so a stored key describes its own curve. * @param {Uint8Array} privateKey - 32-byte scalar * @param {'p256'|'secp256k1'} [curve] * @returns {Uint8Array} */ export function encodeStoredKey(privateKey, curve = 'p256') { if (curve === 'p256') return privateKey; const tagged = new Uint8Array(SECP256K1_MULTICODEC.length + 32); tagged.set(SECP256K1_MULTICODEC); tagged.set(privateKey, SECP256K1_MULTICODEC.length); return tagged; } /** * Decode a stored private key into its curve and 32-byte scalar. Bare 32 * bytes are P-256 (every key stored before curves were tagged). * @param {Uint8Array} stored * @returns {{curve: 'p256'|'secp256k1', privateKey: Uint8Array}} */ export function decodeStoredKey(stored) { if (stored.length === 32) return { curve: 'p256', privateKey: stored }; if ( stored.length === 34 && stored[0] === SECP256K1_MULTICODEC[0] && stored[1] === SECP256K1_MULTICODEC[1] ) { return { curve: 'secp256k1', privateKey: stored.slice(2) }; } if ( stored.length === 34 && stored[0] === P256_MULTICODEC[0] && stored[1] === P256_MULTICODEC[1] ) { return { curve: 'p256', privateKey: stored.slice(2) }; } throw new Error(`Unrecognized stored key format (${stored.length} bytes)`); } /** * Convert bytes to hexadecimal string * @param {Uint8Array} bytes - Bytes to convert * @returns {string} Hex string */ export function bytesToHex(bytes) { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * Convert hexadecimal string to bytes * @param {string} hex - Hex string * @returns {Uint8Array} Decoded bytes */ export function hexToBytes(hex) { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } return bytes; } /** * @param {Uint8Array} bytes * @returns {bigint} */ function bytesToBigInt(bytes) { let result = 0n; for (const byte of bytes) { result = (result << 8n) | BigInt(byte); } return result; } /** * @param {bigint} n * @param {number} length * @returns {Uint8Array} */ function bigIntToBytes(n, length) { const bytes = new Uint8Array(length); for (let i = length - 1; i >= 0; i--) { bytes[i] = Number(n & 0xffn); n >>= 8n; } return bytes; } /** * Encode bytes as base32lower string * @param {Uint8Array} bytes - Bytes to encode * @returns {string} Base32lower-encoded string */ export function base32Encode(bytes) { const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; let result = ''; let bits = 0; let value = 0; for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; result += alphabet[(value >> bits) & 31]; } } if (bits > 0) { result += alphabet[(value << (5 - bits)) & 31]; } return result; } /** * Decode base32lower string to bytes * @param {string} str - Base32lower-encoded string * @returns {Uint8Array} Decoded bytes */ export function base32Decode(str) { const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; let bits = 0; let value = 0; const output = []; for (const char of str) { const idx = alphabet.indexOf(char); if (idx === -1) continue; value = (value << 5) | idx; bits += 5; if (bits >= 8) { bits -= 8; output.push((value >> bits) & 0xff); } } return new Uint8Array(output); } /** * Encode bytes as base64url string (no padding) * @param {Uint8Array} bytes - Bytes to encode * @returns {string} Base64url-encoded string */ export function base64UrlEncode(bytes) { let binary = ''; for (const byte of bytes) { binary += String.fromCharCode(byte); } const base64 = btoa(binary); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } /** * Decode base64url string to bytes * @param {string} str - Base64url-encoded string * @returns {Uint8Array} Decoded bytes */ export function base64UrlDecode(str) { const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); const pad = base64.length % 4; const padded = pad ? base64 + '='.repeat(4 - pad) : base64; const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } /** * Generate a new P-256 key pair * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key */ export async function generateKeyPair() { const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); // Export private key as raw bytes const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); const privateBytes = base64UrlDecode(/** @type {string} */ (privateJwk.d)); // Export public key as compressed point const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); const publicBytes = new Uint8Array(publicRaw); const compressed = compressPublicKey(publicBytes); return { privateKey: privateBytes, publicKey: compressed }; } /** * Import a raw P-256 private key for signing * @param {Uint8Array} privateKeyBytes - 32-byte raw private key * @returns {Promise} Web Crypto key handle */ export async function importPrivateKey(privateKeyBytes) { // Validate private key length (P-256 requires exactly 32 bytes) if ( !(privateKeyBytes instanceof Uint8Array) || privateKeyBytes.length !== 32 ) { throw new Error( `Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`, ); } // PKCS#8 wrapper for raw P-256 private key const pkcs8Prefix = new Uint8Array([ 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, ]); const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32); pkcs8.set(pkcs8Prefix); pkcs8.set(privateKeyBytes, pkcs8Prefix.length); return crypto.subtle.importKey( 'pkcs8', /** @type {BufferSource} */ (pkcs8), { name: 'ECDSA', namedCurve: 'P-256' }, true, // extractable - needed to export public key for JWKS ['sign'], ); } /** * The account signing key: either a WebCrypto P-256 key handle, or an * injected-signer handle for curves WebCrypto cannot sign (secp256k1). * The object form carries its own `sign` so core never needs a secp256k1 * dependency — the implementation is injected by the deployment, mirroring * the SignatureVerifierPort on the verification side. * @typedef {CryptoKey | {curve: 'secp256k1', sign: (data: Uint8Array) => Promise, publicKey: () => Promise}} SigningKey */ /** * Curve of a signing key. WebCrypto handles are always P-256 here; injected * signer handles declare their curve. * @param {SigningKey} key * @returns {'p256'|'secp256k1'} */ export function signingKeyCurve(key) { return 'curve' in key && key.curve === 'secp256k1' ? 'secp256k1' : 'p256'; } /** * Sign data with the account signing key, returning a low-S normalized * 64-byte (r || s) signature. P-256 keys sign with WebCrypto; injected * signer handles sign with their own implementation (which must also * produce low-S compact signatures). * @param {SigningKey} privateKey - Signing key * @param {Uint8Array} data - Data to sign * @returns {Promise} 64-byte signature (r || s) */ export async function sign(privateKey, data) { if ('curve' in privateKey && typeof privateKey.sign === 'function') { return privateKey.sign(data); } const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, /** @type {CryptoKey} */ (privateKey), /** @type {BufferSource} */ (data), ); const sig = new Uint8Array(signature); const r = sig.slice(0, 32); const s = sig.slice(32, 64); const sBigInt = bytesToBigInt(s); // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent // signature malleability (two valid signatures for same message) if (sBigInt > P256_N_DIV_2) { const newS = P256_N - sBigInt; const newSBytes = bigIntToBytes(newS, 32); const normalized = new Uint8Array(64); normalized.set(r, 0); normalized.set(newSBytes, 32); return normalized; } return sig; } /** * Compress public key (SEC1 format) * @param {Uint8Array} uncompressed - 65-byte uncompressed public key (0x04 + x + y) * @returns {Uint8Array} 33-byte compressed public key */ export function compressPublicKey(uncompressed) { // uncompressed is 65 bytes: 0x04 + x(32) + y(32) // compressed is 33 bytes: prefix(02 or 03) + x(32) const x = uncompressed.slice(1, 33); const y = uncompressed.slice(33, 65); const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; const compressed = new Uint8Array(33); compressed[0] = prefix; compressed.set(x, 1); return compressed; } /** * Derive the curve and compressed public key for a stored private key. * @param {Uint8Array} storedKeyBytes - As stored: bare P-256 scalar or curve-tagged * @param {import('./ports.js').Secp256k1SignerPort|null} [k256Signer] * @returns {Promise<{curve: 'p256'|'secp256k1', publicKey: Uint8Array}>} */ export async function derivePublicKey(storedKeyBytes, k256Signer = null) { const { curve, privateKey } = decodeStoredKey(storedKeyBytes); if (curve === 'secp256k1') { if (!k256Signer) { throw new Error('Stored key is secp256k1 but no k256Signer was injected'); } return { curve, publicKey: k256Signer.getPublicKey(privateKey) }; } const signingKey = await importPrivateKey(privateKey); const jwk = await crypto.subtle.exportKey('jwk', signingKey); const x = base64UrlDecode(/** @type {string} */ (jwk.x)); const y = base64UrlDecode(/** @type {string} */ (jwk.y)); const uncompressed = new Uint8Array(65); uncompressed[0] = 0x04; uncompressed.set(x, 1); uncompressed.set(y, 33); return { curve, publicKey: compressPublicKey(uncompressed) }; } /** * Compute JWK thumbprint (SHA-256) per RFC 7638. * Creates a canonical JSON representation of EC key required members * and returns the base64url-encoded SHA-256 hash. * @param {{ kty: string, crv: string, x: string, y: string }} jwk - The EC public key in JWK format * @returns {Promise} The base64url-encoded thumbprint */ export async function computeJwkThumbprint(jwk) { // RFC 7638: members must be in lexicographic order const thumbprintInput = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y, }); const hash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(thumbprintInput), ); return base64UrlEncode(new Uint8Array(hash)); } /** * HMAC-SHA256 a string with a secret. * @param {string} data - Data to sign * @param {string} secret - Secret key * @returns {Promise} Base64url-encoded MAC */ export async function hmacSha256(data, secret) { const key = await crypto.subtle.importKey( 'raw', /** @type {BufferSource} */ (new TextEncoder().encode(secret)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const mac = await crypto.subtle.sign( 'HMAC', key, /** @type {BufferSource} */ (new TextEncoder().encode(data)), ); return base64UrlEncode(new Uint8Array(mac)); } /** * Timing-safe string comparison using constant-time comparison. * Compares hashes of strings to prevent timing attacks. * @param {string} a - First string to compare * @param {string} b - Second string to compare * @returns {Promise} True if strings are equal */ export async function timingSafeEqual(a, b) { const encoder = new TextEncoder(); const aBytes = encoder.encode(a); const bBytes = encoder.encode(b); // Hash both to ensure constant-time comparison regardless of length const [aHash, bHash] = await Promise.all([ crypto.subtle.digest('SHA-256', aBytes), crypto.subtle.digest('SHA-256', bBytes), ]); const aArr = new Uint8Array(aHash); const bArr = new Uint8Array(bHash); // Constant-time comparison let result = 0; for (let i = 0; i < aArr.length; i++) { result |= aArr[i] ^ bArr[i]; } return result === 0; }