UNPKG

@seda-protocol/secp256k1-vrf

Version:

A TypeScript implementation of Verifiable Random Functions (VRF) for secp256k1

249 lines 11.3 kB
import { sha256 } from "@noble/hashes/sha256"; import * as secp256k1 from "@noble/secp256k1"; import { generateNonce } from "./nonce.js"; /** * Verifiable Random Function (VRF) implementation using @noble/secp256k1 * Based on RFC 9381 (Verifiable Random Functions (VRFs)) * https://datatracker.ietf.org/doc/rfc9381/ * * This implementation focuses specifically on the secp256k1 curve. */ export class Secp256k1Vrf { /** * Extension beyond RFC 9381 - secp256k1 with SHA-256 and TAI * Note: This is not defined in RFC 9381 and is a custom extension */ suiteID = 0xfe; cLen = 16; // Challenge length scalarSize = 32; // 256 bits ptLen = 32; // Size of x-coordinate // Domain separator constants defined per RFC 9381 Section 5.4 CHALLENGE_GENERATION_DOMAIN_SEPARATOR_FRONT = 0x02; CHALLENGE_GENERATION_DOMAIN_SEPARATOR_BACK = 0x00; ENCODE_TO_CURVE_DST_FRONT = 0x01; ENCODE_TO_CURVE_DST_BACK = 0x00; PROOF_TO_HASH_DOMAIN_SEPARATOR_FRONT = 0x03; PROOF_TO_HASH_DOMAIN_SEPARATOR_BACK = 0x00; COMPRESSED_POINT_EVEN_Y_PREFIX = 0x02; /** * Generate a VRF proof for a message using a private key * Implements algorithm from RFC 9381 Section 5.1 * @param secret Private key as bytes * @param message Message to prove as bytes * @returns VRF proof as bytes */ prove(secret, message) { // Validate the secret key let secretBigInt; try { secretBigInt = secp256k1.utils.normPrivateKeyToScalar(secret); } catch (error) { throw new Error(`Invalid secret key: ${error instanceof Error ? error.message : String(error)}`); } // Step 1: derive public key from secret key const publicKey = secp256k1.getPublicKey(secret); // Step 2: Encode to curve (using TAI) const hBytes = this.encodeToCurveTAI(publicKey, message); // Step 4: Gamma = x * H const hPoint = secp256k1.ProjectivePoint.fromHex(hBytes); const gammaPoint = hPoint.multiply(secretBigInt); // Step 5: Generate nonce (using RFC 6979) const kScalar = this.generateNonce(secretBigInt, hBytes); // Step 6: Challenge generation // U = k*B const kScalarBigInt = secp256k1.utils.normPrivateKeyToScalar(kScalar); const uPoint = secp256k1.ProjectivePoint.BASE.multiply(kScalarBigInt); // V = k*H const vPoint = hPoint.multiply(kScalarBigInt); // Convert points to compressed format const publicKeyBytes = publicKey; const hPointBytes = hBytes; const gammaPointBytes = gammaPoint.toRawBytes(true); const uPointBytes = uPoint.toRawBytes(true); const vPointBytes = vPoint.toRawBytes(true); // Challenge generation const inputs = Buffer.concat([publicKeyBytes, hPointBytes, gammaPointBytes, uPointBytes, vPointBytes]); const cScalar = this.challengeGeneration(inputs, this.cLen); const cBigInt = BigInt(`0x${Buffer.from(cScalar).toString("hex")}`); // Step 7: s = (k + c*x) mod q const kBigInt = secp256k1.utils.normPrivateKeyToScalar(kScalar); // Calculate s = (k + c*x) mod q const n = secp256k1.CURVE.n; const sBigInt = (kBigInt + ((cBigInt * secretBigInt) % n)) % n; const sHex = sBigInt.toString(16).padStart(this.scalarSize * 2, "0"); const sScalar = Buffer.from(sHex, "hex"); // Step 8: encode (gamma, c, s) return Buffer.concat([gammaPointBytes, cScalar, sScalar]); } /** * Verify a VRF proof and return the resulting hash if valid * Implements algorithm from RFC 9381 Section 5.3 * @param publicKey Public key as bytes * @param proof VRF proof as bytes * @param message Original message as bytes * @returns Hash as a hex string if valid, "INVALID" if invalid */ verify(publicKey, proof, message) { try { // Step 1-2: Decode public key // Verify the public key is valid const publicKeyBytes = secp256k1.ProjectivePoint.fromHex(publicKey).toRawBytes(true); // Step 4-6: Decode proof const { gamma, cScalar, sScalar } = this.decodeProof(proof); // Step 7: Hash to curve const H = this.encodeToCurveTAI(publicKeyBytes, message); // Convert to noble/secp256k1 points const Y = secp256k1.ProjectivePoint.fromHex(publicKey); const Gamma = secp256k1.ProjectivePoint.fromHex(gamma); const HPoint = secp256k1.ProjectivePoint.fromHex(H); // Convert challenge and scalar to BigInt const cScalarBigInt = secp256k1.utils.normPrivateKeyToScalar(cScalar); const sScalarBigInt = secp256k1.utils.normPrivateKeyToScalar(sScalar); // Step 8-9: Compute U and V // U = sG - cY const sG = secp256k1.ProjectivePoint.BASE.multiply(sScalarBigInt); const cY = Y.multiply(cScalarBigInt); const U = sG.add(cY.negate()); // V = sH - cGamma const sH = HPoint.multiply(sScalarBigInt); const cGamma = Gamma.multiply(cScalarBigInt); const V = sH.add(cGamma.negate()); // Step 10: Compute c' const c_prime = this.challengeGeneration(Buffer.concat([publicKeyBytes, H, gamma, U.toRawBytes(true), V.toRawBytes(true)]), this.cLen); // Compare c' to c from the proof const cPrimeBytes = Buffer.from(c_prime); const cScalarBytes = Buffer.from(cScalar).slice(this.scalarSize - this.cLen); return Buffer.compare(cPrimeBytes, cScalarBytes) === 0 ? { isValid: true, hash: Buffer.from(this.gammaToHash(gamma)).toString("hex") } : { isValid: false, reason: "Invalid challenge" }; } catch (error) { return { isValid: false, reason: `VRF verification failed: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * Convert a VRF proof to its corresponding hash output * Implements algorithm from RFC 9381 Section 5.2 * @param proof VRF proof as bytes * @returns Hash output as a hex string */ proofToHash(proof) { const { gamma } = this.decodeProof(proof); return Buffer.from(this.gammaToHash(gamma)).toString("hex"); } /** * Generate a key pair for use with VRF * @returns Object containing secret key and public key as hex strings */ keygen() { const privateKey = secp256k1.utils.randomPrivateKey(); const publicKey = secp256k1.getPublicKey(privateKey, true); // Compressed format return { secretKey: Buffer.from(privateKey).toString("hex"), publicKey: Buffer.from(publicKey).toString("hex"), }; } // Private helper methods /** * Decode a VRF proof into its components * @param pi Proof to decode as bytes * @returns Decoded gamma, c, and s components as bytes */ decodeProof(pi) { // Convert pi to Buffer if it's not already const piBuffer = Buffer.isBuffer(pi) ? pi : Buffer.from(pi); // Total compressed point size: ptLen (x-coordinate) + 1 (prefix) const gammaOct = this.ptLen + 1; // Expected proof length = point + challenge + scalar if (piBuffer.length !== gammaOct + this.cLen + this.scalarSize) { throw new Error(`Invalid proof length: expected ${gammaOct + this.cLen + this.scalarSize}, got ${piBuffer.length}`); } // Gamma point (compressed format) const gamma = piBuffer.slice(0, gammaOct); // C scalar (needs to be padded with leading zeroes to match scalar size) const cScalar = Buffer.alloc(this.scalarSize); piBuffer.slice(gammaOct, gammaOct + this.cLen).copy(cScalar, this.scalarSize - this.cLen); // S scalar const sScalar = piBuffer.slice(gammaOct + this.cLen); return { gamma, cScalar, sScalar }; } /** * Challenge generation function * @param points Concatenated point data as bytes * @param truncateLen Length to truncate the output hash to * @returns Challenge value as bytes */ challengeGeneration(points, truncateLen) { const pointBytes = Buffer.concat([ Buffer.from([this.suiteID, this.CHALLENGE_GENERATION_DOMAIN_SEPARATOR_FRONT]), points, Buffer.from([this.CHALLENGE_GENERATION_DOMAIN_SEPARATOR_BACK]), ]); const cString = sha256(pointBytes); if (truncateLen > cString.length) { throw new Error("Truncate length exceeds hash length"); } return cString.slice(0, truncateLen); } /** * Encode a message to an elliptic curve point using try-and-increment method * @param encodeToCurveSalt Salt value (usually the public key) as bytes * @param alpha Message to encode as bytes * @returns Point on the curve as bytes */ encodeToCurveTAI(encodeToCurveSalt, alpha) { // Prepare components for the hash input const prefix = Buffer.from([this.suiteID, this.ENCODE_TO_CURVE_DST_FRONT]); const suffix = Buffer.from([0x00, this.ENCODE_TO_CURVE_DST_BACK]); // Initial CTR=0 // Concatenate all parts using Buffer.concat for better efficiency const hashInput = Buffer.concat([prefix, encodeToCurveSalt, alpha, suffix]); const ctrPosition = hashInput.length - 2; const candidatePoint = new Uint8Array(33); candidatePoint[0] = this.COMPRESSED_POINT_EVEN_Y_PREFIX; for (let i = 0; i <= 255; i++) { hashInput[ctrPosition] = i; const hashBytes = sha256(hashInput); try { // Copy hash bytes to candidatePoint candidatePoint.set(hashBytes.slice(0, 32), 1); // Try to create a valid point const point = secp256k1.ProjectivePoint.fromHex(candidatePoint); // No need to apply cofactor multiplication (Secp256k1 has cofactor 1) // Return point in compressed format return point.toRawBytes(true); } catch (_err) { // Continue to next attempt } } throw new Error("EncodeToCurveTai: no solution found after 256 attempts"); } /** * Generate a deterministic nonce for ECDSA signatures using RFC 6979 * @param secretKey Secret key * @param data Input data * @returns Nonce as bytes */ generateNonce(secretKey, data) { return generateNonce(secretKey, sha256(data)); } /** * Convert a gamma point to its corresponding hash output * @param gamma Gamma point as bytes * @returns Hash output as bytes */ gammaToHash(gamma) { const data = Buffer.concat([ Buffer.from([this.suiteID]), Buffer.from([this.PROOF_TO_HASH_DOMAIN_SEPARATOR_FRONT]), gamma, Buffer.from([this.PROOF_TO_HASH_DOMAIN_SEPARATOR_BACK]), ]); return sha256(data); } } //# sourceMappingURL=secp256k1-vrf.js.map