Refactor ECDSA and ASN.1 helpers to use derBytesToPem; remove redundant functions

This commit is contained in:
Leon Zandman 2026-06-09 23:31:05 +02:00
parent 4bb2ba5c36
commit 203101c715
6 changed files with 57 additions and 120 deletions

View File

@ -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");

View File

@ -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`;
}

View File

@ -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"));
}
/**

View File

@ -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

View File

@ -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;

View File

@ -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}),