javascriptrsadigital-signatureecdsasubtlecrypto

Generating deterministic ECDSA/RSA keys based on seed and salt using zero-dependencies


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:

  1. Generate the key material and then somehow import it as ECDSA keys.
  2. Generate 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);
});

Solution

  • 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;
    }