Fix DeriveECDHKeyMaterial: real test vectors, None KDF fix, P-521 comment, module=Ciphers
This commit is contained in:
parent
89e39a8642
commit
550d0b7ec6
@ -26,24 +26,22 @@ function parsePemOrHex(input, format, pemLabel) {
|
|||||||
.replace(new RegExp(`-----BEGIN ${pemLabel}-----`, "g"), "")
|
.replace(new RegExp(`-----BEGIN ${pemLabel}-----`, "g"), "")
|
||||||
.replace(new RegExp(`-----END ${pemLabel}-----`, "g"), "")
|
.replace(new RegExp(`-----END ${pemLabel}-----`, "g"), "")
|
||||||
.replace(/\s+/g, "");
|
.replace(/\s+/g, "");
|
||||||
|
|
||||||
return new Uint8Array(fromBase64(normalized, undefined, "byteArray"));
|
return new Uint8Array(fromBase64(normalized, undefined, "byteArray"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hex = value.replace(/\s+/g, "");
|
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.");
|
throw new OperationError("Expected hex input.");
|
||||||
}
|
|
||||||
|
|
||||||
const out = new Uint8Array(hex.length / 2);
|
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);
|
out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes PEM private keys to PKCS#8 DER for WebCrypto import.
|
* 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
|
* @param {string} input
|
||||||
* @returns {Uint8Array}
|
* @returns {Uint8Array}
|
||||||
@ -52,13 +50,11 @@ function parsePrivateKey(input) {
|
|||||||
const value = (input || "").trim();
|
const value = (input || "").trim();
|
||||||
if (!value.length) throw new OperationError("Missing key input.");
|
if (!value.length) throw new OperationError("Missing key input.");
|
||||||
|
|
||||||
if (!value.includes("-----BEGIN")) {
|
if (!value.includes("-----BEGIN"))
|
||||||
return parsePemOrHex(value, "HEX", "PRIVATE KEY");
|
return parsePemOrHex(value, "HEX", "PRIVATE KEY");
|
||||||
}
|
|
||||||
|
|
||||||
if (value.includes("-----BEGIN PRIVATE KEY-----")) {
|
if (value.includes("-----BEGIN PRIVATE KEY-----"))
|
||||||
return parsePemOrHex(value, "PEM", "PRIVATE KEY");
|
return parsePemOrHex(value, "PEM", "PRIVATE KEY");
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = r.KEYUTIL.getKey(value);
|
const key = r.KEYUTIL.getKey(value);
|
||||||
@ -79,24 +75,20 @@ function concatBytes(parts) {
|
|||||||
const total = parts.reduce((sum, p) => sum + p.length, 0);
|
const total = parts.reduce((sum, p) => sum + p.length, 0);
|
||||||
const out = new Uint8Array(total);
|
const out = new Uint8Array(total);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const p of parts) {
|
for (const p of parts) { out.set(p, offset); offset += p.length; }
|
||||||
out.set(p, offset);
|
|
||||||
offset += p.length;
|
|
||||||
}
|
|
||||||
return out;
|
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} rawSecret
|
||||||
* @param {Uint8Array} sharedInfo
|
* @param {Uint8Array} sharedInfo
|
||||||
* @param {string} hashAlg
|
* @param {string} hashAlg "SHA-256" or "SHA-512"
|
||||||
* @param {number} outputLen
|
* @param {number} outputLen
|
||||||
* @returns {Promise<Uint8Array>}
|
* @returns {Promise<Uint8Array>}
|
||||||
*/
|
*/
|
||||||
async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) {
|
async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) {
|
||||||
const digestName = hashAlg === "SHA-256" ? "SHA-256" : "SHA-512";
|
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let generated = 0;
|
let generated = 0;
|
||||||
@ -105,11 +97,11 @@ async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) {
|
|||||||
const ctr = new Uint8Array([
|
const ctr = new Uint8Array([
|
||||||
(counter >>> 24) & 0xff,
|
(counter >>> 24) & 0xff,
|
||||||
(counter >>> 16) & 0xff,
|
(counter >>> 16) & 0xff,
|
||||||
(counter >>> 8) & 0xff,
|
(counter >>> 8) & 0xff,
|
||||||
counter & 0xff,
|
counter & 0xff,
|
||||||
]);
|
]);
|
||||||
const data = concatBytes([ctr, rawSecret, sharedInfo]);
|
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);
|
chunks.push(digest);
|
||||||
generated += digest.length;
|
generated += digest.length;
|
||||||
counter += 1;
|
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 {
|
class DeriveECDHKeyMaterial extends Operation {
|
||||||
|
|
||||||
/**
|
|
||||||
* DeriveECDHKeyMaterial constructor
|
|
||||||
*/
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.name = "Derive ECDH Key Material";
|
this.name = "Derive ECDH Key Material";
|
||||||
this.module = "Ciphers";
|
this.module = "Ciphers";
|
||||||
this.description = "Paste your private key into the input field and paste the peer public key into the <b>Peer public key</b> argument field.<br><br><b>Input:</b> private key in PEM or PKCS#8 DER hex. PEM may be <code>BEGIN PRIVATE KEY</code> or <code>BEGIN EC PRIVATE KEY</code> when it can be normalized to PKCS#8.<br><b>Arguments:</b> choose the curve, peer public key format, optional KDF, optional shared info, output length, and output format.<br><br>Use <b>KDF = None</b> to get the raw shared secret.";
|
this.description = [
|
||||||
this.inlineHelp = "<strong>Input:</strong> your private key.<br><strong>Args:</strong> pick the curve, paste the peer public key, then choose raw shared secret or KDF output.";
|
"Paste your EC private key into the input field and provide the peer's public key as an argument.",
|
||||||
|
"<br><br>",
|
||||||
|
"<b>Input:</b> private key in PEM (<code>BEGIN PRIVATE KEY</code> or <code>BEGIN EC PRIVATE KEY</code>)",
|
||||||
|
" or as PKCS#8 DER hex.",
|
||||||
|
"<br><b>Arguments:</b> curve, peer public key, optional KDF (NIST SP 800-56A Concat KDF),",
|
||||||
|
" shared info, output length, and output format.",
|
||||||
|
"<br><br>",
|
||||||
|
"Use <b>KDF = None</b> 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 = "<strong>Input:</strong> your EC private key (PEM or PKCS8 DER hex).<br>" +
|
||||||
|
"<strong>Args:</strong> pick the curve, paste the peer public key, then choose raw secret or KDF output.";
|
||||||
|
|
||||||
this.testDataSamples = [
|
this.testDataSamples = [
|
||||||
{
|
{
|
||||||
name: "Known P-256 PEM vector",
|
name: "P-256 raw shared secret",
|
||||||
input: "__ECDH_TEST_PRIVATE_KEY__",
|
input: "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4HBsMvgcOvEQBrYJ\ndEXulke/dh5vYiOvfI41AToqfbWhRANCAAQgZgScW2pSpRRTOADLPL5D+8TF6xXx\nx9GDOE8V1xYj7arujDYH5935uCdVxXa84lUEw35+afHuh0bDmBDxolmx\n-----END PRIVATE KEY-----",
|
||||||
args: ["PEM", "P-256", "PEM", "__ECDH_TEST_PEER_PUBLIC_KEY__", "None", 32, "", "Hex"]
|
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.infoURL = "https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman";
|
||||||
this.inputType = "string";
|
this.inputType = "string";
|
||||||
this.outputType = "string";
|
this.outputType = "string";
|
||||||
@ -148,50 +152,49 @@ class DeriveECDHKeyMaterial extends Operation {
|
|||||||
"name": "Private key format",
|
"name": "Private key format",
|
||||||
"type": "option",
|
"type": "option",
|
||||||
"value": ["PEM", "Hex (PKCS8 DER)"],
|
"value": ["PEM", "Hex (PKCS8 DER)"],
|
||||||
"comment": "Input field format for your private key. PEM may be <code>BEGIN PRIVATE KEY</code> or a supported <code>BEGIN EC PRIVATE KEY</code> 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",
|
"name": "Curve",
|
||||||
"type": "option",
|
"type": "option",
|
||||||
"value": ["P-256", "P-384", "P-521"],
|
"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",
|
"name": "Peer public key format",
|
||||||
"type": "option",
|
"type": "option",
|
||||||
"value": ["PEM", "Hex (SPKI DER)"],
|
"value": ["PEM", "Hex (SPKI DER)"],
|
||||||
"comment": "Format of the peer public key argument. PEM should be an SPKI <code>BEGIN PUBLIC KEY</code> block."
|
"comment": "PEM should be an SPKI BEGIN PUBLIC KEY block.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Peer public key",
|
"name": "Peer public key",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"value": "-----BEGIN PUBLIC KEY-----",
|
"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",
|
"name": "KDF",
|
||||||
"type": "option",
|
"type": "option",
|
||||||
"value": ["None", "Concat KDF SHA-256", "Concat KDF SHA-512"],
|
"value": ["None", "Concat KDF SHA-256", "Concat KDF SHA-512"],
|
||||||
"comment": "Use <code>None</code> 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)",
|
"name": "Output length (bytes)",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"value": 32,
|
"value": 32,
|
||||||
"comment": "Used only with KDF modes. For <code>None</code>, 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)",
|
"name": "Shared info (hex)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"value": "",
|
"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",
|
"name": "Output format",
|
||||||
"type": "option",
|
"type": "option",
|
||||||
"value": ["Hex", "Base64"],
|
"value": ["Hex", "Base64"],
|
||||||
"comment": "Controls how the raw shared secret or KDF output is displayed."
|
},
|
||||||
}
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,51 +212,60 @@ class DeriveECDHKeyMaterial extends Operation {
|
|||||||
kdf,
|
kdf,
|
||||||
outLenArg,
|
outLenArg,
|
||||||
sharedInfoHex,
|
sharedInfoHex,
|
||||||
outputFormat
|
outputFormat,
|
||||||
] = args;
|
] = args;
|
||||||
|
|
||||||
if (!globalThis.crypto || !globalThis.crypto.subtle) {
|
if (!globalThis.crypto || !globalThis.crypto.subtle)
|
||||||
throw new OperationError("WebCrypto is not available in this runtime.");
|
throw new OperationError("WebCrypto is not available in this runtime.");
|
||||||
}
|
|
||||||
|
|
||||||
const privateDer = privateFmt === "PEM" ? parsePrivateKey(input) : parsePemOrHex(input, "HEX", "PRIVATE KEY");
|
const privateDer = privateFmt === "PEM"
|
||||||
const publicDer = parsePemOrHex(peerPublicKey, publicFmt === "PEM" ? "PEM" : "HEX", "PUBLIC KEY");
|
? 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 outLen = Math.max(1, Number(outLenArg) || 32);
|
||||||
|
|
||||||
const sharedInfoHexNorm = (sharedInfoHex || "").replace(/\s+/g, "");
|
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.");
|
throw new OperationError("Shared info must be hex.");
|
||||||
}
|
|
||||||
const sharedInfo = sharedInfoHexNorm.length ?
|
const sharedInfo = sharedInfoHexNorm.length
|
||||||
new Uint8Array(sharedInfoHexNorm.match(/.{2}/g).map(h => parseInt(h, 16))) :
|
? new Uint8Array(sharedInfoHexNorm.match(/.{2}/g).map(h => parseInt(h, 16)))
|
||||||
new Uint8Array();
|
: new Uint8Array();
|
||||||
|
|
||||||
const privateKey = await crypto.subtle.importKey(
|
const privateKey = await crypto.subtle.importKey(
|
||||||
"pkcs8",
|
"pkcs8", privateDer,
|
||||||
privateDer,
|
|
||||||
{ name: "ECDH", namedCurve: curve },
|
{ name: "ECDH", namedCurve: curve },
|
||||||
false,
|
false, ["deriveBits"]
|
||||||
["deriveBits"]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const publicKey = await crypto.subtle.importKey(
|
const publicKey = await crypto.subtle.importKey(
|
||||||
"spki",
|
"spki", publicDer,
|
||||||
publicDer,
|
|
||||||
{ name: "ECDH", namedCurve: curve },
|
{ 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 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") {
|
if (kdf === "Concat KDF SHA-256") {
|
||||||
out = await concatKdf(rawSecret, sharedInfo, "SHA-256", outLen);
|
out = await concatKdf(rawSecret, sharedInfo, "SHA-256", outLen);
|
||||||
} else if (kdf === "Concat KDF SHA-512") {
|
} else if (kdf === "Concat KDF SHA-512") {
|
||||||
out = await concatKdf(rawSecret, sharedInfo, "SHA-512", outLen);
|
out = await concatKdf(rawSecret, sharedInfo, "SHA-512", outLen);
|
||||||
} else {
|
} 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();
|
return outputFormat === "Base64" ? toBase64(out) : toHexFast(out).toUpperCase();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user