From 65181be8257df191f3578b42a5b5a99f1711a1ae Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Sun, 17 May 2026 18:33:00 +0200 Subject: [PATCH] PEM/JWK conversion + key extraction (PublicKey bundle). --- plan-jsrsasign.md | 13 +- src/core/lib/KeyConvert.mjs | 735 +++++++++++++++++++ src/core/operations/JWKToPem.mjs | 33 +- src/core/operations/PEMToJWK.mjs | 31 +- src/core/operations/PubKeyFromPrivKey.mjs | 32 +- tests/operations/tests/JWK.mjs | 12 +- tests/operations/tests/PubKeyFromPrivKey.mjs | 16 +- 7 files changed, 794 insertions(+), 78 deletions(-) create mode 100644 src/core/lib/KeyConvert.mjs diff --git a/plan-jsrsasign.md b/plan-jsrsasign.md index 687aa2cc..0e138bb3 100644 --- a/plan-jsrsasign.md +++ b/plan-jsrsasign.md @@ -7,13 +7,14 @@ - [x] PR 1 — Setup + ASN.1 utilities - [x] PR 2 — SM2 rewrite - [x] PR 3 — ECDSA primitives -- [ ] PR 4 — PEM/JWK conversion + key extraction +- [x] PR 4 — PEM/JWK conversion + key extraction - [ ] PR 5 — X.509 / CSR / CRL parsing - [ ] PR 6 — Removal _Notes for next session:_ - **PR 2 resolved:** SM2 is now built on `weierstrass(...)` + `ecdh(...)` from `@noble/curves/abstract/weierstrass.js` (curve params per GM/T 0003-2012). No `/sm2` subpath needed. - **PR 3 resolved:** ECDSA primitives migrated; new [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) is the shared helper module. Existing ECDSA fixture set passes unchanged (sign↔verify round-trips and the canned P-256 signature fixtures both verify against `lowS: false`). +- **PR 4 resolved:** Key-conversion ops migrated; new [src/core/lib/KeyConvert.mjs](src/core/lib/KeyConvert.mjs) wraps RSA/EC/DSA PEM⇄JWK⇄SPKI/PKCS#8. DSA private-key parsing/building goes through `asn1js` directly (no peculiar `asn1-dsa` package and `node-forge` doesn't expose DSA), reusing the existing EC helpers from `Ecdsa.mjs`. - **PR 5 blocker:** `@peculiar/x509` v2 needs a `reflect-metadata` polyfill at every entry point — PR 1 pinned to `^1.14.3` to avoid that. Stay on v1 unless the polyfill cost gets resolved. ## Context @@ -239,6 +240,16 @@ After **PR 6:** Record deviations from the original plan here, newest at the top. One bullet per change: what changed, why, and which PR. +### PR 4 — 2026-05-17 +- New [src/core/lib/KeyConvert.mjs](src/core/lib/KeyConvert.mjs) holds the shared RSA/EC/DSA conversion helpers (`parseKeyPem`, `parseCertPublicKey`, `keyToJwk`, `keyFromJwk`, `keyInfoToPem`, `derivePublicKeyInfo`). Plan called for inlining helpers per-op; factoring them out avoided duplicating the RSA SPKI/PKCS#8 plumbing between [PEMToJWK.mjs](src/core/operations/PEMToJWK.mjs), [JWKToPem.mjs](src/core/operations/JWKToPem.mjs) and [PubKeyFromPrivKey.mjs](src/core/operations/PubKeyFromPrivKey.mjs). +- **node-forge dropped from the migration.** The plan reserved node-forge for the DSA path in `PubKeyFromPrivKey`, but `node-forge` only ships RSA support in its `pki` module — no DSA parser/serialiser. DSA traditional `-----BEGIN DSA PRIVATE KEY-----` is parsed via `asn1js.fromBER` directly, and the SPKI is built via `new asn1js.Sequence({value: [new Integer(...), ...]}).toBER(false)`. No new dependency. +- **RSA PEMs go through `@peculiar/asn1-rsa` schemas** (`RSAPrivateKey`, `RSAPublicKey`) plus the existing `PrivateKeyInfo` / `SubjectPublicKeyInfo` / `AlgorithmIdentifier` schemas. `id_rsaEncryption` is namespace-imported (`import * as rsaSchemas from "@peculiar/asn1-rsa"`) and aliased to `ID_RSA_ENCRYPTION` to dodge the ESLint `camelcase` rule, matching the convention PR 3 established for the EC OID constants. +- **DER `00`/INTEGER plumbing.** Parsed INTEGER fields come out of asn1js with the DER 2's-complement leading `00` still present; `stripLeadingZero` removes it for JWK output. On the way back the helper `prefixForDerInt` re-adds the `00` byte when the magnitude's MSB is set so the serialised INTEGER stays positive. JWK base64url is hand-rolled (no padding) — `toBase64(..., "A-Za-z0-9-_")` in `Base64.mjs` requires going through `Utils.strToArrayBuffer` for string inputs, which would double-encode the bytes for non-ASCII content. +- **PEM line endings switched from `\r\n` to `\n`** in the test fixtures for [tests/operations/tests/JWK.mjs](tests/operations/tests/JWK.mjs) and [tests/operations/tests/PubKeyFromPrivKey.mjs](tests/operations/tests/PubKeyFromPrivKey.mjs), per the cross-PR convention in [AGENTS.md](AGENTS.md). The fixtures previously did `.replace(/\n/g, "\r\n")` around every expected PEM; those are gone now. +- **Ed25519/Ed448 error-message drift.** PubKeyFromPrivKey's "Unsupported key type" wrapper used to surface jsrsasign's `Error: malformed PKCS8 private key(code:004)`; with the EC parser now coming from `@peculiar/asn1-ecc` + `loadEcKey`, the inner error is the `OperationError("Provided key is not an EC key.")` raised inside [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs). Updated the two `Ed25519` / `Ed448` test fixtures to expect the new message (`Unsupported key type: Error: Provided key is not an EC key.` — `OperationError`'s name field is `Error`, hence the `Error:` prefix). +- **`Unsupported JWK key type` error now references `jwk.kty`** rather than the top-level `inputJson.kty`. The legacy op compared against `jwk.kty` but reported `inputJson.kty`, which was undefined for JWK Set / array inputs. Existing test passes either way because it uses a single-key OKP input. +- No new dependencies; relies on the deps PR 1 already pinned (`@peculiar/asn1-rsa` etc. ship with `@peculiar/x509`). + ### PR 3 — 2026-05-17 - New [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) replaces the jsrsasign calls in [ECDSASign.mjs](src/core/operations/ECDSASign.mjs), [ECDSAVerify.mjs](src/core/operations/ECDSAVerify.mjs), [ECDSASignatureConversion.mjs](src/core/operations/ECDSASignatureConversion.mjs) and [GenerateECDSAKeyPair.mjs](src/core/operations/GenerateECDSAKeyPair.mjs). `loadEcKey` handles SEC1, PKCS#8 and SPKI PEMs by parsing them with `@peculiar/asn1-ecc` / `@peculiar/asn1-pkcs8` / `@peculiar/asn1-x509`. ECDSA itself is `@noble/curves`'s `p256` / `p384` / `p521` with explicit `{ prehash: false, lowS: false, format: "der" }` — see notes below. - **`lowS: false` on both sign and verify.** Noble defaults to `lowS: true` (BTC/ETH-style malleability rejection), which (a) would normalise produced signatures and could diverge from jsrsasign byte-for-byte, and (b) would reject existing jsrsasign-produced signatures whose `s` happened to be in the upper half. We disable lowS to preserve interoperability with the existing fixtures. diff --git a/src/core/lib/KeyConvert.mjs b/src/core/lib/KeyConvert.mjs new file mode 100644 index 00000000..47e9f1ab --- /dev/null +++ b/src/core/lib/KeyConvert.mjs @@ -0,0 +1,735 @@ +/** + * Asymmetric key conversion helpers built on @peculiar/asn1-* and asn1js. + * + * Shared by the PEM to JWK / JWK to PEM / Public Key from Private Key + * operations. Replaces the jsrsasign key-format-conversion plumbing. + * + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import { AsnParser, AsnSerializer, OctetString } from "@peculiar/asn1-schema"; +import * as rsaSchemas from "@peculiar/asn1-rsa"; +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 { + getCurveByName, + loadEcKey, + publicKeyToSpkiPem, + privateKeyToPkcs8Pem, +} from "./Ecdsa.mjs"; + +const { RSAPrivateKey, RSAPublicKey } = rsaSchemas; +const ID_RSA_ENCRYPTION = rsaSchemas.id_rsaEncryption; +const ID_DSA = "1.2.840.10040.4.1"; + +// DER NULL — used as the `parameters` field of the rsaEncryption algorithm +// identifier in RSA SPKI/PKCS#8 envelopes. +const DER_NULL = new Uint8Array([0x05, 0x00]).buffer; + +const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + +// ----- public API ----------------------------------------------------------- + +/** + * Parse a PEM-encoded asymmetric key blob. Recognised labels are + * `RSA PRIVATE KEY`, `RSA PUBLIC KEY`, `DSA PRIVATE KEY`, `EC PRIVATE KEY`, + * `PRIVATE KEY` (PKCS#8) and `PUBLIC KEY` (SPKI). + * + * @param {string} pem + * @returns {object} + */ +export function parseKeyPem(pem) { + const { label, bytes } = pemToDer(pem); + switch (label) { + case "RSA PRIVATE KEY": + return rsaPrivateFromBytes(parseRsaPrivateKey(bytes)); + case "RSA PUBLIC KEY": + return rsaPublicFromBytes(parseRsaPublicKey(bytes)); + case "EC PRIVATE KEY": + return ecInfoFromLoad(loadEcKey(pem)); + case "DSA PRIVATE KEY": + return parseDsaTraditional(bytes); + case "PRIVATE KEY": + return parsePkcs8(bytes); + case "PUBLIC KEY": + return parseSpki(bytes); + default: + throw new OperationError(`Unsupported PEM label: '${label}'`); + } +} + +/** + * Parse a single X.509 certificate PEM and return the normalised public key + * info of its subject. + * + * @param {string} certPem + * @returns {object} + */ +export function parseCertPublicKey(certPem) { + const { bytes } = pemToDer(certPem); + // Certificate ::= SEQUENCE { tbsCertificate ::= SEQUENCE { version?, serial, + // sigAlg, issuer, validity, subject, spki, ... }, ... } + const parsed = fromBER(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + if (parsed.offset === -1) throw new OperationError("Invalid certificate DER"); + const cert = parsed.result; + const tbs = cert.valueBlock.value[0]; + if (!tbs) throw new OperationError("Certificate is missing TBSCertificate"); + + let idx = 0; + const first = tbs.valueBlock.value[0]; + // Skip explicit [0] version tag if present + if (first && first.idBlock && first.idBlock.tagClass === 3 && first.idBlock.tagNumber === 0) { + idx = 1; + } + // tbs layout: [version?] serial sigAlg issuer validity subject spki ... + const spkiNode = tbs.valueBlock.value[idx + 5]; + if (!spkiNode) throw new OperationError("Could not locate SubjectPublicKeyInfo in certificate"); + + const spkiBytes = derOf(spkiNode); + return parseSpki(spkiBytes); +} + +/** + * Convert normalised key info to a JWK object (built in the canonical + * field order so `JSON.stringify` emits a deterministic string). + * + * @param {object} info + * @returns {object} + */ +export function keyToJwk(info) { + switch (info.kty) { + case "RSA": { + const jwk = { kty: "RSA", n: b64url(info.n), e: b64url(info.e) }; + if (info.isPrivate) { + jwk.d = b64url(info.d); + jwk.p = b64url(info.p); + jwk.q = b64url(info.q); + jwk.dp = b64url(info.dp); + jwk.dq = b64url(info.dq); + jwk.qi = b64url(info.qi); + } + return jwk; + } + case "EC": { + const jwk = { + kty: "EC", + crv: info.curveName, + x: b64url(info.x), + y: b64url(info.y), + }; + if (info.isPrivate) jwk.d = b64url(info.d); + return jwk; + } + default: + throw new OperationError(`Cannot build JWK for key type '${info.kty}'`); + } +} + +/** + * Build a normalised key info object from a JWK. + * + * @param {object} jwk + * @returns {object} + */ +export function keyFromJwk(jwk) { + if (!jwk || typeof jwk !== "object" || typeof jwk.kty !== "string") { + throw new OperationError("Invalid JWK format"); + } + + if (jwk.kty === "RSA") { + if (typeof jwk.n !== "string" || typeof jwk.e !== "string") { + throw new OperationError("RSA JWK is missing required fields"); + } + const info = { + kty: "RSA", + isPrivate: typeof jwk.d === "string", + n: b64urlToBytes(jwk.n), + e: b64urlToBytes(jwk.e), + }; + if (info.isPrivate) { + if ( + typeof jwk.d !== "string" || typeof jwk.p !== "string" || + typeof jwk.q !== "string" || typeof jwk.dp !== "string" || + typeof jwk.dq !== "string" || typeof jwk.qi !== "string" + ) { + throw new OperationError("RSA private JWK is missing CRT components"); + } + info.d = b64urlToBytes(jwk.d); + info.p = b64urlToBytes(jwk.p); + info.q = b64urlToBytes(jwk.q); + info.dp = b64urlToBytes(jwk.dp); + info.dq = b64urlToBytes(jwk.dq); + info.qi = b64urlToBytes(jwk.qi); + } + return info; + } + + if (jwk.kty === "EC") { + if (typeof jwk.crv !== "string" || typeof jwk.x !== "string" || typeof jwk.y !== "string") { + throw new OperationError("EC JWK is missing required fields"); + } + const curve = getCurveByName(jwk.crv); + const x = b64urlToBytes(jwk.x); + const y = b64urlToBytes(jwk.y); + if (x.length !== curve.byteLen || y.length !== curve.byteLen) { + throw new OperationError(`EC JWK coords have wrong length for curve ${jwk.crv}`); + } + const publicKey = new Uint8Array(1 + 2 * curve.byteLen); + publicKey[0] = 0x04; + publicKey.set(x, 1); + publicKey.set(y, 1 + curve.byteLen); + const info = { + kty: "EC", + curveName: jwk.crv, + byteLen: curve.byteLen, + isPrivate: typeof jwk.d === "string", + isPublic: typeof jwk.d !== "string", + publicKey, + x, + y, + }; + if (info.isPrivate) { + const d = b64urlToBytes(jwk.d); + if (d.length !== curve.byteLen) { + throw new OperationError(`EC JWK 'd' has wrong length for curve ${jwk.crv}`); + } + info.d = d; + } else { + info.d = null; + } + return info; + } + + throw new OperationError(`Unsupported JWK key type '${jwk.kty}'`); +} + +/** + * Encode normalised key info as a PEM blob. Private keys come out as + * PKCS#8; public keys come out as SPKI. Both use LF line endings. + * + * @param {object} info + * @returns {string} + */ +export function keyInfoToPem(info) { + if (info.kty === "RSA") { + return info.isPrivate ? rsaPkcs8Pem(info) : rsaSpkiPem(info); + } + if (info.kty === "EC") { + return info.isPrivate ? privateKeyToPkcs8Pem(info) : publicKeyToSpkiPem(info); + } + if (info.kty === "DSA") { + if (!info.isPrivate) return dsaSpkiPem(info); + throw new OperationError("Building DSA private-key PEMs is not supported"); + } + throw new OperationError(`Cannot serialise key of type '${info.kty}'`); +} + +/** + * Derive the public-key info from a private-key info. RSA keeps `n`/`e`, + * EC re-derives `(x,y)` via the curve, DSA keeps `(p,q,g,y)`. + * + * @param {object} info + * @returns {object} + */ +export function derivePublicKeyInfo(info) { + if (!info.isPrivate) return info; + if (info.kty === "RSA") { + return { kty: "RSA", isPrivate: false, n: info.n, e: info.e }; + } + if (info.kty === "EC") { + const curve = getCurveByName(info.curveName); + return { + kty: "EC", + curveName: info.curveName, + byteLen: curve.byteLen, + isPrivate: false, + isPublic: true, + d: null, + publicKey: info.publicKey, + x: info.x, + y: info.y, + }; + } + if (info.kty === "DSA") { + if (!info.y) { + throw new OperationError(`DSA Private Key in PKCS#8 is not supported`); + } + return { + kty: "DSA", + isPrivate: false, + p: info.p, + q: info.q, + g: info.g, + y: info.y, + }; + } + throw new OperationError(`Unsupported key type: ${info.kty}`); +} + + +// ----- format-specific decoding --------------------------------------------- + +/** + * Decode a PKCS#1 RSAPrivateKey blob. + * + * @param {Uint8Array} bytes + * @returns {RSAPrivateKey} + */ +function parseRsaPrivateKey(bytes) { + try { + return AsnParser.parse(bytes, RSAPrivateKey); + } catch (e) { + throw new OperationError(`Could not parse RSA private key: ${e.message}`); + } +} + +/** + * Decode a PKCS#1 RSAPublicKey blob. + * + * @param {Uint8Array} bytes + * @returns {RSAPublicKey} + */ +function parseRsaPublicKey(bytes) { + try { + return AsnParser.parse(bytes, RSAPublicKey); + } catch (e) { + throw new OperationError(`Could not parse RSA public key: ${e.message}`); + } +} + +/** + * Build the normalised info object for an RSA private key. + * + * @param {RSAPrivateKey} rsa + * @returns {object} + */ +function rsaPrivateFromBytes(rsa) { + return { + kty: "RSA", + isPrivate: true, + n: stripLeadingZero(abufToBytes(rsa.modulus)), + e: stripLeadingZero(abufToBytes(rsa.publicExponent)), + d: stripLeadingZero(abufToBytes(rsa.privateExponent)), + p: stripLeadingZero(abufToBytes(rsa.prime1)), + q: stripLeadingZero(abufToBytes(rsa.prime2)), + dp: stripLeadingZero(abufToBytes(rsa.exponent1)), + dq: stripLeadingZero(abufToBytes(rsa.exponent2)), + qi: stripLeadingZero(abufToBytes(rsa.coefficient)), + }; +} + +/** + * Build the normalised info object for an RSA public key. + * + * @param {RSAPublicKey} rsa + * @returns {object} + */ +function rsaPublicFromBytes(rsa) { + return { + kty: "RSA", + isPrivate: false, + n: stripLeadingZero(abufToBytes(rsa.modulus)), + e: stripLeadingZero(abufToBytes(rsa.publicExponent)), + }; +} + +/** + * Reshape an `loadEcKey` return value to the normalised format used here + * (adds the `kty` field). + * + * @param {object} ecInfo + * @returns {object} + */ +function ecInfoFromLoad(ecInfo) { + return { kty: "EC", ...ecInfo }; +} + +/** + * Decode a PKCS#8 blob. Dispatches on the algorithm OID to the + * RSA/EC parsers. + * + * @param {Uint8Array} bytes + * @returns {object} + */ +function parsePkcs8(bytes) { + let info; + try { + info = AsnParser.parse(bytes, PrivateKeyInfo); + } catch (e) { + throw new OperationError(`Could not parse PKCS#8 key: ${e.message}`); + } + const alg = info.privateKeyAlgorithm.algorithm; + if (alg === ID_RSA_ENCRYPTION) { + const inner = abufToBytes(info.privateKey.buffer); + return rsaPrivateFromBytes(parseRsaPrivateKey(inner)); + } + if (alg === ID_DSA) { + // DSA PKCS#8 carries only x (with p/q/g in the algorithm + // parameters). jsrsasign rejected this layout because no y was + // present and the existing tests assert that we do the same — we + // mark the info accordingly and let derivePublicKeyInfo throw. + return { kty: "DSA", isPrivate: true, y: null }; + } + // 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"))); +} + +/** + * Decode a SubjectPublicKeyInfo blob. Dispatches on the algorithm OID. + * + * @param {Uint8Array} bytes + * @returns {object} + */ +function parseSpki(bytes) { + let spki; + try { + spki = AsnParser.parse(bytes, SubjectPublicKeyInfo); + } catch (e) { + throw new OperationError(`Could not parse SubjectPublicKeyInfo: ${e.message}`); + } + const alg = spki.algorithm.algorithm; + if (alg === ID_RSA_ENCRYPTION) { + return rsaPublicFromBytes(parseRsaPublicKey(abufToBytes(spki.subjectPublicKey))); + } + if (alg === ID_DSA) { + if (!spki.algorithm.parameters) { + throw new OperationError("DSA SubjectPublicKeyInfo is missing DSS-Parms"); + } + const { p, q, g } = parseDssParms(abufToBytes(spki.algorithm.parameters)); + const y = parseIntegerBitString(abufToBytes(spki.subjectPublicKey)); + return { kty: "DSA", isPrivate: false, p, q, g, y }; + } + return ecInfoFromLoad(loadEcKey(derToPem(bytes, "PUBLIC KEY"))); +} + +/** + * Decode a traditional OpenSSL DSAPrivateKey blob. + * + * @param {Uint8Array} bytes + * @returns {object} + */ +function parseDsaTraditional(bytes) { + const parsed = fromBER(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + if (parsed.offset === -1) throw new OperationError("Invalid DSA private key DER"); + const seq = parsed.result; + const items = seq.valueBlock && seq.valueBlock.value; + if (!items || items.length < 6) throw new OperationError("Malformed DSA private key"); + return { + kty: "DSA", + isPrivate: true, + p: extractIntegerBytes(items[1]), + q: extractIntegerBytes(items[2]), + g: extractIntegerBytes(items[3]), + y: extractIntegerBytes(items[4]), + x: extractIntegerBytes(items[5]), + }; +} + +/** + * Decode the DSS-Parms (p, q, g) SEQUENCE from a DSA algorithm parameters + * blob. + * + * @param {Uint8Array} bytes + * @returns {{p: Uint8Array, q: Uint8Array, g: Uint8Array}} + */ +function parseDssParms(bytes) { + const parsed = fromBER(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + if (parsed.offset === -1) throw new OperationError("Invalid DSS-Parms"); + const items = parsed.result.valueBlock && parsed.result.valueBlock.value; + if (!items || items.length < 3) throw new OperationError("Malformed DSS-Parms"); + return { + p: extractIntegerBytes(items[0]), + q: extractIntegerBytes(items[1]), + g: extractIntegerBytes(items[2]), + }; +} + +/** + * Decode an INTEGER wrapped in a BIT STRING (used for DSA subjectPublicKey). + * + * @param {Uint8Array} bytes + * @returns {Uint8Array} + */ +function parseIntegerBitString(bytes) { + const parsed = fromBER(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + if (parsed.offset === -1) throw new OperationError("Invalid INTEGER in SPKI"); + return extractIntegerBytes(parsed.result); +} + +/** + * Pull the raw INTEGER bytes out of an asn1js Integer node (without the + * DER 2's-complement leading 00 when present). + * + * @param {Object} node + * @returns {Uint8Array} + */ +function extractIntegerBytes(node) { + const view = node.valueBlock && node.valueBlock.valueHexView; + if (!view) throw new OperationError("Missing INTEGER value"); + return stripLeadingZero(new Uint8Array(view)); +} + + +// ----- format-specific encoding --------------------------------------------- + +/** + * Encode an RSA private-key info object as a PKCS#8 PEM. + * + * @param {object} info + * @returns {string} + */ +function rsaPkcs8Pem(info) { + const rsa = new RSAPrivateKey({ + version: 0, + modulus: bytesToAbuf(prefixForDerInt(info.n)), + publicExponent: bytesToAbuf(prefixForDerInt(info.e)), + privateExponent: bytesToAbuf(prefixForDerInt(info.d)), + prime1: bytesToAbuf(prefixForDerInt(info.p)), + prime2: bytesToAbuf(prefixForDerInt(info.q)), + exponent1: bytesToAbuf(prefixForDerInt(info.dp)), + exponent2: bytesToAbuf(prefixForDerInt(info.dq)), + coefficient: bytesToAbuf(prefixForDerInt(info.qi)), + }); + const pkcs8 = new PrivateKeyInfo({ + version: 0, + privateKeyAlgorithm: new AlgorithmIdentifier({ + algorithm: ID_RSA_ENCRYPTION, + parameters: DER_NULL, + }), + privateKey: new OctetString(AsnSerializer.serialize(rsa)), + }); + return derToPem(new Uint8Array(AsnSerializer.serialize(pkcs8)), "PRIVATE KEY"); +} + +/** + * Encode an RSA public-key info object as an SPKI PEM. + * + * @param {object} info + * @returns {string} + */ +function rsaSpkiPem(info) { + const rsa = new RSAPublicKey({ + modulus: bytesToAbuf(prefixForDerInt(info.n)), + publicExponent: bytesToAbuf(prefixForDerInt(info.e)), + }); + const spki = new SubjectPublicKeyInfo({ + algorithm: new AlgorithmIdentifier({ + algorithm: ID_RSA_ENCRYPTION, + parameters: DER_NULL, + }), + subjectPublicKey: AsnSerializer.serialize(rsa), + }); + return derToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); +} + +/** + * Encode a DSA public-key info object as an SPKI PEM. + * + * @param {object} info + * @returns {string} + */ +function dsaSpkiPem(info) { + const params = buildDssParms(info.p, info.q, info.g); + const innerY = buildDerInteger(info.y); + const spki = new SubjectPublicKeyInfo({ + algorithm: new AlgorithmIdentifier({ + algorithm: ID_DSA, + parameters: params, + }), + subjectPublicKey: innerY, + }); + return derToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); +} + +/** + * Build the DER encoding of an INTEGER from its raw magnitude bytes. + * + * @param {Uint8Array} bytes + * @returns {ArrayBuffer} + */ +function buildDerInteger(bytes) { + const node = new Integer({ valueHex: prefixForDerInt(bytes) }); + return node.toBER(false); +} + +/** + * Build the DER encoding of DSS-Parms (SEQUENCE { p, q, g }). + * + * @param {Uint8Array} p + * @param {Uint8Array} q + * @param {Uint8Array} g + * @returns {ArrayBuffer} + */ +function buildDssParms(p, q, g) { + const seq = new Sequence({ + value: [ + new Integer({ valueHex: prefixForDerInt(p) }), + new Integer({ valueHex: prefixForDerInt(q) }), + new Integer({ valueHex: prefixForDerInt(g) }), + ], + }); + return seq.toBER(false); +} + + +// ----- byte/PEM utilities --------------------------------------------------- + +/** + * Decode a PEM blob to its raw DER bytes and label. + * + * @param {string} pem + * @returns {{label: string, bytes: Uint8Array}} + */ +export function pemToDer(pem) { + const match = pem.match(/-----BEGIN ([A-Z0-9 ]+)-----([\s\S]+?)-----END \1-----/); + if (!match) throw new OperationError("Not a valid PEM blob"); + const body = match[2].replace(/\s+/g, ""); + let bin; + if (typeof Buffer !== "undefined") { + bin = Buffer.from(body, "base64"); + } else { + const decoded = atob(body); + bin = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) bin[i] = decoded.charCodeAt(i); + } + return { + label: match[1], + bytes: new Uint8Array(bin.buffer || bin, bin.byteOffset || 0, bin.byteLength || bin.length), + }; +} + +/** + * 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). + * + * @param {Uint8Array} bytes + * @returns {string} + */ +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; +} + +/** + * Decode a base64url-encoded string to bytes. Strips 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; +} + +/** + * Strip a single leading 0x00 byte from a buffer when it's only present + * to keep a DER INTEGER positive (i.e. the next byte's MSB is set). + * + * @param {Uint8Array} bytes + * @returns {Uint8Array} + */ +function stripLeadingZero(bytes) { + if (bytes.length > 1 && bytes[0] === 0 && (bytes[1] & 0x80)) { + return bytes.slice(1); + } + return bytes; +} + +/** + * Add a leading 0x00 byte to a buffer when its MSB is set, so it survives + * round-tripping through a DER INTEGER without being interpreted as a + * negative value. + * + * @param {Uint8Array} bytes + * @returns {Uint8Array} + */ +function prefixForDerInt(bytes) { + if (bytes.length === 0) return new Uint8Array([0]); + if (bytes[0] & 0x80) { + const out = new Uint8Array(bytes.length + 1); + out.set(bytes, 1); + return out; + } + return bytes; +} + +/** + * Convert an ArrayBuffer / Uint8Array / typed view to a Uint8Array. + * + * @param {ArrayBuffer|ArrayBufferView} abuf + * @returns {Uint8Array} + */ +function abufToBytes(abuf) { + if (abuf instanceof Uint8Array) return abuf; + if (ArrayBuffer.isView(abuf)) return new Uint8Array(abuf.buffer, abuf.byteOffset, abuf.byteLength); + return new Uint8Array(abuf); +} + +/** + * Wrap a Uint8Array in a freshly allocated ArrayBuffer. + * + * @param {Uint8Array} bytes + * @returns {ArrayBuffer} + */ +function bytesToAbuf(bytes) { + const copy = new Uint8Array(bytes); + return copy.buffer; +} + +/** + * Re-serialise a parsed asn1js node to its DER bytes. + * + * @param {Object} node + * @returns {Uint8Array} + */ +function derOf(node) { + return new Uint8Array(node.toBER(false)); +} diff --git a/src/core/operations/JWKToPem.mjs b/src/core/operations/JWKToPem.mjs index c8c00270..4edb931a 100644 --- a/src/core/operations/JWKToPem.mjs +++ b/src/core/operations/JWKToPem.mjs @@ -4,17 +4,17 @@ * @license Apache-2.0 */ -import r from "jsrsasign"; import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; +import { keyFromJwk, keyInfoToPem } from "../lib/KeyConvert.mjs"; /** - * PEM to JWK operation + * JWK to PEM operation */ -class PEMToJWK extends Operation { +class JWKToPem extends Operation { /** - * PEMToJWK constructor + * JWKToPem constructor */ constructor() { super(); @@ -45,36 +45,27 @@ class PEMToJWK extends Operation { let keys = []; if (Array.isArray(inputJson)) { - // list of keys => transform all keys keys = inputJson; } else if (Array.isArray(inputJson.keys)) { - // JSON Web Key Set => transform all keys keys = inputJson.keys; - } else if (typeof inputJson === "object") { - // single key + } else if (typeof inputJson === "object" && inputJson !== null) { keys.push(inputJson); } else { throw new OperationError("Input is not a JSON Web Key"); } let output = ""; - for (let i=0; i 0) { - output += "\n"; - } - output += JSON.stringify(jwk); + info = parseKeyPem(pem); } else if (match[1] === "CERTIFICATE") { - const cert = new r.X509(); - cert.readCertPEM(pem); - const key = cert.getPublicKey(); - const jwk = r.KEYUTIL.getJWKFromKey(key); - if (output.length > 0) { - output += "\n"; - } - output += JSON.stringify(jwk); + info = parseCertPublicKey(pem); } else { throw new OperationError(`Unsupported PEM type '${match[1]}'`); } + + if (info.kty === "DSA") { + throw new OperationError("DSA keys are not supported for JWK"); + } + + if (output.length > 0) output += "\n"; + output += JSON.stringify(keyToJwk(info)); } return output; } diff --git a/src/core/operations/PubKeyFromPrivKey.mjs b/src/core/operations/PubKeyFromPrivKey.mjs index 5a08882b..ba6ee654 100644 --- a/src/core/operations/PubKeyFromPrivKey.mjs +++ b/src/core/operations/PubKeyFromPrivKey.mjs @@ -4,9 +4,13 @@ * @license Apache-2.0 */ -import r from "jsrsasign"; import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; +import { + parseKeyPem, + derivePublicKeyInfo, + keyInfoToPem, +} from "../lib/KeyConvert.mjs"; /** * Public Key from Private Key operation @@ -39,7 +43,6 @@ class PubKeyFromPrivKey extends Operation { let match; const regex = /-----BEGIN ((RSA |EC |DSA )?PRIVATE KEY)-----/g; while ((match = regex.exec(input)) !== null) { - // find corresponding end tag const indexBase64 = match.index + match[0].length; const footer = `-----END ${match[1]}-----`; const indexFooter = input.indexOf(footer, indexBase64); @@ -48,32 +51,15 @@ class PubKeyFromPrivKey extends Operation { } const privKeyPem = input.substring(match.index, indexFooter + footer.length); - let privKey; + let privInfo; try { - privKey = r.KEYUTIL.getKey(privKeyPem); + privInfo = parseKeyPem(privKeyPem); } catch (err) { throw new OperationError(`Unsupported key type: ${err}`); } - let pubKey; - if (privKey.type && privKey.type === "EC") { - pubKey = new r.KJUR.crypto.ECDSA({ curve: privKey.curve }); - pubKey.setPublicKeyHex(privKey.generatePublicKeyHex()); - } else if (privKey.type && privKey.type === "DSA") { - if (!privKey.y) { - throw new OperationError(`DSA Private Key in PKCS#8 is not supported`); - } - pubKey = new r.KJUR.crypto.DSA(); - pubKey.setPublic(privKey.p, privKey.q, privKey.g, privKey.y); - } else if (privKey.n && privKey.e) { - pubKey = new r.RSAKey(); - pubKey.setPublic(privKey.n, privKey.e); - } else { - throw new OperationError(`Unsupported key type`); - } - const pubKeyPem = r.KEYUTIL.getPEM(pubKey); - // PEM ends with '\n', so a new key always starts on a new line - output += pubKeyPem; + const pubInfo = derivePublicKeyInfo(privInfo); + output += keyInfoToPem(pubInfo); } return output; } diff --git a/tests/operations/tests/JWK.mjs b/tests/operations/tests/JWK.mjs index c77c983e..844f42b3 100644 --- a/tests/operations/tests/JWK.mjs +++ b/tests/operations/tests/JWK.mjs @@ -290,7 +290,7 @@ TestRegister.addTests([ { name: "JWK to PEM: RSA Private Key", input: JSON.stringify(RSA_512.private.jwk), - expectedOutput: RSA_512.private.pem8.replace(/\r/g, "").replace(/\n/g, "\r\n")+"\r\n", + expectedOutput: RSA_512.private.pem8+"\n", recipeConfig: [ { op: "JWK to PEM", @@ -301,7 +301,7 @@ TestRegister.addTests([ { name: "JWK to PEM: RSA Public Key", input: JSON.stringify(RSA_512.public.jwk), - expectedOutput: RSA_512.public.pem8.replace(/\r/g, "").replace(/\n/g, "\r\n")+"\r\n", + expectedOutput: RSA_512.public.pem8+"\n", recipeConfig: [ { op: "JWK to PEM", @@ -314,7 +314,7 @@ TestRegister.addTests([ { name: "JWK to PEM: EC Private Key", input: JSON.stringify(EC_P256.private.jwk), - expectedOutput: EC_P256.private.pem8.replace(/\r/g, "").replace(/\n/g, "\r\n")+"\r\n", + expectedOutput: EC_P256.private.pem8+"\n", recipeConfig: [ { op: "JWK to PEM", @@ -325,7 +325,7 @@ TestRegister.addTests([ { name: "JWK to PEM: EC Public Key", input: JSON.stringify(EC_P256.public.jwk), - expectedOutput: EC_P256.public.pem8.replace(/\r/g, "").replace(/\n/g, "\r\n")+"\r\n", + expectedOutput: EC_P256.public.pem8+"\n", recipeConfig: [ { op: "JWK to PEM", @@ -337,7 +337,7 @@ TestRegister.addTests([ { name: "JWK to PEM: Array of keys", input: JSON.stringify([RSA_512.public.jwk, EC_P256.public.jwk]), - expectedOutput: (RSA_512.public.pem8 + "\n" + EC_P256.public.pem8 + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (RSA_512.public.pem8 + "\n" + EC_P256.public.pem8 + "\n"), recipeConfig: [ { op: "JWK to PEM", @@ -348,7 +348,7 @@ TestRegister.addTests([ { name: "JWK to PEM: JSON Web Key Set", input: JSON.stringify({"keys": [RSA_512.public.jwk, EC_P256.public.jwk]}), - expectedOutput: (RSA_512.public.pem8 + "\n" + EC_P256.public.pem8 + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (RSA_512.public.pem8 + "\n" + EC_P256.public.pem8 + "\n"), recipeConfig: [ { op: "JWK to PEM", diff --git a/tests/operations/tests/PubKeyFromPrivKey.mjs b/tests/operations/tests/PubKeyFromPrivKey.mjs index 25f14a69..6261a169 100644 --- a/tests/operations/tests/PubKeyFromPrivKey.mjs +++ b/tests/operations/tests/PubKeyFromPrivKey.mjs @@ -147,7 +147,7 @@ TestRegister.addTests([ { name: "Public Key from Private Key: RSA PKCS#1", input: RSA_PRIVKEY_PKCS1, - expectedOutput: (RSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (RSA_PUBKEY + "\n"), recipeConfig: [ { op: "Public Key from Private Key", @@ -158,7 +158,7 @@ TestRegister.addTests([ { name: "Public Key from Private Key: RSA PKCS#8", input: RSA_PRIVKEY_PKCS8, - expectedOutput: (RSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (RSA_PUBKEY + "\n"), recipeConfig: [ { op: "Public Key from Private Key", @@ -171,7 +171,7 @@ TestRegister.addTests([ { name: "Public Key from Private Key: EC SEC1", input: EC_P256_PRIVKEY_SEC1, - expectedOutput: (EC_P256_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (EC_P256_PUBKEY + "\n"), recipeConfig: [ { op: "Public Key from Private Key", @@ -182,7 +182,7 @@ TestRegister.addTests([ { name: "Public Key from Private Key: EC PKCS#8", input: EC_P256_PRIVKEY_PKCS8, - expectedOutput: (EC_P256_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (EC_P256_PUBKEY + "\n"), recipeConfig: [ { op: "Public Key from Private Key", @@ -195,7 +195,7 @@ TestRegister.addTests([ { name: "Public Key from Private Key: DSA Traditional", input: DSA_PRIVKEY_TRAD, - expectedOutput: (DSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (DSA_PUBKEY + "\n"), recipeConfig: [ { op: "Public Key from Private Key", @@ -219,7 +219,7 @@ TestRegister.addTests([ { name: "Public Key from Private Key: Ed25519", input: ED25519_PRIVKEY, - expectedOutput: "Unsupported key type: Error: malformed PKCS8 private key(code:004)", + expectedOutput: "Unsupported key type: Error: Provided key is not an EC key.", recipeConfig: [ { op: "Public Key from Private Key", @@ -230,7 +230,7 @@ TestRegister.addTests([ { name: "Public Key from Private Key: Ed448", input: ED448_PRIVKEY, - expectedOutput: "Unsupported key type: Error: malformed PKCS8 private key(code:004)", + expectedOutput: "Unsupported key type: Error: Provided key is not an EC key.", recipeConfig: [ { op: "Public Key from Private Key", @@ -243,7 +243,7 @@ TestRegister.addTests([ { name: "Public Key from Private Key: Multiple keys", input: RSA_PRIVKEY_PKCS8 + "\n" + EC_P256_PRIVKEY_PKCS8, - expectedOutput: (RSA_PUBKEY + "\n" + EC_P256_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (RSA_PUBKEY + "\n" + EC_P256_PUBKEY + "\n"), recipeConfig: [ { op: "Public Key from Private Key",