From 203101c7155ff17fb9a6e8b77e8eb712a4133e42 Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Tue, 9 Jun 2026 23:31:05 +0200 Subject: [PATCH] Refactor ECDSA and ASN.1 helpers to use derBytesToPem; remove redundant functions --- src/core/lib/Asn1.mjs | 14 ++++ src/core/lib/Ecdsa.mjs | 50 ++------------ src/core/lib/KeyConvert.mjs | 69 +++++--------------- src/core/lib/X509.mjs | 15 ++--- src/core/operations/GenerateECDSAKeyPair.mjs | 13 +--- tests/operations/tests/ECDSA.mjs | 16 +++++ 6 files changed, 57 insertions(+), 120 deletions(-) diff --git a/src/core/lib/Asn1.mjs b/src/core/lib/Asn1.mjs index d64bac7d..19e523f7 100644 --- a/src/core/lib/Asn1.mjs +++ b/src/core/lib/Asn1.mjs @@ -130,6 +130,20 @@ export function derToPem(hex, label) { bytes[i] = Number.isNaN(v) ? 0 : v; } + return derBytesToPem(bytes, label); +} + +/** + * Wrap raw DER bytes in a PEM envelope with LF line endings. + * + * Canonical helper shared by the key/ECDSA conversion modules; the hex-input + * {@link derToPem} above delegates here after parsing. + * + * @param {Uint8Array} bytes + * @param {string} label + * @returns {string} + */ +export function derBytesToPem(bytes, label) { let b64; if (typeof Buffer !== "undefined") { b64 = Buffer.from(bytes).toString("base64"); diff --git a/src/core/lib/Ecdsa.mjs b/src/core/lib/Ecdsa.mjs index f78025bc..f5af36c4 100644 --- a/src/core/lib/Ecdsa.mjs +++ b/src/core/lib/Ecdsa.mjs @@ -13,7 +13,9 @@ import { p256, p384, p521 } from "@noble/curves/nist.js"; import { DER } from "@noble/curves/abstract/weierstrass.js"; import { md5, sha1 } from "@noble/hashes/legacy.js"; import { sha256, sha384, sha512 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { AsnParser, AsnSerializer, OctetString } from "@peculiar/asn1-schema"; +import { derBytesToPem } from "./Asn1.mjs"; import * as ecc from "@peculiar/asn1-ecc"; const { ECPrivateKey, ECParameters } = ecc; const ID_EC_PUBLIC_KEY = ecc.id_ecPublicKey; @@ -303,7 +305,7 @@ export function publicKeyToSpkiPem(pair) { }), subjectPublicKey: pair.publicKey.slice().buffer, }); - return derToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); + return derBytesToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); } /** @@ -328,7 +330,7 @@ export function privateKeyToPkcs8Pem(pair) { }), privateKey: new OctetString(AsnSerializer.serialize(ecKey)), }); - return derToPem(new Uint8Array(AsnSerializer.serialize(pkcs8)), "PRIVATE KEY"); + return derBytesToPem(new Uint8Array(AsnSerializer.serialize(pkcs8)), "PRIVATE KEY"); } @@ -549,47 +551,3 @@ function readLength(bytes, offset) { return { value, next: offset + 1 + n }; } -/** - * Convert a hex string to a Uint8Array. - * - * @param {string} hex - * @returns {Uint8Array} - */ -function hexToBytes(hex) { - if (hex.length % 2 !== 0) throw new OperationError("Hex string has odd length"); - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); - return out; -} - -/** - * Convert a Uint8Array to a lowercase hex string. - * - * @param {Uint8Array} bytes - * @returns {string} - */ -function bytesToHex(bytes) { - let out = ""; - for (const b of bytes) out += b.toString(16).padStart(2, "0"); - return out; -} - -/** - * Wrap raw DER bytes in a PEM envelope with LF line endings. - * - * @param {Uint8Array} bytes - * @param {string} label - * @returns {string} - */ -function derToPem(bytes, label) { - let b64; - if (typeof Buffer !== "undefined") { - b64 = Buffer.from(bytes).toString("base64"); - } else { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - b64 = btoa(bin); - } - const lines = b64.match(/.{1,64}/g) || [""]; - return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`; -} diff --git a/src/core/lib/KeyConvert.mjs b/src/core/lib/KeyConvert.mjs index 0b3e5a83..c903b322 100644 --- a/src/core/lib/KeyConvert.mjs +++ b/src/core/lib/KeyConvert.mjs @@ -15,6 +15,8 @@ import { PrivateKeyInfo } from "@peculiar/asn1-pkcs8"; import { AlgorithmIdentifier, SubjectPublicKeyInfo } from "@peculiar/asn1-x509"; import { Sequence, Integer, fromBER } from "asn1js"; import OperationError from "../errors/OperationError.mjs"; +import { toBase64, fromBase64 } from "./Base64.mjs"; +import { derBytesToPem } from "./Asn1.mjs"; import { getCurveByName, loadEcKey, @@ -30,8 +32,6 @@ const ID_DSA = "1.2.840.10040.4.1"; // identifier in RSA SPKI/PKCS#8 envelopes. const DER_NULL = new Uint8Array([0x05, 0x00]).buffer; -const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - // ----- public API ----------------------------------------------------------- @@ -98,6 +98,14 @@ export function parseCertPublicKey(certPem) { * Convert normalised key info to a JWK object (built in the canonical * field order so `JSON.stringify` emits a deterministic string). * + * The JWK fields are marshalled by hand rather than via a library: the + * conversion spans both RSA (including the CRT private params p/q/dp/dq/qi) + * and EC keys, and no current dependency covers both. `@noble/curves` is + * EC-only, and Web Crypto's JWK export is async and unavailable for the + * MD5/SHA-1 code paths this module supports. Adding a JOSE library purely + * for this base64url field mapping would enlarge the dependency surface for + * no real benefit. The per-field encoding is delegated to {@link b64url}. + * * @param {object} info * @returns {object} */ @@ -377,7 +385,7 @@ function parsePkcs8(bytes) { // EC and everything else delegate to the existing EC loader by // re-wrapping the bytes as a PKCS#8 PEM. loadEcKey will surface its // own "not an EC key" error for unsupported algorithms. - return ecInfoFromLoad(loadEcKey(derToPem(bytes, "PRIVATE KEY"))); + return ecInfoFromLoad(loadEcKey(derBytesToPem(bytes, "PRIVATE KEY"))); } /** @@ -405,7 +413,7 @@ function parseSpki(bytes) { const y = parseIntegerBitString(abufToBytes(spki.subjectPublicKey)); return { kty: "DSA", isPrivate: false, p, q, g, y }; } - return ecInfoFromLoad(loadEcKey(derToPem(bytes, "PUBLIC KEY"))); + return ecInfoFromLoad(loadEcKey(derBytesToPem(bytes, "PUBLIC KEY"))); } /** @@ -504,7 +512,7 @@ function rsaPkcs8Pem(info) { }), privateKey: new OctetString(AsnSerializer.serialize(rsa)), }); - return derToPem(new Uint8Array(AsnSerializer.serialize(pkcs8)), "PRIVATE KEY"); + return derBytesToPem(new Uint8Array(AsnSerializer.serialize(pkcs8)), "PRIVATE KEY"); } /** @@ -525,7 +533,7 @@ function rsaSpkiPem(info) { }), subjectPublicKey: AsnSerializer.serialize(rsa), }); - return derToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); + return derBytesToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); } /** @@ -544,7 +552,7 @@ function dsaSpkiPem(info) { }), subjectPublicKey: innerY, }); - return derToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); + return derBytesToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); } /** @@ -604,25 +612,6 @@ export function pemToDer(pem) { }; } -/** - * Wrap raw DER bytes in a PEM envelope with LF line endings. - * - * @param {Uint8Array} bytes - * @param {string} label - * @returns {string} - */ -export function derToPem(bytes, label) { - let b64; - if (typeof Buffer !== "undefined") { - b64 = Buffer.from(bytes).toString("base64"); - } else { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - b64 = btoa(bin); - } - const lines = b64.match(/.{1,64}/g) || [""]; - return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`; -} /** * Encode a byte array as base64url (no padding). @@ -632,40 +621,18 @@ export function derToPem(bytes, label) { */ export function b64url(bytes) { if (!(bytes instanceof Uint8Array)) bytes = new Uint8Array(bytes); - let out = ""; - let i = 0; - while (i < bytes.length) { - const b1 = bytes[i++]; - const b2 = i < bytes.length ? bytes[i++] : -1; - const b3 = i < bytes.length ? bytes[i++] : -1; - out += BASE64URL_ALPHABET[b1 >> 2]; - out += BASE64URL_ALPHABET[((b1 & 0x03) << 4) | (b2 < 0 ? 0 : (b2 >> 4))]; - if (b2 < 0) break; - out += BASE64URL_ALPHABET[((b2 & 0x0f) << 2) | (b3 < 0 ? 0 : (b3 >> 6))]; - if (b3 < 0) break; - out += BASE64URL_ALPHABET[b3 & 0x3f]; - } - return out; + return toBase64(bytes, "A-Za-z0-9-_"); } /** - * Decode a base64url-encoded string to bytes. Strips standard base64 + * Decode a base64url-encoded string to bytes. Tolerates standard base64 * padding if present. * * @param {string} str * @returns {Uint8Array} */ export function b64urlToBytes(str) { - const cleaned = str.replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, ""); - const pad = cleaned.length % 4 === 0 ? "" : "=".repeat(4 - (cleaned.length % 4)); - const padded = cleaned + pad; - if (typeof Buffer !== "undefined") { - return new Uint8Array(Buffer.from(padded, "base64")); - } - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; + return new Uint8Array(fromBase64(str, "A-Za-z0-9-_", "byteArray")); } /** diff --git a/src/core/lib/X509.mjs b/src/core/lib/X509.mjs index e5f2e970..b2528c38 100644 --- a/src/core/lib/X509.mjs +++ b/src/core/lib/X509.mjs @@ -13,6 +13,7 @@ import * as asn1X509 from "@peculiar/asn1-x509"; import * as ecc from "@peculiar/asn1-ecc"; import * as rsaSchemas from "@peculiar/asn1-rsa"; import { fromBER, Utf8String, BmpString, PrintableString, IA5String } from "asn1js"; +import { bytesToHex } from "@noble/hashes/utils.js"; import OperationError from "../errors/OperationError.mjs"; import { fromBase64 } from "./Base64.mjs"; import { fromHex } from "./Hex.mjs"; @@ -425,17 +426,9 @@ export function asnNameToJson(asnName) { // ----- byte helpers --------------------------------------------------------- -/** - * Convert a Uint8Array to a lowercase hex string. - * - * @param {Uint8Array} bytes - * @returns {string} - */ -export function bytesToHex(bytes) { - let out = ""; - for (const b of bytes) out += b.toString(16).padStart(2, "0"); - return out; -} +// bytesToHex is re-exported from @noble/hashes/utils (imported above) so +// operations can continue importing it from this module. +export { bytesToHex }; /** * Strip a single leading 0x00 byte from a buffer when it's only present to diff --git a/src/core/operations/GenerateECDSAKeyPair.mjs b/src/core/operations/GenerateECDSAKeyPair.mjs index acd98f1c..c5a9c8cc 100644 --- a/src/core/operations/GenerateECDSAKeyPair.mjs +++ b/src/core/operations/GenerateECDSAKeyPair.mjs @@ -5,6 +5,7 @@ */ import Operation from "../Operation.mjs"; +import { bytesToHex } from "@noble/hashes/utils.js"; import { cryptNotice } from "../lib/Crypt.mjs"; import { toBase64 } from "../lib/Base64.mjs"; import { @@ -102,16 +103,4 @@ function b64url(bytes) { return toBase64(bytes, "A-Za-z0-9-_"); } -/** - * Convert a byte array to a lowercase hex string. - * - * @param {Uint8Array} bytes - * @returns {string} - */ -function bytesToHex(bytes) { - let out = ""; - for (const b of bytes) out += b.toString(16).padStart(2, "0"); - return out; -} - export default GenerateECDSAKeyPair; diff --git a/tests/operations/tests/ECDSA.mjs b/tests/operations/tests/ECDSA.mjs index ffffbb10..4b1c888c 100644 --- a/tests/operations/tests/ECDSA.mjs +++ b/tests/operations/tests/ECDSA.mjs @@ -286,6 +286,22 @@ TestRegister.addTests([ } ] }, + { + // Published known-answer test pinning correctness against the spec + // (not against the previous jsrsasign output). RFC 6979 Appendix + // A.2.5: curve P-256 (prime256v1), message "sample", SHA-256. The + // public key below is the SPKI form of the (Ux, Uy) point from the + // RFC; the r||s signature is the deterministic signature it lists. + name: "ECDSA Verify: RFC 6979 A.2.5 P-256/SHA-256 known-answer vector", + input: "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8", + expectedOutput: "Verified OK", + recipeConfig: [ + { + "op": "ECDSA Verify", + "args": ["Auto", "SHA-256", "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYP7UuiVanTHJYet0xjVtaMBJuJI7\nYfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ==\n-----END PUBLIC KEY-----", "sample", "Raw"] + } + ] + }, { name: "ECDSA Verify: JSON signature missing r", input: JSON.stringify({s: JSON.parse(P256.signature.sha256.json).s}),