diff --git a/plan-jsrsasign.md b/plan-jsrsasign.md index d643abf3..687aa2cc 100644 --- a/plan-jsrsasign.md +++ b/plan-jsrsasign.md @@ -6,13 +6,14 @@ - [x] PR 1 — Setup + ASN.1 utilities - [x] PR 2 — SM2 rewrite -- [ ] PR 3 — ECDSA primitives +- [x] PR 3 — ECDSA primitives - [ ] 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 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 @@ -238,6 +239,15 @@ 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 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. +- **`prehash: false`.** The operation hashes the message itself (so MD5/SHA-1 work via `@noble/hashes/legacy`); noble would otherwise re-hash with the curve's default digest. The Sign/Verify operations call `digestBytes(...)` and pass the digest in directly. +- **Latin-1 truncation for string→bytes.** Both Sign (input string) and Verify (`Utils.convertToByteString(msg, "Raw")` → string) follow what jsrsasign did under the hood: each JS code unit is masked to its low byte. That's wrong for free-text UTF-8 but matches the existing behaviour and is what the round-trip tests assume. Encoded as `strToBytesLatin1` in `Ecdsa.mjs`. +- **`parseAsn1SigToHexRS` keeps the DER `00` prefix.** The jsrsasign helper returned r/s as the raw INTEGER bytes (so a 32-byte r whose MSB was set came back as 33 hex bytes starting with `00`). The Raw JSON test fixture in [tests/operations/tests/ECDSA.mjs](tests/operations/tests/ECDSA.mjs) depends on that. Replicated with an inline DER reader rather than going through `bigint` (which would strip the leading zero). +- **No fixture changes.** All 83 ECDSA tests pass unchanged: deterministic-k sign↔verify cycles, the canned P-256 SHA-256 ASN.1/P1363/JWS/JSON inputs, and the negative tests (RSA key rejected, private-where-public expected, JSON missing r/s, etc.). The Generate ECDSA Key Pair op has no fixtures; manually exercised PEM/DER/JWK output paths. +- `id_*` OID constants from `@peculiar/asn1-ecc` are namespace-imported and re-aliased to SCREAMING_SNAKE locals because ESLint's `camelcase` rule trips on the snake_case export names. + ### PR 2 — 2026-05-17 - `@noble/curves` v2 has no `/sm2` subpath, so [src/core/lib/SM2.mjs](src/core/lib/SM2.mjs) builds the curve itself with `weierstrass(...)` from `@noble/curves/abstract/weierstrass.js` using the GM/T 0003-2012 parameter set. Curve constructor is memoised per name so repeated `new SM2(...)` calls don't rebuild it. - Random `k` generation: `ecdh(Point).utils.randomSecretKey()` → `bytesToNumberBE(...) % (n-1n) + 1n`, which mirrors the `[1, n-1]` distribution of the old `r.SecureRandom`-backed `getBigRandom`. The plan suggested `sm2.utils.randomPrivateKey()` directly; the abstract-Weierstrass path needs the explicit `bytes → bigint mod` step because the curve isn't pre-wrapped. diff --git a/src/core/lib/Ecdsa.mjs b/src/core/lib/Ecdsa.mjs new file mode 100644 index 00000000..9614a904 --- /dev/null +++ b/src/core/lib/Ecdsa.mjs @@ -0,0 +1,596 @@ +/** + * Shared ECDSA helpers built on @noble/curves and @peculiar/asn1-*. + * + * Used by the ECDSA Sign/Verify/Signature Conversion/Generate Key Pair + * operations. Migrated from jsrsasign. + * + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +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 { AsnParser, AsnSerializer, OctetString } from "@peculiar/asn1-schema"; +import * as ecc from "@peculiar/asn1-ecc"; +const { ECPrivateKey, ECParameters } = ecc; +const ID_EC_PUBLIC_KEY = ecc.id_ecPublicKey; +const ID_SECP256R1 = ecc.id_secp256r1; +const ID_SECP384R1 = ecc.id_secp384r1; +const ID_SECP521R1 = ecc.id_secp521r1; +import { PrivateKeyInfo } from "@peculiar/asn1-pkcs8"; +import { AlgorithmIdentifier, SubjectPublicKeyInfo } from "@peculiar/asn1-x509"; +import { fromBER } from "asn1js"; +import OperationError from "../errors/OperationError.mjs"; + +const CURVES = { + "P-256": { oid: ID_SECP256R1, curve: p256, byteLen: 32 }, + "P-384": { oid: ID_SECP384R1, curve: p384, byteLen: 48 }, + "P-521": { oid: ID_SECP521R1, curve: p521, byteLen: 66 }, +}; + +const OID_TO_CURVE = { + [ID_SECP256R1]: "P-256", + [ID_SECP384R1]: "P-384", + [ID_SECP521R1]: "P-521", +}; + +const HASHES = { + "MD5": md5, + "SHA-1": sha1, + "SHA-256": sha256, + "SHA-384": sha384, + "SHA-512": sha512, +}; + +/** + * Return the @noble/curves ECDSA instance for a curve name. + * + * @param {string} name - One of "P-256", "P-384", "P-521". + * @returns {{oid: string, curve: object, byteLen: number, name: string}} + */ +export function getCurveByName(name) { + const entry = CURVES[name]; + if (!entry) throw new OperationError(`Unsupported curve: ${name}`); + return { ...entry, name }; +} + +/** + * Hash a byte string with one of the supported digests. + * + * @param {string} algo - "MD5", "SHA-1", "SHA-256", "SHA-384" or "SHA-512". + * @param {Uint8Array} bytes + * @returns {Uint8Array} + */ +export function digestBytes(algo, bytes) { + const fn = HASHES[algo]; + if (!fn) throw new OperationError(`Unsupported digest: ${algo}`); + return fn(bytes); +} + +/** + * Convert a JS string to bytes by Latin-1 truncation of each code unit (i.e. + * `charCodeAt(i) & 0xff`). This matches how jsrsasign / CryptoJS fed strings + * into MessageDigest.update — keeping signatures interoperable for inputs + * coming out of upstream byte-producing ops, while preserving the + * (questionable) UTF-16-truncating behaviour for free-text inputs. + * + * @param {string} str + * @returns {Uint8Array} + */ +export function strToBytesLatin1(str) { + const out = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xff; + return out; +} + +/** + * Parse a PEM-encoded EC key (SEC1, PKCS#8 or SPKI). + * + * @param {string} pem + * @returns {{ + * curveName: string, + * curve: object, + * byteLen: number, + * isPrivate: boolean, + * isPublic: boolean, + * d: Uint8Array|null, + * publicKey: Uint8Array + * }} + */ +export function loadEcKey(pem) { + const { label, bytes } = pemToDer(pem); + + if (label === "EC PRIVATE KEY") { + return parseSec1PrivateKey(bytes); + } + if (label === "PRIVATE KEY") { + return parsePkcs8PrivateKey(bytes); + } + if (label === "PUBLIC KEY") { + return parseSpkiPublicKey(bytes); + } + if (label === "RSA PRIVATE KEY" || label === "RSA PUBLIC KEY") { + throw new OperationError("Provided key is not an EC key."); + } + throw new OperationError("Provided key is not an EC key."); +} + +/** + * Sign a digest with an EC private key. + * + * @param {object} keyInfo - Output of {@link loadEcKey}. + * @param {Uint8Array} digest + * @returns {string} ASN.1 DER signature, hex-encoded. + */ +export function signEcdsa(keyInfo, digest) { + if (!keyInfo.isPrivate || !keyInfo.d) { + throw new OperationError("Provided key is not a private key."); + } + const sig = keyInfo.curve.sign(digest, keyInfo.d, { + prehash: false, + lowS: false, + format: "der", + }); + return bytesToHex(sig); +} + +/** + * Verify a DER-encoded ECDSA signature against a digest and public key. + * + * @param {object} keyInfo - Output of {@link loadEcKey}. + * @param {Uint8Array} digest + * @param {string} asn1Hex - DER signature, hex-encoded. + * @returns {boolean} + */ +export function verifyEcdsa(keyInfo, digest, asn1Hex) { + if (!keyInfo.isPublic) { + throw new OperationError("Provided key is not a public key."); + } + try { + return keyInfo.curve.verify(hexToBytes(asn1Hex), digest, keyInfo.publicKey, { + prehash: false, + lowS: false, + format: "der", + }); + } catch { + return false; + } +} + +/** + * Quick test for whether a hex string parses as a single DER-encoded ASN.1 + * value. Mirrors jsrsasign's ASN1HEX.isASN1HEX. + * + * @param {string} hex + * @returns {boolean} + */ +export function isAsn1Hex(hex) { + if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) return false; + if (!/^[0-9a-f]+$/i.test(hex)) return false; + const bytes = hexToBytes(hex); + try { + const result = fromBER(bytes.buffer); + return result.offset !== -1; + } catch { + return false; + } +} + +/** + * Parse an ASN.1 DER-encoded ECDSA signature and return the raw r/s INTEGER + * bytes as hex. Preserves the DER 2's-complement leading 0x00 when present + * (i.e. r/s may have a leading "00" pair) — this matches the legacy + * jsrsasign ECDSA.parseSigHexInHexRS behaviour that existing tests assume. + * + * @param {string} asn1Hex + * @returns {{r: string, s: string}} + */ +export function parseAsn1SigToHexRS(asn1Hex) { + const bytes = hexToBytes(asn1Hex); + let i = 0; + if (bytes[i++] !== 0x30) throw new OperationError("Signature is not an ASN.1 SEQUENCE"); + const seq = readLength(bytes, i); + i = seq.next; + if (i + seq.value !== bytes.length) throw new OperationError("Trailing bytes after SEQUENCE"); + + if (bytes[i++] !== 0x02) throw new OperationError("First element is not an INTEGER"); + const rLen = readLength(bytes, i); + i = rLen.next; + const r = bytes.slice(i, i + rLen.value); + i += rLen.value; + + if (bytes[i++] !== 0x02) throw new OperationError("Second element is not an INTEGER"); + const sLen = readLength(bytes, i); + i = sLen.next; + const s = bytes.slice(i, i + sLen.value); + + return { r: bytesToHex(r), s: bytesToHex(s) }; +} + +/** + * Convert hex r/s pair to a DER-encoded ECDSA signature hex string. + * + * @param {string} rHex + * @param {string} sHex + * @returns {string} + */ +export function hexRSToAsn1Sig(rHex, sHex) { + const r = BigInt("0x" + rHex); + const s = BigInt("0x" + sHex); + return DER.hexFromSig({ r, s }); +} + +/** + * Convert a DER-encoded ECDSA signature (hex) to the P1363 / IEEE concat + * form (r || s, each fixed-width). The signature's curve is inferred from + * the integer sizes — supports P-256/P-384/P-521. + * + * @param {string} asn1Hex + * @returns {string} + */ +export function asn1SigToConcatHex(asn1Hex) { + const { r, s } = parseAsn1SigToHexRS(asn1Hex); + const rStripped = stripDerLeadingZero(r); + const sStripped = stripDerLeadingZero(s); + const maxBytes = Math.max(rStripped.length, sStripped.length) / 2; + + let coordBytes; + if (maxBytes <= 32) coordBytes = 32; + else if (maxBytes <= 48) coordBytes = 48; + else if (maxBytes <= 66) coordBytes = 66; + else throw new OperationError(`Unsupported ECDSA signature size (${maxBytes} bytes per component)`); + + const width = coordBytes * 2; + return rStripped.padStart(width, "0") + sStripped.padStart(width, "0"); +} + +/** + * Convert a concat (P1363) ECDSA signature hex back to ASN.1 DER hex. + * + * @param {string} concatHex + * @returns {string} + */ +export function concatHexToAsn1Sig(concatHex) { + if (concatHex.length % 4 !== 0) { + throw new OperationError("Concat signature length must be a multiple of 4 hex chars"); + } + const half = concatHex.length / 2; + return hexRSToAsn1Sig(concatHex.slice(0, half), concatHex.slice(half)); +} + +/** + * Generate an EC key pair on the named curve. + * + * @param {string} curveName - "P-256", "P-384" or "P-521". + * @returns {{ + * curveName: string, + * byteLen: number, + * d: Uint8Array, + * publicKey: Uint8Array, + * x: Uint8Array, + * y: Uint8Array + * }} + */ +export function generateEcKeyPair(curveName) { + const info = getCurveByName(curveName); + const { secretKey, publicKey } = info.curve.keygen(); + const { x, y } = splitUncompressedPoint(publicKey, info.byteLen); + return { + curveName, + byteLen: info.byteLen, + d: secretKey, + publicKey, + x, + y, + }; +} + +/** + * Encode the public key half of a generated key pair as SPKI PEM. + * + * @param {object} pair - Output of {@link generateEcKeyPair}. + * @returns {string} + */ +export function publicKeyToSpkiPem(pair) { + const info = getCurveByName(pair.curveName); + const params = new ECParameters({ namedCurve: info.oid }); + const spki = new SubjectPublicKeyInfo({ + algorithm: new AlgorithmIdentifier({ + algorithm: ID_EC_PUBLIC_KEY, + parameters: AsnSerializer.serialize(params), + }), + subjectPublicKey: pair.publicKey.slice().buffer, + }); + return derToPem(new Uint8Array(AsnSerializer.serialize(spki)), "PUBLIC KEY"); +} + +/** + * Encode the private key half of a generated key pair as PKCS#8 PEM. + * + * @param {object} pair - Output of {@link generateEcKeyPair}. + * @returns {string} + */ +export function privateKeyToPkcs8Pem(pair) { + const info = getCurveByName(pair.curveName); + const params = new ECParameters({ namedCurve: info.oid }); + const ecKey = new ECPrivateKey({ + version: 1, + privateKey: new OctetString(pair.d), + publicKey: pair.publicKey.slice().buffer, + }); + const pkcs8 = new PrivateKeyInfo({ + version: 0, + privateKeyAlgorithm: new AlgorithmIdentifier({ + algorithm: ID_EC_PUBLIC_KEY, + parameters: AsnSerializer.serialize(params), + }), + privateKey: new OctetString(AsnSerializer.serialize(ecKey)), + }); + return derToPem(new Uint8Array(AsnSerializer.serialize(pkcs8)), "PRIVATE KEY"); +} + + +// ---- internals -------------------------------------------------------------- + +/** + * Parse a PEM blob and return the decoded body bytes plus the label + * (e.g. "EC PRIVATE KEY"). + * + * @param {string} pem + * @returns {{label: string, bytes: Uint8Array}} + */ +function pemToDer(pem) { + const match = pem.match(/-----BEGIN ([A-Z0-9 ]+)-----([\s\S]+?)-----END \1-----/); + if (!match) throw new OperationError("Not a valid PEM"); + 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) }; +} + +/** + * Parse a SEC1 ECPrivateKey blob. + * + * @param {Uint8Array} bytes + * @returns {object} + */ +function parseSec1PrivateKey(bytes) { + let ec; + try { + ec = AsnParser.parse(bytes, ECPrivateKey); + } catch (e) { + throw new OperationError(`Could not parse EC private key: ${e.message}`); + } + const curveName = curveFromParameters(ec.parameters); + if (!curveName) throw new OperationError("EC private key missing curve parameters"); + return buildPrivateKeyInfo(curveName, ec); +} + +/** + * Parse a PKCS#8 PrivateKeyInfo blob. + * + * @param {Uint8Array} bytes + * @returns {object} + */ +function parsePkcs8PrivateKey(bytes) { + let info; + try { + info = AsnParser.parse(bytes, PrivateKeyInfo); + } catch (e) { + throw new OperationError(`Could not parse PKCS#8 key: ${e.message}`); + } + if (info.privateKeyAlgorithm.algorithm !== ID_EC_PUBLIC_KEY) { + throw new OperationError("Provided key is not an EC key."); + } + const params = info.privateKeyAlgorithm.parameters; + if (!params) throw new OperationError("EC private key missing curve parameters"); + const ecParams = AsnParser.parse(params, ECParameters); + const curveName = curveFromParameters(ecParams); + if (!curveName) throw new OperationError("Unsupported EC curve"); + + const innerBytes = new Uint8Array(info.privateKey.buffer, info.privateKey.byteOffset, info.privateKey.byteLength); + let ec; + try { + ec = AsnParser.parse(innerBytes, ECPrivateKey); + } catch (e) { + throw new OperationError(`Could not parse EC private key: ${e.message}`); + } + return buildPrivateKeyInfo(curveName, ec); +} + +/** + * Parse a SubjectPublicKeyInfo blob. + * + * @param {Uint8Array} bytes + * @returns {object} + */ +function parseSpkiPublicKey(bytes) { + let spki; + try { + spki = AsnParser.parse(bytes, SubjectPublicKeyInfo); + } catch (e) { + throw new OperationError(`Could not parse SPKI: ${e.message}`); + } + if (spki.algorithm.algorithm !== ID_EC_PUBLIC_KEY) { + throw new OperationError("Provided key is not an EC key."); + } + const params = spki.algorithm.parameters; + if (!params) throw new OperationError("EC public key missing curve parameters"); + const ecParams = AsnParser.parse(params, ECParameters); + const curveName = curveFromParameters(ecParams); + if (!curveName) throw new OperationError("Unsupported EC curve"); + const info = getCurveByName(curveName); + + const pub = new Uint8Array(spki.subjectPublicKey); + if (pub[0] !== 0x04) { + throw new OperationError("Only uncompressed EC public keys are supported"); + } + if (pub.length !== 1 + info.byteLen * 2) { + throw new OperationError("EC public key has the wrong length for the named curve"); + } + const { x, y } = splitUncompressedPoint(pub, info.byteLen); + return { + curveName, + curve: info.curve, + byteLen: info.byteLen, + isPrivate: false, + isPublic: true, + d: null, + publicKey: pub, + x, + y, + }; +} + +/** + * Build the loadEcKey return value for a parsed ECPrivateKey. + * + * @param {string} curveName + * @param {object} ec + * @returns {object} + */ +function buildPrivateKeyInfo(curveName, ec) { + const info = getCurveByName(curveName); + const d = leftPadTo( + new Uint8Array(ec.privateKey.buffer, ec.privateKey.byteOffset, ec.privateKey.byteLength), + info.byteLen, + ); + const publicKey = info.curve.getPublicKey(d, false); + const { x, y } = splitUncompressedPoint(publicKey, info.byteLen); + return { + curveName, + curve: info.curve, + byteLen: info.byteLen, + isPrivate: true, + isPublic: false, + d, + publicKey, + x, + y, + }; +} + +/** + * Pad a byte array on the left with zeros to reach `length` bytes. + * + * @param {Uint8Array} bytes + * @param {number} length + * @returns {Uint8Array} + */ +function leftPadTo(bytes, length) { + if (bytes.length === length) return bytes; + if (bytes.length > length) throw new OperationError("EC scalar is longer than the curve allows"); + const out = new Uint8Array(length); + out.set(bytes, length - bytes.length); + return out; +} + +/** + * Map an ECParameters object's namedCurve OID to our short curve name. + * + * @param {object|null|undefined} ecParams + * @returns {string|null} + */ +function curveFromParameters(ecParams) { + if (!ecParams || !ecParams.namedCurve) return null; + return OID_TO_CURVE[ecParams.namedCurve] || null; +} + +/** + * Split an uncompressed SEC1 point (04 || X || Y) into its X and Y bytes. + * + * @param {Uint8Array} pub + * @param {number} byteLen + * @returns {{x: Uint8Array, y: Uint8Array}} + */ +function splitUncompressedPoint(pub, byteLen) { + return { + x: pub.slice(1, 1 + byteLen), + y: pub.slice(1 + byteLen, 1 + 2 * byteLen), + }; +} + +/** + * Strip a single leading "00" byte from a DER INTEGER hex if it's only there + * to keep the value positive — i.e. when the next byte's MSB is set. + * + * @param {string} hex + * @returns {string} + */ +function stripDerLeadingZero(hex) { + if (hex.length >= 4 && hex.slice(0, 2) === "00") { + const second = parseInt(hex.slice(2, 4), 16); + if (second & 0x80) return hex.slice(2); + } + return hex; +} + +/** + * Read a BER/DER length octet sequence. + * + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {{value: number, next: number}} + */ +function readLength(bytes, offset) { + const first = bytes[offset]; + if (first < 0x80) return { value: first, next: offset + 1 }; + const n = first & 0x7f; + let value = 0; + for (let i = 0; i < n; i++) value = (value << 8) | bytes[offset + 1 + i]; + 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/operations/ECDSASign.mjs b/src/core/operations/ECDSASign.mjs index 7b8f57f1..9f2d6045 100644 --- a/src/core/operations/ECDSASign.mjs +++ b/src/core/operations/ECDSASign.mjs @@ -8,7 +8,14 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; import { fromHex } from "../lib/Hex.mjs"; import { toBase64 } from "../lib/Base64.mjs"; -import r from "jsrsasign"; +import { + loadEcKey, + digestBytes, + signEcdsa, + strToBytesLatin1, + asn1SigToConcatHex, + parseAsn1SigToHexRS, +} from "../lib/Ecdsa.mjs"; /** * ECDSA Sign operation @@ -69,38 +76,28 @@ class ECDSASign extends Operation { throw new OperationError("Please enter a private key."); } - const internalAlgorithmName = mdAlgo.replace("-", "") + "withECDSA"; - const sig = new r.KJUR.crypto.Signature({ alg: internalAlgorithmName }); - const key = r.KEYUTIL.getKey(keyPem); - if (key.type !== "EC") { - throw new OperationError("Provided key is not an EC key."); - } + const key = loadEcKey(keyPem); if (!key.isPrivate) { throw new OperationError("Provided key is not a private key."); } - sig.init(key); - const signatureASN1Hex = sig.signString(input); - let result; + const digest = digestBytes(mdAlgo, strToBytesLatin1(input)); + const signatureASN1Hex = signEcdsa(key, digest); + switch (outputFormat) { case "ASN.1 HEX": - result = signatureASN1Hex; - break; + return signatureASN1Hex; case "P1363 HEX": - result = r.KJUR.crypto.ECDSA.asn1SigToConcatSig(signatureASN1Hex); - break; - case "JSON Web Signature": - result = r.KJUR.crypto.ECDSA.asn1SigToConcatSig(signatureASN1Hex); - result = toBase64(fromHex(result), "A-Za-z0-9-_"); // base64url - break; - case "Raw JSON": { - const signatureRS = r.KJUR.crypto.ECDSA.parseSigHexInHexRS(signatureASN1Hex); - result = JSON.stringify(signatureRS); - break; + return asn1SigToConcatHex(signatureASN1Hex); + case "JSON Web Signature": { + const concat = asn1SigToConcatHex(signatureASN1Hex); + return toBase64(fromHex(concat), "A-Za-z0-9-_"); // base64url } + case "Raw JSON": + return JSON.stringify(parseAsn1SigToHexRS(signatureASN1Hex)); + default: + throw new OperationError(`Unsupported output format: ${outputFormat}`); } - - return result; } } diff --git a/src/core/operations/ECDSASignatureConversion.mjs b/src/core/operations/ECDSASignatureConversion.mjs index 3f6c6bfb..6ed2f7f8 100644 --- a/src/core/operations/ECDSASignatureConversion.mjs +++ b/src/core/operations/ECDSASignatureConversion.mjs @@ -8,10 +8,16 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; import { fromBase64, toBase64 } from "../lib/Base64.mjs"; import { fromHex, toHexFast } from "../lib/Hex.mjs"; -import r from "jsrsasign"; +import { + asn1SigToConcatHex, + concatHexToAsn1Sig, + hexRSToAsn1Sig, + parseAsn1SigToHexRS, + isAsn1Hex, +} from "../lib/Ecdsa.mjs"; /** - * ECDSA Sign operation + * ECDSA Signature Conversion operation */ class ECDSASignatureConversion extends Operation { @@ -75,7 +81,7 @@ class ECDSASignatureConversion extends Operation { if (inputFormat === "Auto") { const hexRegex = /^[a-f\d]{2,}$/gi; if (hexRegex.test(input)) { - if (input.substring(0, 2) === "30" && r.ASN1HEX.isASN1HEX(input)) { + if (input.substring(0, 2) === "30" && isAsn1Hex(input)) { inputFormat = "ASN.1 HEX"; } else { inputFormat = "P1363 HEX"; @@ -100,11 +106,11 @@ class ECDSASignatureConversion extends Operation { signatureASN1Hex = input; break; case "P1363 HEX": - signatureASN1Hex = r.KJUR.crypto.ECDSA.concatSigToASN1Sig(input); + signatureASN1Hex = concatHexToAsn1Sig(input); break; case "JSON Web Signature": if (!inputBase64) inputBase64 = fromBase64(input, "A-Za-z0-9-_"); - signatureASN1Hex = r.KJUR.crypto.ECDSA.concatSigToASN1Sig(toHexFast(inputBase64)); + signatureASN1Hex = concatHexToAsn1Sig(toHexFast(inputBase64)); break; case "Raw JSON": { if (!inputJson) inputJson = JSON.parse(input); @@ -114,32 +120,26 @@ class ECDSASignatureConversion extends Operation { if (!inputJson.s) { throw new OperationError('No "s" value in the signature JSON'); } - signatureASN1Hex = r.KJUR.crypto.ECDSA.hexRSSigToASN1Sig(inputJson.r, inputJson.s); + signatureASN1Hex = hexRSToAsn1Sig(inputJson.r, inputJson.s); break; } } // convert ASN.1 hex to output format - let result; switch (outputFormat) { case "ASN.1 HEX": - result = signatureASN1Hex; - break; + return signatureASN1Hex; case "P1363 HEX": - result = r.KJUR.crypto.ECDSA.asn1SigToConcatSig(signatureASN1Hex); - break; - case "JSON Web Signature": - result = r.KJUR.crypto.ECDSA.asn1SigToConcatSig(signatureASN1Hex); - result = toBase64(fromHex(result), "A-Za-z0-9-_"); // base64url - break; - case "Raw JSON": { - const signatureRS = r.KJUR.crypto.ECDSA.parseSigHexInHexRS(signatureASN1Hex); - result = JSON.stringify(signatureRS); - break; + return asn1SigToConcatHex(signatureASN1Hex); + case "JSON Web Signature": { + const concat = asn1SigToConcatHex(signatureASN1Hex); + return toBase64(fromHex(concat), "A-Za-z0-9-_"); // base64url } + case "Raw JSON": + return JSON.stringify(parseAsn1SigToHexRS(signatureASN1Hex)); + default: + throw new OperationError(`Unsupported output format: ${outputFormat}`); } - - return result; } } diff --git a/src/core/operations/ECDSAVerify.mjs b/src/core/operations/ECDSAVerify.mjs index 1f8a53ea..84d82f7e 100644 --- a/src/core/operations/ECDSAVerify.mjs +++ b/src/core/operations/ECDSAVerify.mjs @@ -8,8 +8,16 @@ import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; import { fromBase64 } from "../lib/Base64.mjs"; import { toHexFast } from "../lib/Hex.mjs"; -import r from "jsrsasign"; import Utils from "../Utils.mjs"; +import { + loadEcKey, + digestBytes, + verifyEcdsa, + strToBytesLatin1, + concatHexToAsn1Sig, + hexRSToAsn1Sig, + isAsn1Hex, +} from "../lib/Ecdsa.mjs"; /** * ECDSA Verify operation @@ -96,7 +104,7 @@ class ECDSAVerify extends Operation { if (inputFormat === "Auto") { const hexRegex = /^[a-f\d]{2,}$/gi; if (hexRegex.test(input)) { - if (input.substring(0, 2) === "30" && r.ASN1HEX.isASN1HEX(input)) { + if (input.substring(0, 2) === "30" && isAsn1Hex(input)) { inputFormat = "ASN.1 HEX"; } else { inputFormat = "P1363 HEX"; @@ -121,11 +129,11 @@ class ECDSAVerify extends Operation { signatureASN1Hex = input; break; case "P1363 HEX": - signatureASN1Hex = r.KJUR.crypto.ECDSA.concatSigToASN1Sig(input); + signatureASN1Hex = concatHexToAsn1Sig(input); break; case "JSON Web Signature": if (!inputBase64) inputBase64 = fromBase64(input, "A-Za-z0-9-_"); - signatureASN1Hex = r.KJUR.crypto.ECDSA.concatSigToASN1Sig(toHexFast(inputBase64)); + signatureASN1Hex = concatHexToAsn1Sig(toHexFast(inputBase64)); break; case "Raw JSON": { if (!inputJson) inputJson = JSON.parse(input); @@ -135,26 +143,20 @@ class ECDSAVerify extends Operation { if (!inputJson.s) { throw new OperationError('No "s" value in the signature JSON'); } - signatureASN1Hex = r.KJUR.crypto.ECDSA.hexRSSigToASN1Sig(inputJson.r, inputJson.s); + signatureASN1Hex = hexRSToAsn1Sig(inputJson.r, inputJson.s); break; } } - // verify signature - const internalAlgorithmName = mdAlgo.replace("-", "") + "withECDSA"; - const sig = new r.KJUR.crypto.Signature({ alg: internalAlgorithmName }); - const key = r.KEYUTIL.getKey(keyPem); - if (key.type !== "EC") { - throw new OperationError("Provided key is not an EC key."); - } + const key = loadEcKey(keyPem); if (!key.isPublic) { throw new OperationError("Provided key is not a public key."); } - sig.init(key); + const messageStr = Utils.convertToByteString(msg, msgFormat); - sig.updateString(messageStr); - const result = sig.verify(signatureASN1Hex); - return result ? "Verified OK" : "Verification Failure"; + const digest = digestBytes(mdAlgo, strToBytesLatin1(messageStr)); + const ok = verifyEcdsa(key, digest, signatureASN1Hex); + return ok ? "Verified OK" : "Verification Failure"; } } diff --git a/src/core/operations/GenerateECDSAKeyPair.mjs b/src/core/operations/GenerateECDSAKeyPair.mjs index 14714a02..acd98f1c 100644 --- a/src/core/operations/GenerateECDSAKeyPair.mjs +++ b/src/core/operations/GenerateECDSAKeyPair.mjs @@ -6,7 +6,12 @@ import Operation from "../Operation.mjs"; import { cryptNotice } from "../lib/Crypt.mjs"; -import r from "jsrsasign"; +import { toBase64 } from "../lib/Base64.mjs"; +import { + generateEcKeyPair, + publicKeyToSpkiPem, + privateKeyToPkcs8Pem, +} from "../lib/Ecdsa.mjs"; /** * Generate ECDSA Key Pair operation @@ -54,49 +59,59 @@ class GenerateECDSAKeyPair extends Operation { */ async run(input, args) { const [curveName, outputFormat] = args; + const pair = generateEcKeyPair(curveName); - return new Promise((resolve, reject) => { - let internalCurveName; - switch (curveName) { - case "P-256": - internalCurveName = "secp256r1"; - break; - case "P-384": - internalCurveName = "secp384r1"; - break; - case "P-521": - internalCurveName = "secp521r1"; - break; + switch (outputFormat) { + case "PEM": + return publicKeyToSpkiPem(pair) + privateKeyToPkcs8Pem(pair); + case "DER": + return bytesToHex(pair.d); + case "JWK": { + const pubJwk = { + kty: "EC", + crv: curveName, + x: b64url(pair.x), + y: b64url(pair.y), + "key_ops": ["verify"], + kid: "PublicKey", + }; + const privJwk = { + kty: "EC", + crv: curveName, + x: b64url(pair.x), + y: b64url(pair.y), + d: b64url(pair.d), + "key_ops": ["sign"], + kid: "PrivateKey", + }; + return JSON.stringify({ keys: [privJwk, pubJwk] }, null, 4); } - const keyPair = r.KEYUTIL.generateKeypair("EC", internalCurveName); - - let pubKey; - let privKey; - let result; - switch (outputFormat) { - case "PEM": - pubKey = r.KEYUTIL.getPEM(keyPair.pubKeyObj).replace(/\r/g, ""); - privKey = r.KEYUTIL.getPEM(keyPair.prvKeyObj, "PKCS8PRV").replace(/\r/g, ""); - result = pubKey + "\n" + privKey; - break; - case "DER": - result = keyPair.prvKeyObj.prvKeyHex; - break; - case "JWK": - pubKey = r.KEYUTIL.getJWKFromKey(keyPair.pubKeyObj); - pubKey.key_ops = ["verify"]; // eslint-disable-line camelcase - pubKey.kid = "PublicKey"; - privKey = r.KEYUTIL.getJWKFromKey(keyPair.prvKeyObj); - privKey.key_ops = ["sign"]; // eslint-disable-line camelcase - privKey.kid = "PrivateKey"; - result = JSON.stringify({keys: [privKey, pubKey]}, null, 4); - break; - } - - resolve(result); - }); + default: + throw new Error(`Unsupported output format: ${outputFormat}`); + } } +} +/** + * Base64url-encode a byte array (no padding, URL-safe alphabet). + * + * @param {Uint8Array} bytes + * @returns {string} + */ +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;