Given seed and salt how do I produce a set of deterministic ECDSA keys using zero-dependencies JavaScript and SubtleCrypto
?
(This is a continuation of this question.)
My understanding is that there're two major ways to go about it:
x
& y
required to generate ECDSA keys (I'm not sure how, given that generateKey
accepts just named curves; maybe via JWK?)I'd like the solution to rely as much as possible on the SubtleCrypto
API with as little custom code as possible.
The code:
async function generateDeterministicECDSAKey(seed, salt) {
// Step 1: Convert seed and salt to ArrayBuffers
const enc = new TextEncoder();
const seedBuffer = enc.encode(seed); // Seed as a buffer
const saltBuffer = enc.encode(salt); // Salt as a buffer
// Step 2: Derive key material using PBKDF2
const baseKey = await crypto.subtle.importKey(
'raw',
seedBuffer,
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
// Derive 256 bits (32 bytes) of key material for the private key
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: saltBuffer, // Salt for PBKDF2
iterations: 100000, // Use a secure iteration count
hash: 'SHA-256' // Hash function for PBKDF2
},
baseKey,
256 // 256 bits for the derived key
);
// Step 3: Import derived key material as ECDSA private key
// NOTE - that's where it fails due to a mismatch between the "raw" format
// and { name: 'ECDSA', namedCurve: 'P-256' } algo
const privateKey = await crypto.subtle.importKey(
'raw',
derivedBits, // Use derived bits as private key
{ name: 'ECDSA', namedCurve: 'P-256' }, // ECDSA using the P-256 curve
true, // Mark key as exportable
['sign'] // Key usage for signing
);
// Step 4: Export the private key in PKCS8 format (optional)
const pkcs8Key = await crypto.subtle.exportKey('pkcs8', privateKey);
// Step 5: Generate public key pair (optional)
const publicKey = await crypto.subtle.exportKey('jwk', privateKey);
return {
privateKey: privateKey,
pkcs8Key: pkcs8Key,
publicKey: publicKey
};
}
// Helper function to convert ArrayBuffer to base64 string (for display)
function arrayBufferToBase64(buffer) {
const binary = String.fromCharCode.apply(null, new Uint8Array(buffer));
return window.btoa(binary);
}
// Example usage
const seed = "your_deterministic_seed"; // Example seed
const salt = "your_salt_value"; // Example salt
await generateDeterministicECDSAKey(seed, salt).then(keys => {
const privateKeyBase64 = arrayBufferToBase64(keys.pkcs8Key);
console.log("Private Key (PKCS8 Base64):", privateKeyBase64);
console.log("Public Key (JWK):", keys.publicKey);
});
This code was created as a comprehensive answer to the question covering an educational example of ECDSA without any external modules working in most modern browsers.
Raw Code: index.html
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ECDSA with Deterministic Key Generation</title>
</head>
<body>
<h1>Deterministic ECDSA Keys</h1>
<label for="seed">Seed:</label>
<input type="text" id="seed" placeholder="Enter your seed"><br><br>
<label for="salt">Salt:</label>
<input type="text" id="salt" placeholder="Enter your salt"><br><br>
<button onclick="generateKeys()">Generate Keys</button><br><br>
<label for="message">Message to Sign:</label>
<input type="text" id="message" placeholder="Enter your message"><br><br>
<button onclick="signMessage()">Sign</button><br><br>
<button onclick="convertToJWK()">Convert to JWK</button>
<button onclick="verifyWithCryptoAPI()">Verify with Crypto API</button><br><br>
<div id="output"></div>
<script src="math.js"></script>
<script src="script.js"></script>
</body>
</html>
script.js
// Constants for the P-256 curve (secp256r1 or prime256v1)
const n = BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"); // Curve order (n)
const P = BigInt("0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff"); // Prime field modulus (P)
const A = BigInt("0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc"); // Curve coefficient A
const B = BigInt("0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"); // Curve coefficient B
const G = {
x: BigInt("0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"), // Generator point X-coordinate (G)
y: BigInt("0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5") // Generator point Y-coordinate (G)
};
// Generate deterministic private key from the seed and salt using SHA-256
async function generateKeys() {
const seed = document.getElementById('seed').value;
const salt = document.getElementById('salt').value;
if (!seed || !salt) {
alert("Please enter both seed and salt!");
return;
}
const enc = new TextEncoder();
let privateKey = BigInt(0);
do {
// Derive the private key using SHA-256 with the seed and salt
const derivedBits = await crypto.subtle.digest("SHA-256", enc.encode(seed + salt));
privateKey = BigInt("0x" + Array.from(new Uint8Array(derivedBits))
.map(b => b.toString(16).padStart(2, "0")).join("")) % n; // Ensure private key is within range [0, n-1]
} while (privateKey <= 0n || privateKey >= n); // Keep trying until a valid private key is generated
const publicKey = pointMult(privateKey, G); // Generate the corresponding public key using scalar multiplication
if (!publicKey || !validatePoint(publicKey)) {
alert("Invalid public key generated!");
return;
}
document.getElementById('output').innerHTML = `
<h3>Private Key:</h3>
<pre id="privateKey">${privateKey.toString(16)}</pre>
<h3>Public Key:</h3>
<pre id="publicKey">${JSON.stringify({ x: publicKey.x.toString(16), y: publicKey.y.toString(16) }, null, 2)}</pre>
`;
}
function constantTimeCompare(a, b) {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i]; // XOR operation: If any bit differs, the result will be non-zero
}
return result === 0;
}
// Validate a point on the elliptic curve (check if it satisfies the curve equation)
function validatePoint(point) {
if (point === null) return true;
const { x, y } = point;
const lhs = mod(y ** 2n, P); // Left-hand side of the curve equation: y^2 mod p
const rhs = mod(x ** 3n + A * x + B, P); // Right-hand side of the curve equation: x^3 + A * x + B mod p
return lhs === rhs;
}
// Generate a random k for the signature process
function generateRandomK() {
const array = new Uint8Array(32); // 32 bytes = 256 bits
crypto.getRandomValues(array); // Generate random bytes
let k = BigInt("0x" + Array.from(array).map(b => b.toString(16).padStart(2, '0')).join(''));
return k % n; // Ensure k is within the valid range [1, n-1]
}
// Sign a message using the private key
async function signMessage() {
const message = document.getElementById('message').value;
const privateKeyHex = document.getElementById('privateKey').textContent;
if (!privateKeyHex) {
alert("Please generate a key pair first!");
return;
}
if (!message) {
alert("Please enter a message!");
return;
}
const prevSignatureEntry = document.getElementById('signature-container');
if (prevSignatureEntry) {
prevSignatureEntry.remove();
}
console.time("signMessage");
const privateKey = BigInt("0x" + privateKeyHex); // Convert the private key from hex to BigInt
// Hash the message using SHA-256
const messageHashArrayBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(message));
const messageHashArray = new Uint8Array(messageHashArrayBuffer);
const messageHash = BigInt("0x" + Array.from(messageHashArray)
.map(byte => byte.toString(16).padStart(2, '0'))
.join(''));
// Compute the signature
let k = generateRandomK(); // Generate a random k for signing
const rPoint = pointMult(k, G); // Compute the elliptic curve point k*G
const r = rPoint.x % n; // r is the x-coordinate of the point mod n
const s = mod(((messageHash + r * privateKey) * modInv(k, n)) % n, n); // Compute the signature parameter s
k = null;
console.timeEnd("signMessage");
const signature = JSON.stringify({ r: r.toString(16), s: s.toString(16) });
document.getElementById('output').innerHTML += `
<div id="signature-container">
<h3>Signature:</h3>
<pre id="signature">${signature}</pre>
</div>
`;
const isValid = await verifySignature();
const signatureContainer = document.getElementById('signature-container');
// Change the background color based on the validity of the signature
if (isValid) {
signatureContainer.style.backgroundColor = 'green';
} else {
signatureContainer.style.backgroundColor = 'red';
}
}
// Verify the signature using the public key
async function verifySignature() {
const message = document.getElementById('message').value;
const signatureText = document.getElementById('signature').textContent;
if (!signatureText) {
alert("No signature found!");
return false;
}
// Retrieve the public key
const publicKeyElement = document.getElementById('publicKey');
if (!publicKeyElement) {
alert("Public key has not been generated yet.");
return false;
}
const publicKeyJSON = JSON.parse(publicKeyElement.textContent);
console.time("verifySignature");
const publicKey = {
x: BigInt("0x" + publicKeyJSON.x),
y: BigInt("0x" + publicKeyJSON.y)
};
const signature = JSON.parse(signatureText);
const r = BigInt("0x" + signature.r);
const s = BigInt("0x" + signature.s);
// Check if r and s are within the valid range
if (r <= 0n || r >= n || s <= 0n || s >= n) {
alert("Invalid signature values");
return false;
}
// 1. Compute the hash of the message
const msgHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(message));
const msgHashBigInt = BigInt("0x" + Array.from(new Uint8Array(msgHash)).map(b => b.toString(16).padStart(2, "0")).join(""));
// 2. Calculate w = s^(-1) mod n
const w = modInv(s, n);
// 3. Calculate u1 = H(m) * w mod n and u2 = r * w mod n
const u1 = (msgHashBigInt * w) % n;
const u2 = (r * w) % n;
// 4. Calculate the point P = u1 * G + u2 * Q
const pointP = pointAdd(pointMult(u1, G), pointMult(u2, publicKey));
// 5. Check if r == xP mod n using constant-time comparison
const xP = pointP.x % n;
const rHex = r.toString(16).padStart(64, '0'); // Ensure r is in a consistent format (hex)
console.timeEnd("verifySignature");
// Compare xP and r in constant time
if (constantTimeCompare(rHex, xP.toString(16).padStart(64, '0'))) {
console.log("Signature is valid.");
return true;
} else {
console.log("Signature is invalid.");
return false;
}
}
function toJWK(privateKey, publicKey, curveName) {
const base64UrlEncode = (buffer) =>
btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const hexToUint8Array = (hex) =>
new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
const xArray = publicKey.x.toString(16).padStart(64, '0');
const yArray = publicKey.y.toString(16).padStart(64, '0');
const dArray = privateKey ? privateKey.toString(16).padStart(64, '0') : null;
return {
kty: 'EC',
crv: curveName,
x: base64UrlEncode(hexToUint8Array(xArray)),
y: base64UrlEncode(hexToUint8Array(yArray)),
...(privateKey ? { d: base64UrlEncode(hexToUint8Array(dArray)) } : {})
};
}
function convertToJWK() {
const privateKeyHex = document.getElementById('privateKey')?.textContent;
const publicKeyJSON = JSON.parse(document.getElementById('publicKey')?.textContent);
if (!privateKeyHex || !publicKeyJSON) {
alert("Generate keys first!");
return;
}
const privateKey = BigInt("0x" + privateKeyHex);
const publicKey = {
x: BigInt("0x" + publicKeyJSON.x),
y: BigInt("0x" + publicKeyJSON.y)
};
const jwk = toJWK(privateKey, publicKey, "P-256");
console.log("Generated JWK:", jwk);
document.getElementById('output').innerHTML += `
<h3>JWK:</h3>
<pre id="jwkText">${JSON.stringify(jwk, null, 2)}</pre>
`;
}
async function verifyWithCryptoAPI() {
const jwkText = document.getElementById('jwkText').textContent;
const signatureText = document.getElementById('signature').textContent;
const message = document.getElementById('message').value;
if (!jwkText || !signatureText || !message) {
alert("Make sure keys, signature, and message are available!");
return;
}
const jwk = JSON.parse(jwkText);
const signature = JSON.parse(signatureText);
try {
const cryptoKey = await crypto.subtle.importKey(
"jwk",
{
kty: jwk.kty,
crv: jwk.crv,
x: jwk.x,
y: jwk.y,
},
{ name: "ECDSA", namedCurve: jwk.crv },
false,
["verify"]
);
const encoder = new TextEncoder();
const data = encoder.encode(message);
const r = BigInt("0x" + signature.r);
const s = BigInt("0x" + signature.s);
const rBytes = new Uint8Array(r.toString(16).padStart(64, '0').match(/.{2}/g).map(byte => parseInt(byte, 16)));
const sBytes = new Uint8Array(s.toString(16).padStart(64, '0').match(/.{2}/g).map(byte => parseInt(byte, 16)));
const sigBytes = new Uint8Array([...rBytes, ...sBytes]);
const isValid = await crypto.subtle.verify(
{ name: "ECDSA", hash: { name: "SHA-256" } },
cryptoKey,
sigBytes,
data
);
alert(`Verification result: ${isValid}`);
} catch (err) {
console.error("Error during verification:", err);
alert("Verification failed!");
}
}
math.js
// Modular arithmetic functions for the elliptic curve operations
// Modulo operation to handle negative results correctly
function mod(a, m) {
return ((a % m) + m) % m;
}
// Subtraction in modular space
function modSub(a, b, m) {
return mod(a - b, m);
}
// Multiplication in modular space
function modMult(a, b, p) {
return mod(a * b, p);
}
// Modular inverse using the Extended Euclidean Algorithm
function modInv(a, p) {
if (a === 0n) throw new Error("Modular inverse is not defined for 0");
let t = 0n, newT = 1n;
let r = p, newR = a % p;
// Extended Euclidean Algorithm to find the inverse of 'a' modulo 'p'
while (newR !== 0n) {
const quotient = r / newR;
[t, newT] = [newT, t - quotient * newT];
[r, newR] = [newR, r - quotient * newR];
}
// If the greatest common divisor is greater than 1, no modular inverse exists
if (r > 1n) throw new Error("Element not invertible");
// Ensure the result is positive
if (t < 0n) t += p;
return t;
}
// Elliptic curve point addition (Weierstrass equation)
function pointAdd(p1, p2) {
if (p1 === null) return p2; // If p1 is the identity (point at infinity), return p2
if (p2 === null) return p1; // If p2 is the identity, return p1
if (p1.x === p2.x && mod(p1.y + p2.y, P) === 0n) return null; // If p1 and p2 are inverses, return the identity
let lambda;
if (p1.x === p2.x && p1.y === p2.y) {
// Point doubling
const num = 3n * mod(p1.x ** 2n, P) + A; // Formula for point doubling: λ = (3 * x1^2 + A) / 2y1
const denom = modInv(2n * p1.y, P); // Denominator is the modular inverse of 2y1
lambda = modMult(num, denom, P);
} else {
// Regular point addition
const num = modSub(p2.y, p1.y, P); // y2 - y1
const denom = modInv(modSub(p2.x, p1.x, P), P); // x2 - x1
lambda = modMult(num, denom, P); // λ = (y2 - y1) / (x2 - x1)
}
// Calculate the new point coordinates
const x3 = mod(lambda ** 2n - p1.x - p2.x, P); // x3 = λ^2 - x1 - x2
const y3 = mod(lambda * modSub(p1.x, x3, P) - p1.y, P); // y3 = λ(x1 - x3) - y1
return { x: x3, y: y3 };
}
// Elliptic curve point multiplication (scalar multiplication)
function pointMult(k, point) {
let result = null;
let addend = point;
// Perform the binary method for scalar multiplication
while (k > 0n) {
if (k & 1n) {
result = pointAdd(result, addend); // Add the point to the result if the corresponding bit is 1
}
addend = pointAdd(addend, addend); // Double the point (equivalent to shift left in binary)
k >>= 1n; // Right shift to process the next bit of k
}
return result;
}