Affected code: checkECDSASignature() uses crypto.createVerify("RSA-SHA256") to verify U2F ECDSA (P-256) signatures.
Summary
The implementation verifies U2F ECDSA+SHA256 signatures using crypto.createVerify("RSA-SHA256"). In Node.js, the string passed to createVerify() is interpreted as a digest/algorithm identifier by the underlying OpenSSL provider. In my environment, "RSA-SHA256" is accepted as a digest alias and ECDSA verification succeeds when an EC public key is provided. This is semantically incorrect (suggests RSA) and introduces a portability/auditability risk: behavior depends on OpenSSL/provider algorithm-name aliasing and may differ across platforms, OpenSSL builds, or FIPS/provider configurations.
Why this matters (impact)
-
Auditability / code-review risk: the code appears to verify RSA signatures while it is actually verifying ECDSA signatures. This is confusing and can mislead security review, compliance audits, or automated analysis.
-
Portability risk / split behavior potential: the verification behavior relies on OpenSSL name-alias acceptance. Other environments may reject "RSA-SHA256" (or treat it differently), potentially causing U2F authentication failures (DoS/logical auth break) after deployment changes (Node/OpenSSL/provider/FIPS).
This is not reported as a signature bypass in my environment; it is a correctness and safety hardening issue with potential operational security impact.
Proof of Concept
I built a PoC that:
- Generates a P-256 keypair
- Signs a message with ECDSA+SHA256 producing a DER (r,s) signature
- Verifies the signature using both streaming createVerify() and one-shot crypto.verify() with multiple algorithm strings
- Includes a negative control by flipping one bit in the signature (expected to fail)
Code:
import crypto from "crypto";
function convertRawP256ToSPKIPem(raw65) {
if (!Buffer.isBuffer(raw65)) throw new Error("raw65 must be a Buffer");
if (raw65.length !== 65 || raw65[0] !== 0x04) {
throw new Error("raw65 must be uncompressed EC point: 65 bytes starting with 0x04");
}
// SPKI prefix for ecPublicKey + P-256, then BIT STRING (len 0x42 = 66 bytes: 0x00 + 65 raw)
const spkiPrefix = Buffer.from(
"3059301306072a8648ce3d020106082a8648ce3d030107034200",
"hex"
);
const spkiDer = Buffer.concat([spkiPrefix, raw65]);
const b64 = spkiDer.toString("base64");
const lines = b64.match(/.{1,64}/g) || [];
return `-----BEGIN PUBLIC KEY-----\n${lines.join("\n")}\n-----END PUBLIC KEY-----\n`;
}
function b64urlToBuf(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
return Buffer.from(s, "base64");
}
function makeP256Keypair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
namedCurve: "prime256v1", // P-256
});
// Export public key as raw uncompressed point (04 || X || Y) via JWK
const jwk = publicKey.export({ format: "jwk" });
const x = b64urlToBuf(jwk.x);
const y = b64urlToBuf(jwk.y);
const raw65 = Buffer.concat([Buffer.from([0x04]), x, y]);
const pem = convertRawP256ToSPKIPem(raw65);
return { publicKey, privateKey, raw65, pem };
}
function signDerSha256(data, privateKey) {
// Node produces ECDSA DER signature by default
return crypto.sign("sha256", data, privateKey);
}
function verify_streaming(alg, data, pem, sigDer) {
const v = crypto.createVerify(alg);
v.update(data);
v.end();
return v.verify(pem, sigDer);
}
function verify_oneshot(alg, data, pem, sigDer) {
return crypto.verify(alg, data, pem, sigDer);
}
function safeTry(fn) {
try {
return { ok: true, value: fn() };
} catch (e) {
return { ok: false, error: { name: e?.name, message: e?.message } };
}
}
function hexPreview(buf, n = 40) {
const h = buf.toString("hex");
return h.length > n * 2 ? h.slice(0, n * 2) + "..." : h;
}
const envInfo = {
node: process.version,
openssl: process.versions.openssl,
platform: process.platform,
arch: process.arch,
};
const { privateKey, raw65, pem } = makeP256Keypair();
const data = Buffer.from(`u2f-poc-matrix:${Date.now()}`, "utf8");
const sigDer = signDerSha256(data, privateKey);
// Alg strings to test.
// NOTE: Some strings may throw depending on OpenSSL/provider; that's part of the point.
const algs = [
"SHA256",
"sha256",
"RSA-SHA256",
"ecdsa-with-SHA256",
"RSA+SHA256",
"SHA256withRSA",
];
const results = {
env: envInfo,
inputs: {
data: data.toString("utf8"),
pubRaw65_len: raw65.length,
pubRaw65_first8: raw65.slice(0, 8).toString("hex"),
sigDer_hex_preview: hexPreview(sigDer, 50),
},
checks: {},
};
// Baseline sanity: verify should succeed with the “correct” sha256 naming.
results.checks.baseline_streaming_SHA256 = safeTry(() =>
verify_streaming("SHA256", data, pem, sigDer)
);
results.checks.baseline_oneshot_sha256 = safeTry(() =>
verify_oneshot("sha256", data, pem, sigDer)
);
// Matrix:
results.checks.matrix = {};
for (const alg of algs) {
results.checks.matrix[alg] = {
streaming: safeTry(() => verify_streaming(alg, data, pem, sigDer)),
oneshot: safeTry(() => verify_oneshot(alg, data, pem, sigDer)),
};
}
// Negative control: mutate signature (flip one bit) should fail.
const sigBad = Buffer.from(sigDer);
sigBad[sigBad.length - 1] ^= 0x01;
results.checks.negative_control = {
streaming_SHA256: safeTry(() => verify_streaming("SHA256", data, pem, sigBad)),
oneshot_sha256: safeTry(() => verify_oneshot("sha256", data, pem, sigBad)),
// Also check RSA-SHA256 negative control
streaming_RSA_SHA256: safeTry(() => verify_streaming("RSA-SHA256", data, pem, sigBad)),
oneshot_RSA_SHA256: safeTry(() => verify_oneshot("RSA-SHA256", data, pem, sigBad)),
};
console.log(JSON.stringify(results, null, 2));
Environment:
Node: v24.13.1
OpenSSL: 3.5.5
Platform: win32 x64
Observed behavior:
verify("SHA256") succeeds (expected)
verify("sha256") succeeds (expected)
verify("RSA-SHA256") succeeds for ECDSA signatures with EC keys (unexpected/misleading)
Several other algorithm identifiers are rejected as invalid digests (showing provider-specific name handling)
Negative controls fail as expected for both "SHA256" and "RSA-SHA256" (sanity check)
PoC output (abridged):
Baseline:
createVerify("SHA256") → true
crypto.verify("sha256") → true
Matrix:
"RSA-SHA256" → true (streaming + oneshot)
"ecdsa-with-SHA256" → rejected (TypeError: Invalid digest)
"RSA+SHA256" → rejected
"SHA256withRSA" → rejected
Negative control (signature bit flipped):
"SHA256" → false
"RSA-SHA256" → false
Root cause
crypto.createVerify() delegates digest/algorithm parsing to OpenSSL/provider mappings. "RSA-SHA256" may be treated as an alias for SHA-256 digest configuration rather than strictly RSA-specific, and the key type (EC vs RSA) determines the signature mechanism used during verification. This makes the code’s intent ambiguous and backend-dependent.
Recommendation / Fix
Replace:
crypto.createVerify("RSA-SHA256")
With a digest-only identifier that matches the actual intent:
crypto.createVerify("sha256")
(or "SHA256"). This matches the code comment (“SHA256 is what we set here”), avoids RSA mislabeling, and reduces provider-specific alias reliance.
Suggested regression test
Add a unit test that:
- Generates a P-256 keypair
- Signs data with ECDSA+SHA256
- Verifies using createVerify("sha256") (must pass)
Optionally asserts that "RSA-SHA256" is not used (lint/style) to prevent reintroduction
Affected code: checkECDSASignature() uses crypto.createVerify("RSA-SHA256") to verify U2F ECDSA (P-256) signatures.
Summary
The implementation verifies U2F ECDSA+SHA256 signatures using crypto.createVerify("RSA-SHA256"). In Node.js, the string passed to createVerify() is interpreted as a digest/algorithm identifier by the underlying OpenSSL provider. In my environment, "RSA-SHA256" is accepted as a digest alias and ECDSA verification succeeds when an EC public key is provided. This is semantically incorrect (suggests RSA) and introduces a portability/auditability risk: behavior depends on OpenSSL/provider algorithm-name aliasing and may differ across platforms, OpenSSL builds, or FIPS/provider configurations.
Why this matters (impact)
Auditability / code-review risk: the code appears to verify RSA signatures while it is actually verifying ECDSA signatures. This is confusing and can mislead security review, compliance audits, or automated analysis.
Portability risk / split behavior potential: the verification behavior relies on OpenSSL name-alias acceptance. Other environments may reject "RSA-SHA256" (or treat it differently), potentially causing U2F authentication failures (DoS/logical auth break) after deployment changes (Node/OpenSSL/provider/FIPS).
This is not reported as a signature bypass in my environment; it is a correctness and safety hardening issue with potential operational security impact.
Proof of Concept
I built a PoC that:
Code:
Environment:
Node: v24.13.1
OpenSSL: 3.5.5
Platform: win32 x64
Observed behavior:
verify("SHA256") succeeds (expected)
verify("sha256") succeeds (expected)
verify("RSA-SHA256") succeeds for ECDSA signatures with EC keys (unexpected/misleading)
Several other algorithm identifiers are rejected as invalid digests (showing provider-specific name handling)
Negative controls fail as expected for both "SHA256" and "RSA-SHA256" (sanity check)
PoC output (abridged):
Baseline:
createVerify("SHA256") → true
crypto.verify("sha256") → true
Matrix:
"RSA-SHA256" → true (streaming + oneshot)
"ecdsa-with-SHA256" → rejected (TypeError: Invalid digest)
"RSA+SHA256" → rejected
"SHA256withRSA" → rejected
Negative control (signature bit flipped):
"SHA256" → false
"RSA-SHA256" → false
Root cause
crypto.createVerify() delegates digest/algorithm parsing to OpenSSL/provider mappings. "RSA-SHA256" may be treated as an alias for SHA-256 digest configuration rather than strictly RSA-specific, and the key type (EC vs RSA) determines the signature mechanism used during verification. This makes the code’s intent ambiguous and backend-dependent.
Recommendation / Fix
Replace:
crypto.createVerify("RSA-SHA256")
With a digest-only identifier that matches the actual intent:
crypto.createVerify("sha256")
(or "SHA256"). This matches the code comment (“SHA256 is what we set here”), avoids RSA mislabeling, and reduces provider-specific alias reliance.
Suggested regression test
Add a unit test that:
Optionally asserts that "RSA-SHA256" is not used (lint/style) to prevent reintroduction