ECDSA primitives (Ciphers bundle)

This commit is contained in:
Leon Zandman 2026-05-17 18:14:32 +02:00
parent 7e302caded
commit 7f33ed5b91
6 changed files with 722 additions and 102 deletions

View File

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

596
src/core/lib/Ecdsa.mjs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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