Skip to content

Misleading digest identifier in ECDSA verification: createVerify("RSA-SHA256") accepts ECDSA signatures (OpenSSL aliasing) — portability & auditability footgun #18

@fasrm

Description

@fasrm

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:

  1. Generates a P-256 keypair
  2. Signs a message with ECDSA+SHA256 producing a DER (r,s) signature
  3. Verifies the signature using both streaming createVerify() and one-shot crypto.verify() with multiple algorithm strings
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions