diff --git a/src/core/operations/DeriveECDHKeyMaterial.mjs b/src/core/operations/DeriveECDHKeyMaterial.mjs index e4b3b0ed..59b3bc1a 100644 --- a/src/core/operations/DeriveECDHKeyMaterial.mjs +++ b/src/core/operations/DeriveECDHKeyMaterial.mjs @@ -26,24 +26,22 @@ function parsePemOrHex(input, format, pemLabel) { .replace(new RegExp(`-----BEGIN ${pemLabel}-----`, "g"), "") .replace(new RegExp(`-----END ${pemLabel}-----`, "g"), "") .replace(/\s+/g, ""); - return new Uint8Array(fromBase64(normalized, undefined, "byteArray")); } const hex = value.replace(/\s+/g, ""); - if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) { + if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) throw new OperationError("Expected hex input."); - } const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) { + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); - } return out; } /** * Normalizes PEM private keys to PKCS#8 DER for WebCrypto import. + * Accepts PKCS#8 PEM, SEC1 EC PEM (BEGIN EC PRIVATE KEY), or raw hex. * * @param {string} input * @returns {Uint8Array} @@ -52,13 +50,11 @@ function parsePrivateKey(input) { const value = (input || "").trim(); if (!value.length) throw new OperationError("Missing key input."); - if (!value.includes("-----BEGIN")) { + if (!value.includes("-----BEGIN")) return parsePemOrHex(value, "HEX", "PRIVATE KEY"); - } - if (value.includes("-----BEGIN PRIVATE KEY-----")) { + if (value.includes("-----BEGIN PRIVATE KEY-----")) return parsePemOrHex(value, "PEM", "PRIVATE KEY"); - } try { const key = r.KEYUTIL.getKey(value); @@ -79,24 +75,20 @@ function concatBytes(parts) { const total = parts.reduce((sum, p) => sum + p.length, 0); const out = new Uint8Array(total); let offset = 0; - for (const p of parts) { - out.set(p, offset); - offset += p.length; - } + for (const p of parts) { out.set(p, offset); offset += p.length; } return out; } /** - * Derives output keying material using a simple Concat KDF. + * Derives output keying material using NIST SP 800-56A Concat KDF. * * @param {Uint8Array} rawSecret * @param {Uint8Array} sharedInfo - * @param {string} hashAlg + * @param {string} hashAlg "SHA-256" or "SHA-512" * @param {number} outputLen * @returns {Promise} */ async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) { - const digestName = hashAlg === "SHA-256" ? "SHA-256" : "SHA-512"; let counter = 1; const chunks = []; let generated = 0; @@ -105,11 +97,11 @@ async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) { const ctr = new Uint8Array([ (counter >>> 24) & 0xff, (counter >>> 16) & 0xff, - (counter >>> 8) & 0xff, - counter & 0xff, + (counter >>> 8) & 0xff, + counter & 0xff, ]); const data = concatBytes([ctr, rawSecret, sharedInfo]); - const digest = new Uint8Array(await crypto.subtle.digest(digestName, data)); + const digest = new Uint8Array(await crypto.subtle.digest(hashAlg, data)); chunks.push(digest); generated += digest.length; counter += 1; @@ -119,27 +111,39 @@ async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) { } /** - * Derive ECDH key material operation + * Derive ECDH Key Material operation. */ class DeriveECDHKeyMaterial extends Operation { - /** - * DeriveECDHKeyMaterial constructor - */ constructor() { super(); this.name = "Derive ECDH Key Material"; this.module = "Ciphers"; - this.description = "Paste your private key into the input field and paste the peer public key into the Peer public key argument field.

Input: private key in PEM or PKCS#8 DER hex. PEM may be BEGIN PRIVATE KEY or BEGIN EC PRIVATE KEY when it can be normalized to PKCS#8.
Arguments: choose the curve, peer public key format, optional KDF, optional shared info, output length, and output format.

Use KDF = None to get the raw shared secret."; - this.inlineHelp = "Input: your private key.
Args: pick the curve, paste the peer public key, then choose raw shared secret or KDF output."; + this.description = [ + "Paste your EC private key into the input field and provide the peer's public key as an argument.", + "

", + "Input: private key in PEM (BEGIN PRIVATE KEY or BEGIN EC PRIVATE KEY)", + " or as PKCS#8 DER hex.", + "
Arguments: curve, peer public key, optional KDF (NIST SP 800-56A Concat KDF),", + " shared info, output length, and output format.", + "

", + "Use KDF = None to obtain the raw shared secret (the x-coordinate of the shared EC point).", + " The output length argument is ignored in None mode.", + ].join(""); + this.inlineHelp = "Input: your EC private key (PEM or PKCS8 DER hex).
" + + "Args: pick the curve, paste the peer public key, then choose raw secret or KDF output."; + this.testDataSamples = [ { - name: "Known P-256 PEM vector", - input: "__ECDH_TEST_PRIVATE_KEY__", - args: ["PEM", "P-256", "PEM", "__ECDH_TEST_PEER_PUBLIC_KEY__", "None", 32, "", "Hex"] - } + name: "P-256 raw shared secret", + input: "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4HBsMvgcOvEQBrYJ\ndEXulke/dh5vYiOvfI41AToqfbWhRANCAAQgZgScW2pSpRRTOADLPL5D+8TF6xXx\nx9GDOE8V1xYj7arujDYH5935uCdVxXa84lUEw35+afHuh0bDmBDxolmx\n-----END PRIVATE KEY-----", + args: ["PEM", "P-256", "PEM", + "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEa+FXJzzko0OZ9DcOaXpLzAkSt7bE\nXXVKQqYfsmuelH6QgH86dMR04/bvnhl4bF7YKbMWDlPRHs9haSeR/PhFNg==\n-----END PUBLIC KEY-----", + "None", 32, "", "Hex"], + }, ]; + this.infoURL = "https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman"; this.inputType = "string"; this.outputType = "string"; @@ -148,50 +152,49 @@ class DeriveECDHKeyMaterial extends Operation { "name": "Private key format", "type": "option", "value": ["PEM", "Hex (PKCS8 DER)"], - "comment": "Input field format for your private key. PEM may be BEGIN PRIVATE KEY or a supported BEGIN EC PRIVATE KEY that can be normalized to PKCS#8." + "comment": "PEM may be BEGIN PRIVATE KEY (PKCS#8) or BEGIN EC PRIVATE KEY (SEC1, auto-converted).", }, { "name": "Curve", "type": "option", "value": ["P-256", "P-384", "P-521"], - "comment": "Must match the actual curve of both keys. The op does not auto-detect or translate between curves." + "comment": "Must match the actual curve of both keys. The op does not auto-detect the curve.", }, { "name": "Peer public key format", "type": "option", "value": ["PEM", "Hex (SPKI DER)"], - "comment": "Format of the peer public key argument. PEM should be an SPKI BEGIN PUBLIC KEY block." + "comment": "PEM should be an SPKI BEGIN PUBLIC KEY block.", }, { "name": "Peer public key", "type": "text", "value": "-----BEGIN PUBLIC KEY-----", - "comment": "Paste the full peer public key here. For PEM input, include the begin/end lines." + "comment": "Paste the full peer public key here.", }, { "name": "KDF", "type": "option", "value": ["None", "Concat KDF SHA-256", "Concat KDF SHA-512"], - "comment": "Use None to return the raw shared secret. The KDF options use a simple Concat KDF over the shared secret plus optional shared info." + "comment": "None returns the raw shared secret. Concat KDF follows NIST SP 800-56A ยง5.8.1.", }, { "name": "Output length (bytes)", "type": "number", "value": 32, - "comment": "Used only with KDF modes. For None, the raw shared secret length is determined by the curve." + "comment": "Used only with KDF modes. Ignored when KDF is None.", }, { "name": "Shared info (hex)", "type": "string", "value": "", - "comment": "Optional KDF shared info as hex. Leave blank if your test profile does not include shared info." + "comment": "Optional KDF shared info as hex. Leave blank if not used.", }, { "name": "Output format", "type": "option", "value": ["Hex", "Base64"], - "comment": "Controls how the raw shared secret or KDF output is displayed." - } + }, ]; } @@ -209,51 +212,60 @@ class DeriveECDHKeyMaterial extends Operation { kdf, outLenArg, sharedInfoHex, - outputFormat + outputFormat, ] = args; - if (!globalThis.crypto || !globalThis.crypto.subtle) { + if (!globalThis.crypto || !globalThis.crypto.subtle) throw new OperationError("WebCrypto is not available in this runtime."); - } - const privateDer = privateFmt === "PEM" ? parsePrivateKey(input) : parsePemOrHex(input, "HEX", "PRIVATE KEY"); - const publicDer = parsePemOrHex(peerPublicKey, publicFmt === "PEM" ? "PEM" : "HEX", "PUBLIC KEY"); + const privateDer = privateFmt === "PEM" + ? parsePrivateKey(input) + : parsePemOrHex(input, "HEX", "PRIVATE KEY"); + + const publicDer = parsePemOrHex( + peerPublicKey, + publicFmt === "PEM" ? "PEM" : "HEX", + "PUBLIC KEY" + ); + const outLen = Math.max(1, Number(outLenArg) || 32); const sharedInfoHexNorm = (sharedInfoHex || "").replace(/\s+/g, ""); - if (sharedInfoHexNorm.length % 2 !== 0 || (sharedInfoHexNorm.length > 0 && !/^[0-9a-fA-F]+$/.test(sharedInfoHexNorm))) { + if (sharedInfoHexNorm.length % 2 !== 0 || + (sharedInfoHexNorm.length > 0 && !/^[0-9a-fA-F]+$/.test(sharedInfoHexNorm))) throw new OperationError("Shared info must be hex."); - } - const sharedInfo = sharedInfoHexNorm.length ? - new Uint8Array(sharedInfoHexNorm.match(/.{2}/g).map(h => parseInt(h, 16))) : - new Uint8Array(); + + const sharedInfo = sharedInfoHexNorm.length + ? new Uint8Array(sharedInfoHexNorm.match(/.{2}/g).map(h => parseInt(h, 16))) + : new Uint8Array(); const privateKey = await crypto.subtle.importKey( - "pkcs8", - privateDer, + "pkcs8", privateDer, { name: "ECDH", namedCurve: curve }, - false, - ["deriveBits"] + false, ["deriveBits"] ); const publicKey = await crypto.subtle.importKey( - "spki", - publicDer, + "spki", publicDer, { name: "ECDH", namedCurve: curve }, - false, - [] + false, [] ); + // P-521 has a 521-bit field; deriveBits requires a multiple of 8, + // so request 528 bits (66 bytes) and WebCrypto returns the full x-coordinate. const curveBits = curve === "P-256" ? 256 : curve === "P-384" ? 384 : 528; - const rawSecret = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, curveBits)); + const rawSecret = new Uint8Array( + await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, curveBits) + ); - let out = rawSecret; + let out; if (kdf === "Concat KDF SHA-256") { out = await concatKdf(rawSecret, sharedInfo, "SHA-256", outLen); } else if (kdf === "Concat KDF SHA-512") { out = await concatKdf(rawSecret, sharedInfo, "SHA-512", outLen); } else { - out = rawSecret.slice(0, outLen); + // None: return the full raw shared secret; output length arg is ignored. + out = rawSecret; } return outputFormat === "Base64" ? toBase64(out) : toHexFast(out).toUpperCase();