Merge 203101c7155ff17fb9a6e8b77e8eb712a4133e42 into 5105aad91d9091be64182f1d79678fdbecfcfc15

This commit is contained in:
Leon Zandman 2026-06-09 21:31:25 +00:00 committed by GitHub
commit 1d0ca8cb3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 4073 additions and 1058 deletions

View File

@ -13,6 +13,9 @@ All major and minor version changes will be documented in this file. Details of
## Details ## Details
## [Unreleased]
- Removed the unmaintained `jsrsasign` dependency. ASN.1/OID/PEM utilities, SM2, ECDSA, PEM/JWK key conversion, and X.509/CSR/CRL parsing now use `@noble/curves`, `@peculiar/x509`, and `asn1js`; related golden text fixtures were updated during the migration for accepted cosmetic output-format drift.
## [11.0.0] - 2026-04-28 ## [11.0.0] - 2026-04-28
- Revert sitemap to v8.0.X to fix build/deploy on master [@GCHQDeveloper581] | [#2348] - Revert sitemap to v8.0.X to fix build/deploy on master [@GCHQDeveloper581] | [#2348]
- Node version update from 22 to 24 [@lzandman] [@GCHQDeveloper581] | [#2347] - Node version update from 22 to 24 [@lzandman] [@GCHQDeveloper581] | [#2347]
@ -1220,4 +1223,3 @@ Breaking changes:
[#2273]: https://github.com/gchq/CyberChef/pull/2273 [#2273]: https://github.com/gchq/CyberChef/pull/2273
[#2342]: https://github.com/gchq/CyberChef/pull/2342 [#2342]: https://github.com/gchq/CyberChef/pull/2342
[#1922]: https://github.com/gchq/CyberChef/pull/1922 [#1922]: https://github.com/gchq/CyberChef/pull/1922

140
CRYPTO_IMPLEMENTATION.md Normal file
View File

@ -0,0 +1,140 @@
# Cryptography Implementation Guide
This document captures the architectural decisions and coding conventions established during the migration away from `jsrsasign`. It serves as a reference for understanding library choices, implementation patterns, and maintenance of cryptographic operations.
## Library Choices
The following libraries were selected for specific cryptographic operations. These decisions prioritize compatibility with the existing CyberChef API surface and cryptographic correctness.
### ECDSA: `@noble/curves`
Uses `@noble/curves` with `@noble/hashes/legacy` for MD5/SHA-1 digests.
**Why not Web Crypto?** The Web Crypto API refuses MD5 and SHA-1 digests, which the CyberChef UI exposes to users. `@noble/curves` with legacy hash support maintains this functionality.
### X.509 / CSR / CRL: `@peculiar/x509`
Uses `@peculiar/x509` for certificate, CSR, and CRL parsing and generation, with `@peculiar/asn1-*` schema packages as needed.
**Version pin:** Keep `@peculiar/x509` on the `1.14.x` line.
**Why pinned?** `@peculiar/x509` v2 requires a `reflect-metadata` polyfill/import at each runtime entry point. CyberChef currently avoids that cross-bundle polyfill cost and complexity, so v1 remains the project baseline unless that tradeoff is revisited.
**Why not pkijs?** `@peculiar/x509` offers a cleaner API better suited to CyberChef's operation model.
### SM2: `@noble/curves/sm2`
Uses the SM2 implementation from `@noble/curves`.
**Why not sm-crypto?** `@noble/curves` provides a reliable, actively maintained SM2 implementation.
### DSA: `node-forge`
The `PubKeyFromPrivKey` operation continues to use `node-forge` for DSA key generation.
**Why?** `node-forge` is already a project dependency, and DSA is a single, localized operation not worth introducing a new library for.
### Generic ASN.1 Dump: `asn1js`
Uses `asn1js` for generic ASN.1 hex structure dumping.
**Note:** This is a transitive dependency of `@peculiar/x509` and requires no additional imports for most use cases.
## Coding Conventions
These conventions ensure consistency across cryptographic operations and maintain compatibility with existing test fixtures.
### PEM Line Endings
Use `\n` only for PEM-encoded output. Do not use `\r\n`.
The legacy `jsrsasign` output used `\r\n` in some cases. All test fixtures have been updated to expect `\n` exclusively.
### Hex Coordinate Padding
When converting elliptic curve point coordinates from `bigint` to hex:
- **P-256 and SM2:** Use `.padStart(64, "0")` (32 bytes = 64 hex characters)
- **P-521:** Use `.padStart(132, "0")` (66 bytes = 132 hex characters)
Apply this after calling `bigint.toString(16)`.
### JWK Field Ordering
Build JWK objects using literal syntax in this exact order so `JSON.stringify` emits fields in the correct sequence:
**Elliptic Curve:**
```javascript
{ kty, crv, x, y, d? }
```
**RSA:**
```javascript
{ kty, n, e, d?, p?, q?, dp?, dq?, qi? }
```
JavaScript preserves insertion order for object properties, and downstream consumers may depend on this ordering.
### ECDSA r/s Leading-Zero Quirk
The `parseSigHexInHexRS` function historically prepends `00` to the r or s component when its most significant bit is set. This is a quirk from DER 2's-complement encoding.
**Replicate this behavior.** Existing test fixtures depend on it, and changing it breaks compatibility.
### RFC 6979 Determinism
ECDSA signature outputs from `@noble/curves` *should* match `jsrsasign` byte-for-byte when using the same curve and digest algorithm.
**If they diverge:** The signature is still cryptographically valid. Update the test fixture and document the divergence in the changelog. Do not attempt to massage the library output to force a match.
### Golden Output Cosmetic Drift
For text-based operations that dump parsed structures (`ParseX509Certificate`, `ParseCSR`, `ParseX509CRL`, `ParseASN1HexString`), minor formatting differences between `jsrsasign` and the new implementation are acceptable.
**Action:** Update the test fixture and note the change in the changelog.
### Cryptographic Correctness is Non-Negotiable
Fixture data that exercises actual cryptographic operations (e.g., SM2 ciphertext→plaintext transformations in [tests/operations/tests/SM2.mjs](tests/operations/tests/SM2.mjs)) must pass unchanged. These fixtures pin real cryptographic behavior and cannot be updated cosmetically.
## Shared Helper Modules
To avoid duplicating logic across operations, use these utility modules:
### [src/core/lib/Asn1.mjs](src/core/lib/Asn1.mjs)
ASN.1 utilities for OID and DER manipulation:
- `oidHexToInt()` — convert ASN.1 OID hex encoding to integer dotted notation
- `oidIntToHex()` — convert integer dotted notation to ASN.1 OID hex
- `derToPem()` — wrap DER bytes in PEM format
- `dumpAsn1Hex()` — generic ASN.1 hex structure dump (uses `asn1js`)
### [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs)
ECDSA operations with `@noble/curves`:
- `loadEcKey()` — parse EC private/public keys from PEM or JWK
- `signEcdsa()` — sign data with an EC private key
- `verifyEcdsa()` — verify an ECDSA signature
- Signature format converters (ASN.1 DER ↔ r/s hex format)
- `isAsn1Hex()` — detect ASN.1 DER-encoded data
- `generateEcKeyPair()` — generate a new EC key pair
### [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs)
Public key utilities and DN formatting. The `formatDnObj()` function handles both legacy jsrsasign DN objects and `@peculiar/x509` `JsonName` shapes for backward compatibility.
### [src/core/lib/SM2.mjs](src/core/lib/SM2.mjs)
SM2 encryption and decryption with support for multiple ciphertext layouts:
- GMT 0009 BBB format
- GMT 0010 C1C2C3 format
- GMT 0010 C1C3C2 format
Preserves compatibility with existing test fixtures for all three layouts.
## Key File Locations
- **Cryptographic operations:** [src/core/operations/](src/core/operations/)
- **Test fixtures:** [tests/operations/tests/](tests/operations/tests/) — golden output fixtures live here
- **Dependencies:** [package.json](package.json)
- **Library modules:** [src/core/lib/](src/core/lib/)

View File

@ -8,6 +8,24 @@ const path = require("path");
const nodeFlags = "--no-warnings --no-deprecation"; const nodeFlags = "--no-warnings --no-deprecation";
/**
* Generates the bundle analyzer report from Webpack stats only.
*/
class StatsOnlyBundleAnalyzerPlugin extends BundleAnalyzerPlugin {
/**
* Prevent webpack-bundle-analyzer from reading emitted bundle files.
*
* Inline worker-loader assets are present in Webpack stats but intentionally
* not written to disk, so stat sizes are the most complete source for this
* production report.
*
* @returns {null}
*/
getBundleDirFromCompiler() {
return null;
}
}
/** /**
* Grunt configuration for building the app in various formats. * Grunt configuration for building the app in various formats.
* *
@ -141,10 +159,11 @@ module.exports = function (grunt) {
minifyCSS: true minifyCSS: true
} }
}), }),
new BundleAnalyzerPlugin({ new StatsOnlyBundleAnalyzerPlugin({
analyzerMode: "static", analyzerMode: "static",
reportFilename: "BundleAnalyzerReport.html", reportFilename: "BundleAnalyzerReport.html",
openAnalyzer: false openAnalyzer: false,
defaultSizes: "stat"
}), }),
] ]
}; };

306
PLAN_JSRSASIGN-REMOVAL.md Normal file
View File

@ -0,0 +1,306 @@
# Replace jsrsasign in CyberChef
> See [AGENTS.md](AGENTS.md) for cross-PR conventions, workflow, and library decisions agents must follow.
## Status
- [x] PR 1 — Setup + ASN.1 utilities
- [x] PR 2 — SM2 rewrite
- [x] PR 3 — ECDSA primitives
- [x] PR 4 — PEM/JWK conversion + key extraction
- [x] PR 5 — X.509 / CSR / CRL parsing
- [x] PR 6 — Removal
_Notes for next session:_
- **PR 6 complete:** dependency removed, source tree is free of runtime references, and the remaining migration references are documentation/changelog only.
- **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 resolved:** X.509 / CSR / CRL ops migrated to `@peculiar/x509` v1, plus the matching `@peculiar/asn1-*` schemas. New [src/core/lib/X509.mjs](src/core/lib/X509.mjs) holds the shared helpers (SPKI describer, signature-OID → display-name table, SAN/GeneralName formatter, EC-signature splitter, hex wrapping). Regression coverage added for `ParseX509Certificate` ([tests/operations/tests/ParseX509Certificate.mjs](tests/operations/tests/ParseX509Certificate.mjs)).
- **PR 5 blocker (still relevant for PR 6+):** `@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
[jsrsasign](https://github.com/kjur/jsrsasign) (currently pinned at `^11.1.3` in [package.json:148](package.json#L148)) has [announced end-of-support](https://github.com/kjur/jsrsasign/blob/master/README.md#end-of-support-announcement-for-jsrsasign). It is used in 14 operations and one library file in CyberChef and covers X.509/CSR/CRL parsing, ECDSA signing/verification, key format conversion, ASN.1/OID utilities, and SM2 elliptic-curve encryption.
Continuing to ship an unmaintained crypto library is a security risk. This plan replaces every jsrsasign call with proven, actively-maintained alternatives, then removes the dependency entirely.
## Library choices
Add these production dependencies, all MIT/BSD-3 (compatible with CyberChef's Apache-2.0 license), all pure-JS, all browser + Node:
| Library | Used for | Why this one |
|---|---|---|
| **[@peculiar/x509](https://github.com/PeculiarVentures/x509)** (^1.14) | X.509 certs, PKCS#10 CSRs, X.509 CRLs, SPKI/PKCS#8 key parsing | Peculiar Ventures specialise in browser PKI. Built on Web Crypto + asn1js. Used by Microsoft, Cloudflare. MIT. |
| **[asn1js](https://github.com/PeculiarVentures/ASN1.js)** (^3) | Generic ASN.1 parse + tree dump for `ParseASN1HexString` | Same author. BSD-3. Pulled in transitively by @peculiar/x509 anyway. |
| **[@noble/curves](https://github.com/paulmillr/noble-curves)** (^2) | ECDSA sign/verify, signature format conversion, key generation, SM2 curve | Audited by Cure53, Trail of Bits, Kudelski. Zero dependencies. Used by MetaMask, Coinbase, Ethereum Foundation. Built-in `@noble/curves/sm2`. Same author as the already-installed `@noble/hashes`. |
| **node-forge** (already a dep) | DSA path inside `PubKeyFromPrivKey` only | Avoids adding another lib just for DSA. |
**Not adopted, with reasons:** `pkijs` (covered by @peculiar/x509); `jose`/panva (overkill for two JWK round-trip ops); `sm-crypto`/`sm-crypto-v2` (@noble/curves/sm2 handles it); native Web Crypto for ECDSA (refuses MD5/SHA-1 digests that the UI exposes, and JWK/format gymnastics outweigh the dependency saving).
**End state:** `npm uninstall jsrsasign`; remove from [package.json](package.json).
## Decisions confirmed with user
- Phased delivery: **6 PRs**, one per phase below.
- Output fidelity: **cosmetic drift accepted** for golden text-output tests (`ParseX509Certificate`, `ParseCSR`, `ParseX509CRL`, `ParseASN1HexString`). Update fixtures, document in CHANGELOG.
- DSA in `PubKeyFromPrivKey`: **keep via node-forge** (no user-visible regression).
- ECDSA signature fixtures: **update to @noble values** if RFC 6979 outputs diverge (signatures remain valid).
## Files to be modified
**14 operations** (all under [src/core/operations/](src/core/operations/)):
- ASN.1 / encoding: `HexToObjectIdentifier.mjs`, `ObjectIdentifierToHex.mjs`, `HexToPEM.mjs`, `ParseASN1HexString.mjs`
- X.509 / CSR / CRL: `ParseX509Certificate.mjs`, `PubKeyFromCert.mjs`, `ParseCSR.mjs`, `ParseX509CRL.mjs`
- Key conversion: `PEMToJWK.mjs`, `JWKToPem.mjs`, `PubKeyFromPrivKey.mjs`
- ECDSA: `ECDSASign.mjs`, `ECDSAVerify.mjs`, `ECDSASignatureConversion.mjs`, `GenerateECDSAKeyPair.mjs`
**Library files:**
- [src/core/lib/SM2.mjs](src/core/lib/SM2.mjs) — full rewrite, drop jsrsasign
- [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs) — extend `formatDnObj` to accept @peculiar/x509's `Name.toJSON()` shape
- **NEW** [src/core/lib/Asn1.mjs](src/core/lib/Asn1.mjs) — `oidHexToInt`, `oidIntToHex`, `derToPem`, `dumpAsn1Hex`
- **NEW** [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) — shared ECDSA helpers (key load, sign/verify, sig format conversions)
**Build/manifest:**
- [package.json](package.json) — add 3 deps in PR 1, remove jsrsasign in PR 6
- `CHANGELOG.md` — note in PR 6
## Phased plan
### PR 1 — Setup + ASN.1 utilities (PublicKey bundle)
**Goal:** Add the three new deps, ship the `Asn1.mjs` helper module, migrate the four utility operations.
1. `npm install --save @peculiar/x509 asn1js @noble/curves`. Confirm webpack builds; smoke-test `npm test`.
2. Create [src/core/lib/Asn1.mjs](src/core/lib/Asn1.mjs):
- `oidHexToInt(hex)` — inline BER OID decode (~25 lines, handles arc0/arc1 combined byte and base-128 multibyte arcs).
- `oidIntToHex(oid)` — inverse.
- `derToPem(hex, label)` — base64 + 64-col wrap with `\n` line endings.
- `dumpAsn1Hex(hex, { truncate, startIndex })` — walks `asn1js.fromBER` output and emits an indented tree.
3. Migrate:
- [HexToObjectIdentifier.mjs](src/core/operations/HexToObjectIdentifier.mjs): `r.KJUR.asn1.ASN1Util.oidHexToInt``oidHexToInt`.
- [ObjectIdentifierToHex.mjs](src/core/operations/ObjectIdentifierToHex.mjs): `r.KJUR.asn1.ASN1Util.oidIntToHex``oidIntToHex`.
- [HexToPEM.mjs](src/core/operations/HexToPEM.mjs): `r.KJUR.asn1.ASN1Util.getPEMStringFromHex``derToPem`.
- [ParseASN1HexString.mjs](src/core/operations/ParseASN1HexString.mjs): `r.ASN1HEX.dump``dumpAsn1Hex`.
4. Tests:
- Add round-trip OID tests (sample OIDs incl. `2.5.4.3`, `1.3.6.1.4.1.311.2.1.4`, edge case with arc > 127).
- Update `ParseASN1HexString` golden output to new tree format (drift allowed).
**Risks:** OID encoding edge cases — verify the `40·a + b` first byte and base-128 arcs.
### PR 2 — SM2 (Ciphers bundle)
**Goal:** Rewrite [src/core/lib/SM2.mjs](src/core/lib/SM2.mjs) on top of `@noble/curves/sm2`.
API mapping:
| jsrsasign | @noble/curves/sm2 |
|---|---|
| `r.crypto.ECParameterDB.regist("sm2p256v1", …)` + `getByName` | `import { sm2 } from "@noble/curves/sm2"` (curve built-in) |
| `r.SecureRandom` + `new r.BigInteger(bits, rng).mod(n-1)+1` | `sm2.utils.randomPrivateKey()` |
| `ecParams.G.multiply(k)` | `sm2.Point.BASE.multiply(k)` (k is `bigint`) |
| `ecParams.curve.decodePointHex("04"+x+y)` | `sm2.Point.fromHex("04"+x+y)` |
| `point.getX().toBigInteger()` / `getY()` | `point.toAffine().x` / `.y` (both `bigint`) |
| `point.isInfinity()` | `point.is0()` (or `equals(sm2.Point.ZERO)`) |
| `new r.BigInteger(hex, 16)` | `BigInt("0x" + hex)` |
**Gotchas:**
- Pad point coords with `.padStart(64, "0")` after `bigint.toString(16)` — existing tests depend on fixed-width hex.
- KDF logic (SM3-based) is already pure-JS; do **not** change it.
- Preserve both ciphertext layouts (GMT 0009 BBB vs GMT 0010 C1C2C3/C1C3C2) — keep current format-string handling.
**Tests:** [tests/operations/tests/SM2.mjs](tests/operations/tests/SM2.mjs) has hard-coded ciphertext→plaintext fixtures. They MUST pass unchanged — they pin cryptographic correctness.
### PR 3 — ECDSA primitives (Ciphers bundle)
**Goal:** Migrate the four ECDSA operations to `@noble/curves` + `@noble/hashes`.
1. Create [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) with shared helpers:
- `loadEcKey(pem)` — parse SEC1 / PKCS#8 / SPKI via `@peculiar/asn1-ecc` + `@peculiar/asn1-pkcs8`; return `{ curve, isPrivate, d?, x, y, jwk }`.
- `signEcdsa(curve, dBytes, digest)`, `verifyEcdsa(curve, qBytes, digest, sigDer)`.
- `asn1SigToConcatHex`, `concatHexToAsn1Sig`, `parseAsn1SigToHexRS`, `hexRSToAsn1Sig` — wrap `Signature.fromBytes(.., 'der'|'compact')` and `.toBytes(format)`.
- `isAsn1Hex(hex)` — try `asn1js.fromBER`.
- `generateEcKeyPair(curveName)``curve.utils.randomPrivateKey()`, build SPKI + PKCS#8 via `@peculiar/asn1-*` schemas, derive JWK.
2. Migrate operations:
- [ECDSASign.mjs](src/core/operations/ECDSASign.mjs): use `loadEcKey` + hash (md5/sha1/sha256/sha384/sha512 from `@noble/hashes`; **md5/sha1 come from `@noble/hashes/legacy`**) + `signEcdsa`. Format conversion via the helpers.
- [ECDSAVerify.mjs](src/core/operations/ECDSAVerify.mjs): `isAsn1Hex` for input-format auto-detect; `verifyEcdsa`.
- [ECDSASignatureConversion.mjs](src/core/operations/ECDSASignatureConversion.mjs): format conversions only — no key handling.
- [GenerateECDSAKeyPair.mjs](src/core/operations/GenerateECDSAKeyPair.mjs): `generateEcKeyPair`; drop the `\r` stripping (was a jsrsasign artefact).
**Gotchas:**
- **MD5/SHA-1 digests**`@noble/hashes/legacy` (the v2 split). Web Crypto cannot do this, which is why we chose `@noble/curves`.
- **P-521 byte length is 66**, not 65 — important for P1363 padding and JWK `x`/`y` encoding.
- **`parseSigHexInHexRS`** historically returns r/s with a leading `00` when the MSB is set (DER 2's-complement artefact). Replicate this — tests depend on it.
- **RFC 6979 determinism** — outputs should match jsrsasign byte-for-byte. If they don't, update fixtures (signatures still valid; document in CHANGELOG).
- **JWK field ordering** for the JWK output of `GenerateECDSAKeyPair`: build the literal as `{kty, crv, x, y, d?}``JSON.stringify` preserves insertion order.
**Tests:** [tests/operations/tests/ECDSA.mjs](tests/operations/tests/ECDSA.mjs) has 83 tests covering all curves × digests × formats. Run; update fixtures where needed.
### PR 4 — PEM/JWK conversion + key extraction (PublicKey bundle)
**Goal:** Migrate [PEMToJWK.mjs](src/core/operations/PEMToJWK.mjs), [JWKToPem.mjs](src/core/operations/JWKToPem.mjs), [PubKeyFromPrivKey.mjs](src/core/operations/PubKeyFromPrivKey.mjs).
API mapping:
| jsrsasign | Replacement |
|---|---|
| `r.KEYUTIL.getKey(pem)` RSA | `node-forge` `pki.privateKeyFromPem` / `publicKeyFromPem` |
| `r.KEYUTIL.getKey(pem)` EC | `loadEcKey` from `Ecdsa.mjs` |
| `r.KEYUTIL.getKey(pem)` DSA | `node-forge` DSA parsing |
| `r.KEYUTIL.getKey(jwk)` | Inline: detect `kty`, dispatch to RSA / EC builders |
| `r.KEYUTIL.getJWKFromKey(key)` | Inline literal `{ kty, …, [d] }` |
| `r.KEYUTIL.getPEM(key)` SPKI | `@peculiar/x509` `PublicKey(spki).toString("pem")` |
| `r.KEYUTIL.getPEM(key, "PKCS8PRV")` | Build PKCS#8 via `@peculiar/asn1-pkcs8`'s `PrivateKeyInfo` |
| `new r.RSAKey().setPublic(n, e)` + getPEM | `node-forge` `pki.setRsaPublicKey(n, e)` + `publicKeyToPem` |
| `new r.KJUR.crypto.ECDSA({curve}).setPublicKeyHex/generatePublicKeyHex` | Derive Q with `curve.getPublicKey(d, false)`; build SPKI |
| `new r.KJUR.crypto.DSA().setPublic(p, q, g, y)` | `node-forge` DSA public key + `publicKeyToPem` |
**Gotchas:**
- JWK field order and PEM line endings (`\n` not `\r\n`) — see PR 3.
- `JWKToPem`'s `PKCS8PRV` output: build via `@peculiar/asn1-pkcs8` + the algorithm-specific key schema (RSA or EC).
- DSA PKCS#8 with no `y`: keep the current "unsupported" throw — test already accepts it.
**Tests:** [tests/operations/tests/JWK.mjs](tests/operations/tests/JWK.mjs), [tests/operations/tests/PubKeyFromPrivKey.mjs](tests/operations/tests/PubKeyFromPrivKey.mjs) — should pass with minor whitespace adjustments.
### PR 5 — X.509 / CSR / CRL parsing (PublicKey bundle)
**Goal:** Migrate the four cert-family operations to `@peculiar/x509`.
1. **Before any code changes:** add a regression test for [ParseX509Certificate.mjs](src/core/operations/ParseX509Certificate.mjs) (currently uncovered — confirm with `ls tests/operations/tests/ | grep -i x509certificate`). Use the existing test certs from [PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs).
2. Extend [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs): adapt `formatDnObj` to accept either the legacy `{array: [[{type, value}]]}` shape OR @peculiar/x509's `JsonName` array shape.
3. Migrate:
**`ParseX509Certificate`:**
| jsrsasign | @peculiar/x509 |
|---|---|
| `new r.X509(); readCertHex/readCertPEM` | `new X509Certificate(input)` (accepts PEM string or BufferSource) |
| `cert.hex` | `bytesToHex(cert.rawData)` |
| `cert.getSerialNumberHex()` | `cert.serialNumber` |
| `cert.getIssuer()` / `getSubject()` | `cert.issuerName.toJSON()` / `cert.subjectName.toJSON()``formatDnObj` |
| `cert.getPublicKey()` | `cert.publicKey`; parse `rawData` with `@peculiar/asn1-rsa` `RSAPublicKey` or `@peculiar/asn1-ecc` `ECPoint` to extract `n`/`e` or `(x, y)` |
| `cert.getSignatureValueHex()` | `bytesToHex(cert.signature)` |
| `cert.version` | `cert.version` |
| `cert.getSignatureAlgorithm*` | `cert.signatureAlgorithm.name` |
| `cert.getNotBefore()` / `getNotAfter()` | `cert.notBefore` / `cert.notAfter` (`Date`) — reformat to the existing yymmddHHMMSSZ string |
| `cert.getInfo()` extensions text | Iterate `cert.extensions`, format known types (BasicConstraints, KeyUsage, EKU, SAN, AKI, SKI, CRLDistributionPoints, AIA) — reuse formatters in current [ParseCSR.mjs](src/core/operations/ParseCSR.mjs) |
| `r.BigInteger` keylen | native `BigInt("0x"+hex)` |
**`PubKeyFromCert`:** `new X509Certificate(pem).publicKey.toString("pem")`.
**`ParseCSR`:**
| jsrsasign | @peculiar/x509 |
|---|---|
| `r.KJUR.asn1.csr.CSRUtil.getParam(input)` | `new Pkcs10CertificateRequest(input)` |
| `csrParam.sbjpubkey` | `csr.publicKey.toString("pem")` |
| `csrParam.sigalg` | `csr.signatureAlgorithm.name` |
| `csrParam.sighex` | `bytesToHex(csr.signature)` |
| `csrParam.extreq` | walk `csr.attributes`, find OID `1.2.840.113549.1.9.14`, decode with `@peculiar/asn1-pkcs9` |
**`ParseX509CRL`:**
| jsrsasign | @peculiar/x509 |
|---|---|
| `new r.X509CRL(input)` | `new X509Crl(input)` (convert hex input → `Uint8Array` first) |
| `crl.getVersion / SignatureAlgorithm* / Issuer / ThisUpdate / NextUpdate` | `crl.version / signatureAlgorithm.name / issuer / thisUpdate / nextUpdate` |
| `crl.getParam().ext` | `crl.extensions` |
| `crl.getRevCertArray()` | `crl.entries` (each has `serialNumber`, `revocationDate`, `extensions`) |
| `crl.getSignatureValueHex()` | `bytesToHex(crl.signature)` |
**Gotchas:**
- `cert.getInfo()` is the trickiest dependency — the current op extracts extensions text by string-splitting on `"X509v3 Extensions:\n"` and `"signature"`. Replace with a real extension walker (reuse `formatGeneralNames`, KeyUsage bit mapping, etc. from existing `ParseCSR.mjs`).
- CRL hex input: convert hex → `Uint8Array` before passing to `X509Crl`.
- DSA-in-cert: rare; use `@peculiar/asn1-x509` DSA params or fall back to a placeholder text block.
- Date formatting: produce both raw `yymmddHHMMSSZ` and a human-readable form, as currently displayed.
**Tests:** Update goldens for [ParseCSR.mjs](tests/operations/tests/ParseCSR.mjs), [ParseX509CRL.mjs](tests/operations/tests/ParseX509CRL.mjs), [PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs) as needed. Add fixtures for the new `ParseX509Certificate` test from step 1.
### PR 6 — Removal
1. `grep -rn jsrsasign src/` — must return zero matches.
2. `npm uninstall jsrsasign`; remove from [package.json](package.json) dependencies.
3. `npm run lint && npm test` — full suite green.
4. `npm run build` — confirm bundle builds; check PublicKey and Ciphers chunk size deltas.
5. Update `CHANGELOG.md`: removed jsrsasign; added @peculiar/x509, asn1js, @noble/curves; note any cosmetic output-format changes in the affected ops.
## Verification
After **each PR:**
- `npm run lint`
- `npm test` (Node + browser test suites)
- `npm run build` succeeds
- `grep -rn "from \"jsrsasign\"" src/core/` shrinks monotonically toward zero
After **PR 6:**
- `grep -rn jsrsasign .` returns zero results outside `package-lock.json` and `CHANGELOG.md`
- Manual UI smoke test: launch `npm start`, try each migrated operation in the browser with representative input
- Check bundle size: `npm run build` and compare `web/assets/*.js` sizes against the pre-migration baseline
## Open follow-ups (not in scope)
- Audit other crypto deps (`crypto-api`, `crypto-js`, `node-forge`) for similar end-of-life risk.
- Consider whether `crypto-api` (used for SM3 in [SM2.mjs](src/core/lib/SM2.mjs)) could also be retired in favour of `@noble/hashes/sm3` in a future cleanup.
## Changelog
Record deviations from the original plan here, newest at the top. One bullet per change: what changed, why, and which PR.
### Post-migration test cleanup — 2026-05-18
- **Ed25519 / Ed448 expected pubkeys in [tests/operations/tests/PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs) cross-verified externally.** Ed25519 matches `openssl x509 -pubkey -noout` byte-for-byte; Ed448 was verified by extracting the SPKI directly from the cert's DER bytes (openssl 3.6.2 in this environment doesn't support Ed448). A short comment above each `ED*_PUBKEY` constant records the verification so the fixtures aren't read as circular snapshots of `@peculiar/x509`'s own output.
- **[tests/node/tests/nodeApi.mjs](tests/node/tests/nodeApi.mjs) recipe-format tests no longer feed `Hex to PEM` garbage.** The three `chef.bake` tests previously piped Morse-coded output (dashes/dots) through `Hex to PEM`, which is the reason PR 1 made `derToPem` lenient about non-hex input. They now use `To Hex``Hex to PEM``To Snake case` and produce well-formed PEM of `"some input"`. Same coverage of compact JSON / clean JSON / optional-args parsing; `derToPem`'s leniency is no longer test-load-bearing (but kept for backwards compatibility).
### PR 6 — 2026-05-17
- Removed `jsrsasign` from [package.json](package.json) and [package-lock.json](package-lock.json). No runtime code changes were needed because PRs 1-5 had already moved operations to `@noble/curves`, `@peculiar/x509`, and `asn1js`.
- Removed stale dependency-name mentions from `src/` comments so `rg -n "jsrsasign" src/` is clean. Remaining mentions are intentionally limited to migration documentation and [CHANGELOG.md](CHANGELOG.md).
- Added a top-level [CHANGELOG.md](CHANGELOG.md) entry noting the removal and the accepted cosmetic output drift across the migrated ASN.1, ECDSA, key conversion, and X.509-family operations.
### PR 5 — 2026-05-17
- New [src/core/lib/X509.mjs](src/core/lib/X509.mjs) holds the shared X.509 helpers (`decodeX509Input`, `sigAlgOidToName`, `describeSpki`, `parseDerEcdsaSignature`, `isDerEcdsaSignature`, `formatJsonName`, `formatGeneralName`, `formatHexByteLines`/`formatHexColonWrapped`, `asnNameToJson`). [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs)'s `formatDnObj` now accepts both the legacy jsrsasign shape and `@peculiar/x509`'s `JsonName` (array-of-records) shape. Migrated ops: [ParseX509Certificate.mjs](src/core/operations/ParseX509Certificate.mjs), [PubKeyFromCert.mjs](src/core/operations/PubKeyFromCert.mjs), [ParseCSR.mjs](src/core/operations/ParseCSR.mjs), [ParseX509CRL.mjs](src/core/operations/ParseX509CRL.mjs).
- **`X509Certificate` v1.14.3 doesn't expose `.version`.** Plan called for `cert.version`, but on the pinned v1 the property is missing. Worked around by parsing `cert.rawData` with the asn1-x509 `Certificate` schema and reading `tbsCertificate.version` + `signatureAlgorithm.algorithm` directly. The same approach is used in `ParseCSR` (with `CertificationRequest` from `@peculiar/asn1-csr`) and `ParseX509CRL` (with `CertificateList` from `@peculiar/asn1-x509`) — bypassing the WebCrypto algorithm mapping is necessary for DSA anyway (peculiar's algorithm provider doesn't know it).
- **DSA bit-length now matches OpenSSL/jsrsasign output.** `describeSpki` strips the leading `00` byte from the DSS-Parms `p` INTEGER before computing the bit length, so `Length: 2048 bits` is reported rather than the 2056 the raw byte count would give.
- **P-521 reports `Length: 521 bits`**, not 528. `EC_CURVE_OID_TO_NAMES` carries an explicit `bits` field per curve so the prime field size (rather than `byteLen * 8`) is reported.
- **KeyUsage rendering order.** `KeyUsage.toJSON()` on the asn1-x509 wrapper returns the flag names in alphabetical order (`crlSign`, `dataEncipherment`, …); the existing CSR golden fixtures expect bit-position order (`digitalSignature`, `nonRepudiation`, `keyEncipherment`, …). The op now parses the BIT STRING and iterates the bits in order so the output is stable.
- **`Public Key from Certificate` now works for Ed25519 / Ed448.** jsrsasign threw "Unsupported public key type"; `@peculiar/x509`'s `cert.publicKey.toString("pem")` handles both. Updated [tests/operations/tests/PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs) — the previously-commented `ED25519_PUBKEY` / `ED448_PUBKEY` constants are live and the two negative test expectations were replaced with the actual PEMs.
- **PEM line endings switched from `\r\n` to `\n` everywhere** (per the cross-PR convention in [AGENTS.md](AGENTS.md)). [tests/operations/tests/PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs) loses its `.replace(/\r/g, "").replace(/\n/g, "\r\n")` post-processing.
- **OtherName payload is parsed loosely.** The legacy `ParseCSR` fixture for `Other: 1.2.3.4::some value` requires extracting a UTF8String wrapped in the ANY-typed `value` field. `formatGeneralName` first tries to parse it as a primitive `UTF8String`/`BmpString`/`PrintableString`/`IA5String`, then falls back to `DirectoryString`, and finally to a hex dump. Same code path serves the CRL OtherName output (with `OtherName:` label, no space — per the CRL flavor) and the CSR/Cert SAN output (`Other: ` label, with space — per the CSR flavor).
- **`ParseX509Certificate` no longer relies on `cert.getInfo()` string-splitting.** The legacy code grabbed the extensions block by splitting on hard-coded delimiters. Replaced with a real walker over `cert.extensions` that recognises BasicConstraints, KeyUsage, EKU, SAN, AKI, SKI, CRLDistributionPoints, IssuerAltName; everything else falls back to `<oid>:` + raw hex.
- **Cosmetic drift in the `Certificate Signature` block for ECDSA certs.** The old code split the DER signature with hard-coded offsets (`r.ASN1HEX.getV(sig, 4)` and `getV(sig, 48)`), which only worked for 32-byte components and produced visibly-overlapping output otherwise. The new code uses the proper DER parser. The output difference is a *correction*, not a regression — but it's still drift, and means the `ParseX509Certificate` golden output now reflects the true r/s values.
- **New regression coverage for `ParseX509Certificate`.** [tests/operations/tests/ParseX509Certificate.mjs](tests/operations/tests/ParseX509Certificate.mjs) carries golden output for an RSA cert and a P-256 cert (reusing the certs from [PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs)), plus an empty-input test. Wired into [tests/operations/index.mjs](tests/operations/index.mjs).
- No new dependencies — everything reuses what PR 1 already pinned.
### 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.
- **`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.
- `Point.fromHex("04" + x + y)` validates that the coords lie on the curve and raises before `is0()` can ever run; the explicit infinity check is kept for symmetry with the previous code. Invalid-point errors are caught and rethrown as `OperationError` so user-facing error messages stay unchanged.
- Coord padding switched from `("0000000000" + ...).slice(-charlen)` to `.padStart(charlen, "0")` per the cross-PR convention in [AGENTS.md](AGENTS.md). Output hex layout is byte-identical to before (still 64-char fields for `sm2p256v1`).
- No fixture changes: [tests/operations/tests/SM2.mjs](tests/operations/tests/SM2.mjs) passes unchanged (the 4 hard-coded ciphertext→plaintext vectors plus the 4 encrypt→decrypt round-trips).
### PR 1 — 2026-05-17
- Pinned `@peculiar/x509` to `^1.14.3` instead of the latest (`2.x`). v2 hard-requires a `reflect-metadata` import at every entry point and the plan didn't budget for polyfilling every webpack chunk. Sticking with v1 keeps the bundle changes scoped to this PR.
- `@noble/curves` installed at `^2.2.0`. v2 no longer exports an `/sm2` subpath — see PR 2 note in "Notes for next session" above.
- `derToPem` was deliberately made lenient (whitespace stripped, odd length left-padded with `0`, non-hex chars treated as nibble `0`) to preserve the recipe-API tests that piped non-hex output from `To Morse Code` through `Hex to PEM`. The actual base64 emitted now follows standard byte-pair semantics, not jsrsasign's quirky `hex2b64` (3-hex-chars-→-2-base64-chars) layout — so the expected outputs in `tests/node/tests/nodeApi.mjs` for those recipe-format tests were regenerated.
- `dumpAsn1Hex` returns a plain `ASN.1 parse error: …` string when asn1js can't make sense of the input, rather than throwing. jsrsasign produced a best-effort `UNKNOWN(<tag>) <bytes>` dump in this case; replicating that on asn1js would be a meaningful chunk of code and the operation has no automated-fixture exposure of the difference, so we accepted the drift.
- PEM line endings switched from `\r\n` to `\n` (per the cross-PR convention in [AGENTS.md](AGENTS.md)). Updated the `Hex to PEM` and `Parse ASN.1 hex string` golden tests in `tests/node/tests/operations.mjs`.
- New per-op fixture file [tests/operations/tests/ASN1.mjs](tests/operations/tests/ASN1.mjs) covers OID round-trips, the multi-byte OID arc edge case, PEM line wrapping, and basic ASN.1 dumps. Wired in via `tests/operations/index.mjs`.
<!-- e.g.
### PR 2 — 2026-05-20
- Added `@noble/hashes` peer-dep bump (was on ^1.x, needed ^2 for the `legacy` subpath). No code impact.
- SM2 ciphertext fixture #3 was already wrong (decoded to the wrong plaintext under jsrsasign too) — confirmed via independent SM2 implementation, regenerated fixture.
-->

50
package-lock.json generated
View File

@ -13,11 +13,14 @@
"@alexaltea/capstone-js": "^3.0.5", "@alexaltea/capstone-js": "^3.0.5",
"@astronautlabs/amf": "^0.0.6", "@astronautlabs/amf": "^0.0.6",
"@blu3r4y/lzma": "^2.3.3", "@blu3r4y/lzma": "^2.3.3",
"@noble/curves": "^2.2.0",
"@noble/hashes": "2.2.0", "@noble/hashes": "2.2.0",
"@peculiar/x509": "^1.14.3",
"@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1",
"@xmldom/xmldom": "^0.8.13", "@xmldom/xmldom": "^0.8.13",
"argon2-browser": "^1.18.0", "argon2-browser": "^1.18.0",
"arrive": "^2.5.3", "arrive": "^2.5.3",
"asn1js": "^3.0.10",
"assert": "^2.1.0", "assert": "^2.1.0",
"avsc": "^5.7.9", "avsc": "^5.7.9",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@ -62,7 +65,6 @@
"jsonpath-plus": "^10.4.0", "jsonpath-plus": "^10.4.0",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"jsrsasign": "^11.1.3",
"kbpgp": "^2.1.17", "kbpgp": "^2.1.17",
"libbzip2-wasm": "0.0.4", "libbzip2-wasm": "0.0.4",
"libyara-wasm": "^1.2.1", "libyara-wasm": "^1.2.1",
@ -4238,6 +4240,21 @@
"archiver": "^5.3.1" "archiver": "^5.3.1"
} }
}, },
"node_modules/@noble/curves": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
@ -4254,7 +4271,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz",
"integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0",
@ -4268,7 +4284,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz",
"integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0",
@ -4281,7 +4296,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz",
"integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0",
@ -4294,7 +4308,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz",
"integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-cms": "^2.6.1",
@ -4309,7 +4322,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz",
"integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0",
@ -4322,7 +4334,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz",
"integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-cms": "^2.6.1",
@ -4339,7 +4350,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz",
"integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0",
@ -4352,7 +4362,6 @@
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asn1js": "^3.0.6", "asn1js": "^3.0.6",
@ -4364,7 +4373,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz",
"integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0",
@ -4377,7 +4385,6 @@
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz",
"integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0",
@ -4390,7 +4397,6 @@
"version": "1.14.3", "version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-cms": "^2.6.0",
@ -5466,14 +5472,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1js": { "node_modules/asn1js": {
"version": "3.0.7", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"pvtsutils": "^1.3.6", "pvtsutils": "^1.3.6",
"pvutils": "^1.1.3", "pvutils": "^1.1.5",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"engines": { "engines": {
@ -12460,12 +12465,6 @@
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/jsrsasign": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.3.tgz",
"integrity": "sha512-nPnK5D/4lv0Dwr7TlzrKtAd8JlLZwFTqTUUB3NQCbtdobcRcohGFxjbPySDVh74iWUudcCsapYT6OxoyhJLhhA==",
"license": "MIT"
},
"node_modules/jszip": { "node_modules/jszip": {
"version": "3.10.1", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@ -15287,7 +15286,6 @@
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.8.1" "tslib": "^2.8.1"
@ -15297,7 +15295,6 @@
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@ -17449,14 +17446,12 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsyringe": { "node_modules/tsyringe": {
"version": "4.10.0", "version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^1.9.3" "tslib": "^1.9.3"
@ -17469,7 +17464,6 @@
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tweetnacl": { "node_modules/tweetnacl": {

View File

@ -97,11 +97,14 @@
"@alexaltea/capstone-js": "^3.0.5", "@alexaltea/capstone-js": "^3.0.5",
"@astronautlabs/amf": "^0.0.6", "@astronautlabs/amf": "^0.0.6",
"@blu3r4y/lzma": "^2.3.3", "@blu3r4y/lzma": "^2.3.3",
"@noble/curves": "^2.2.0",
"@noble/hashes": "2.2.0", "@noble/hashes": "2.2.0",
"@peculiar/x509": "^1.14.3",
"@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1",
"@xmldom/xmldom": "^0.8.13", "@xmldom/xmldom": "^0.8.13",
"argon2-browser": "^1.18.0", "argon2-browser": "^1.18.0",
"arrive": "^2.5.3", "arrive": "^2.5.3",
"asn1js": "^3.0.10",
"assert": "^2.1.0", "assert": "^2.1.0",
"avsc": "^5.7.9", "avsc": "^5.7.9",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@ -146,7 +149,6 @@
"jsonpath-plus": "^10.4.0", "jsonpath-plus": "^10.4.0",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"jsrsasign": "^11.1.3",
"kbpgp": "^2.1.17", "kbpgp": "^2.1.17",
"libbzip2-wasm": "0.0.4", "libbzip2-wasm": "0.0.4",
"libyara-wasm": "^1.2.1", "libyara-wasm": "^1.2.1",

349
src/core/lib/Asn1.mjs Normal file
View File

@ -0,0 +1,349 @@
/**
* ASN.1 / OID / PEM helpers.
*
* Helpers used by the HexToObjectIdentifier, ObjectIdentifierToHex,
* HexToPEM and ParseASN1HexString operations.
*
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2016
* @license Apache-2.0
*/
import { fromBER } from "asn1js";
import OperationError from "../errors/OperationError.mjs";
/**
* Convert a BER-encoded OID (as a hex string) to its dotted-decimal form.
*
* @param {string} hex
* @returns {string}
*/
export function oidHexToInt(hex) {
const cleaned = hex.replace(/\s/g, "").toLowerCase();
if (cleaned.length === 0 || cleaned.length % 2 !== 0 || !/^[0-9a-f]+$/.test(cleaned)) {
throw new OperationError("Invalid hex string");
}
const bytes = new Uint8Array(cleaned.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(cleaned.substr(i * 2, 2), 16);
}
const varints = [];
let value = 0n;
for (const b of bytes) {
value = (value << 7n) | BigInt(b & 0x7f);
if ((b & 0x80) === 0) {
varints.push(value);
value = 0n;
}
}
if (value !== 0n) {
throw new OperationError("Malformed OID: ends with continuation byte");
}
if (varints.length === 0) {
throw new OperationError("Empty OID");
}
const arcs = [];
const first = varints[0];
if (first < 40n) {
arcs.push("0", first.toString());
} else if (first < 80n) {
arcs.push("1", (first - 40n).toString());
} else {
arcs.push("2", (first - 80n).toString());
}
for (let i = 1; i < varints.length; i++) {
arcs.push(varints[i].toString());
}
return arcs.join(".");
}
/**
* Convert a dotted-decimal OID to its BER hex encoding.
*
* @param {string} oid
* @returns {string}
*/
export function oidIntToHex(oid) {
if (typeof oid !== "string" || oid.length === 0) {
throw new OperationError("Empty OID");
}
const arcs = oid.split(".").map(a => {
if (!/^\d+$/.test(a)) {
throw new OperationError(`Invalid OID arc: ${a}`);
}
return BigInt(a);
});
if (arcs.length < 2) {
throw new OperationError("OID must have at least two arcs");
}
if (arcs[0] > 2n) {
throw new OperationError("First arc must be 0, 1 or 2");
}
if (arcs[0] < 2n && arcs[1] > 39n) {
throw new OperationError("Second arc must be ≤ 39 when first arc is 0 or 1");
}
const values = [arcs[0] * 40n + arcs[1], ...arcs.slice(2)];
const out = [];
for (const v of values) {
if (v === 0n) {
out.push(0);
continue;
}
const parts = [];
let n = v;
while (n > 0n) {
parts.unshift(Number(n & 0x7fn));
n >>= 7n;
}
for (let i = 0; i < parts.length - 1; i++) parts[i] |= 0x80;
out.push(...parts);
}
return out.map(b => b.toString(16).padStart(2, "0")).join("");
}
/**
* Wrap a hex-encoded DER blob in a PEM envelope.
*
* Uses LF line endings only.
*
* Input parsing is intentionally lenient: whitespace is stripped, an
* odd-length string is left-padded with a zero, and characters that are not
* hex digits are treated as the nibble `0`. This keeps the operation usable
* as a generic byte-emitter inside larger recipes where the upstream stage
* may not produce strict hex.
*
* @param {string} hex
* @param {string} label
* @returns {string}
*/
export function derToPem(hex, label) {
let cleaned = hex.replace(/\s/g, "");
if (cleaned.length % 2 !== 0) cleaned = "0" + cleaned;
const bytes = new Uint8Array(cleaned.length / 2);
for (let i = 0; i < bytes.length; i++) {
const v = parseInt(cleaned.substr(i * 2, 2), 16);
bytes[i] = Number.isNaN(v) ? 0 : v;
}
return derBytesToPem(bytes, label);
}
/**
* Wrap raw DER bytes in a PEM envelope with LF line endings.
*
* Canonical helper shared by the key/ECDSA conversion modules; the hex-input
* {@link derToPem} above delegates here after parsing.
*
* @param {Uint8Array} bytes
* @param {string} label
* @returns {string}
*/
export function derBytesToPem(bytes, label) {
let b64;
if (typeof Buffer !== "undefined") {
b64 = Buffer.from(bytes).toString("base64");
} else {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
b64 = btoa(bin);
}
const lines = b64.length === 0 ? [""] : b64.match(/.{1,64}/g);
return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`;
}
/**
* Walk an asn1js parse tree and produce CyberChef's indented ASN.1 dump.
*
* @param {string} hex
* @param {Object} [options]
* @param {number} [options.truncate=32] - max bytes of an OCTET/BIT/printable value shown before truncating
* @param {number} [options.startIndex=0] - hex-character offset to start parsing at
* @returns {string}
*/
export function dumpAsn1Hex(hex, options = {}) {
const truncate = options.truncate ?? 32;
const startIndex = options.startIndex ?? 0;
let cleaned = hex.replace(/\s/g, "").toLowerCase();
if (cleaned.length % 2 !== 0) cleaned = "0" + cleaned;
const slice = cleaned.slice(startIndex);
const bytes = new Uint8Array(slice.length / 2);
for (let i = 0; i < bytes.length; i++) {
const v = parseInt(slice.substr(i * 2, 2), 16);
bytes[i] = Number.isNaN(v) ? 0 : v;
}
const result = fromBER(bytes.buffer);
const lines = [];
if (result.offset === -1 || !result.result || (result.result.error && !result.result.idBlock)) {
return `ASN.1 parse error: ${(result.result && result.result.error) || "unknown"}`;
}
formatAsn1Node(result.result, 0, truncate, lines);
if (result.offset === -1 && result.result && result.result.error) {
lines.push(`(parse warning: ${result.result.error})`);
}
return lines.join("\n");
}
/**
* Recursively format one asn1js node.
*
* @param {Object} node
* @param {number} depth
* @param {number} truncate
* @param {string[]} out
*/
function formatAsn1Node(node, depth, truncate, out) {
const pad = " ".repeat(depth);
const idBlock = node.idBlock || {};
const valueBlock = node.valueBlock || {};
const tagClass = idBlock.tagClass;
const tagNumber = idBlock.tagNumber;
const isConstructed = !!idBlock.isConstructed;
if (tagClass !== 1) {
const className = ["", "UNIVERSAL", "APPLICATION", "CONTEXT", "PRIVATE"][tagClass] || "UNKNOWN";
const label = `[${className} ${tagNumber}]${isConstructed ? " (constructed)" : ""}`;
if (isConstructed && Array.isArray(valueBlock.value)) {
out.push(`${pad}${label}`);
for (const child of valueBlock.value) formatAsn1Node(child, depth + 1, truncate, out);
} else {
out.push(`${pad}${label} ${truncateHex(extractHex(valueBlock), truncate)}`);
}
return;
}
const ctorName = node.constructor && node.constructor.name;
switch (ctorName) {
case "Sequence":
out.push(`${pad}SEQUENCE`);
for (const child of valueBlock.value || []) formatAsn1Node(child, depth + 1, truncate, out);
return;
case "Set":
out.push(`${pad}SET`);
for (const child of valueBlock.value || []) formatAsn1Node(child, depth + 1, truncate, out);
return;
case "Null":
out.push(`${pad}NULL`);
return;
case "Boolean":
out.push(`${pad}BOOLEAN ${valueBlock.value ? "TRUE" : "FALSE"}`);
return;
case "Integer":
out.push(`${pad}INTEGER ${formatIntegerValue(valueBlock)}`);
return;
case "ObjectIdentifier":
out.push(`${pad}ObjectIdentifier ${valueBlock.toJSON().value}`);
return;
case "OctetString":
out.push(`${pad}OCTET STRING ${truncateHex(extractHex(valueBlock), truncate)}`);
return;
case "BitString":
out.push(`${pad}BIT STRING ${truncateHex(extractHex(valueBlock), truncate)}`);
return;
case "Utf8String":
case "PrintableString":
case "Ia5String":
case "IA5String":
case "VisibleString":
case "TeletexString":
case "UniversalString":
case "BmpString":
case "BMPString":
case "NumericString":
case "GeneralString":
case "CharacterString":
case "GraphicString":
case "VideotexString":
out.push(`${pad}${ctorName} "${truncateText(valueBlock.value || "", truncate)}"`);
return;
case "UTCTime":
case "GeneralizedTime":
out.push(`${pad}${ctorName} ${valueBlock.toString ? node.toString() : truncateHex(extractHex(valueBlock), truncate)}`);
return;
default: {
const label = ctorName || `UNIVERSAL ${tagNumber}`;
if (isConstructed && Array.isArray(valueBlock.value)) {
out.push(`${pad}${label}`);
for (const child of valueBlock.value) formatAsn1Node(child, depth + 1, truncate, out);
} else {
out.push(`${pad}${label} ${truncateHex(extractHex(valueBlock), truncate)}`);
}
}
}
}
/**
* Extract the hex representation of an asn1js value block.
*
* @param {Object} valueBlock
* @returns {string}
*/
function extractHex(valueBlock) {
if (!valueBlock) return "";
const view = valueBlock.valueHexView;
if (view && view.length) return bufToHex(view);
return "";
}
/**
* Convert a Uint8Array view to a lowercase hex string.
*
* @param {Uint8Array} buf
* @returns {string}
*/
function bufToHex(buf) {
let out = "";
for (const b of buf) out += b.toString(16).padStart(2, "0");
return out;
}
/**
* Truncate hex string to at most `truncate` bytes, appending an ellipsis marker.
*
* @param {string} hex
* @param {number} truncate
* @returns {string}
*/
function truncateHex(hex, truncate) {
const maxChars = truncate * 2;
if (maxChars > 0 && hex.length > maxChars) {
return `${hex.slice(0, maxChars)}... (${hex.length / 2} bytes)`;
}
return hex;
}
/**
* Truncate a text string to at most `truncate` characters.
*
* @param {string} text
* @param {number} truncate
* @returns {string}
*/
function truncateText(text, truncate) {
if (truncate > 0 && text.length > truncate) {
return `${text.slice(0, truncate)}... (${text.length} chars)`;
}
return text;
}
/**
* Format an Integer's value. asn1js exposes a small int as `valueDec`; for
* arbitrary-length ints we fall back to the raw hex view.
*
* @param {Object} valueBlock
* @returns {string}
*/
function formatIntegerValue(valueBlock) {
if (valueBlock.isHexOnly) return extractHex(valueBlock);
if (typeof valueBlock.valueDec === "number" && Number.isFinite(valueBlock.valueDec)) {
return valueBlock.valueDec.toString();
}
return extractHex(valueBlock);
}

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

@ -0,0 +1,553 @@
/**
* Shared ECDSA helpers built on @noble/curves and @peculiar/asn1-*.
*
* Used by the ECDSA Sign/Verify/Signature Conversion/Generate Key Pair
* operations.
*
* @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 { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { AsnParser, AsnSerializer, OctetString } from "@peculiar/asn1-schema";
import { derBytesToPem } from "./Asn1.mjs";
import * as ecc from "@peculiar/asn1-ecc";
const { ECPrivateKey, ECParameters } = ecc;
const ID_EC_PUBLIC_KEY = ecc.id_ecPublicKey;
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 preserves the operation's byte-string
* semantics for inputs coming out of upstream byte-producing ops, including
* the questionable UTF-16 truncation 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.
*
* @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) because the raw-signature fixtures
* assert the INTEGER byte strings, not canonicalized scalar values.
*
* @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 derBytesToPem(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 derBytesToPem(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 };
}

701
src/core/lib/KeyConvert.mjs Normal file
View File

@ -0,0 +1,701 @@
/**
* 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.
*
* @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 { toBase64, fromBase64 } from "./Base64.mjs";
import { derBytesToPem } from "./Asn1.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;
// ----- 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).
*
* The JWK fields are marshalled by hand rather than via a library: the
* conversion spans both RSA (including the CRT private params p/q/dp/dq/qi)
* and EC keys, and no current dependency covers both. `@noble/curves` is
* EC-only, and Web Crypto's JWK export is async and unavailable for the
* MD5/SHA-1 code paths this module supports. Adding a JOSE library purely
* for this base64url field mapping would enlarge the dependency surface for
* no real benefit. The per-field encoding is delegated to {@link b64url}.
*
* @param {object} info
* @returns {object}
*/
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 the private x value, with p/q/g in the
// algorithm parameters. It does not carry the public y value needed
// here, so 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(derBytesToPem(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(derBytesToPem(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 derBytesToPem(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 derBytesToPem(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 derBytesToPem(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),
};
}
/**
* 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);
return toBase64(bytes, "A-Za-z0-9-_");
}
/**
* Decode a base64url-encoded string to bytes. Tolerates standard base64
* padding if present.
*
* @param {string} str
* @returns {Uint8Array}
*/
export function b64urlToBytes(str) {
return new Uint8Array(fromBase64(str, "A-Za-z0-9-_", "byteArray"));
}
/**
* 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));
}

View File

@ -11,28 +11,38 @@ import { toHex, fromHex } from "./Hex.mjs";
/** /**
* Formats Distinguished Name (DN) objects to strings. * Formats Distinguished Name (DN) objects to strings.
* *
* @param {Object} dnObj * Accepts either the legacy `{ array: [[{type, value}, ...], ...] }` shape
* OR `@peculiar/x509`'s `JsonName` shape an array of records keyed by
* RDN short-name (`[{ CN: ["foo"], OU: ["bar"] }, ...]`).
*
* @param {Object|Array} dnObj
* @param {number} indent * @param {number} indent
* @returns {string} * @returns {string}
*/ */
export function formatDnObj(dnObj, indent) { export function formatDnObj(dnObj, indent) {
let output = ""; const rows = [];
const maxKeyLen = dnObj.array.reduce((max, item) => { if (Array.isArray(dnObj)) {
return item[0].type.length > max ? item[0].type.length : max; for (const rdn of dnObj) {
}, 0); for (const key of Object.keys(rdn)) {
for (const value of rdn[key]) rows.push({ key, value });
for (let i = 0; i < dnObj.array.length; i++) { }
if (!dnObj.array[i].length) continue; }
} else if (dnObj && Array.isArray(dnObj.array)) {
const key = dnObj.array[i][0].type; for (const rdn of dnObj.array) {
const value = dnObj.array[i][0].value; if (!rdn || !rdn.length) continue;
const str = `${key.padEnd(maxKeyLen, " ")} = ${value}\n`; rows.push({ key: rdn[0].type, value: rdn[0].value });
}
output += str.padStart(indent + str.length, " "); } else {
return "";
} }
return output.slice(0, -1); if (rows.length === 0) return "";
const maxKeyLen = rows.reduce((max, r) => Math.max(max, r.key.length), 0);
const pad = " ".repeat(indent);
return rows.map(({ key, value }) => `${pad}${key.padEnd(maxKeyLen, " ")} = ${value}`).join("\n");
} }

View File

@ -9,39 +9,77 @@ import OperationError from "../errors/OperationError.mjs";
import { fromHex } from "../lib/Hex.mjs"; import { fromHex } from "../lib/Hex.mjs";
import Utils from "../Utils.mjs"; import Utils from "../Utils.mjs";
import Sm3 from "crypto-api/src/hasher/sm3.mjs"; import Sm3 from "crypto-api/src/hasher/sm3.mjs";
import {toHex} from "crypto-api/src/encoder/hex.mjs"; import { toHex } from "crypto-api/src/encoder/hex.mjs";
import r from "jsrsasign"; import { weierstrass, ecdh } from "@noble/curves/abstract/weierstrass.js";
import { bytesToNumberBE } from "@noble/curves/utils.js";
// SM2 curve parameter sets. The Weierstrass `Point` ctor is built lazily on
// first use and memoised across SM2 instances.
const SM2_CURVES = {
// GM/T 0003-2012 / sm2p256v1 — p = 2^256 - 2^224 - 2^96 + 2^64 - 1
sm2p256v1: {
p: BigInt("0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF"),
n: BigInt("0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123"),
h: BigInt(1),
a: BigInt("0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC"),
b: BigInt("0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93"),
Gx: BigInt("0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7"),
Gy: BigInt("0xBC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0"),
coordCharLen: 64,
},
};
const curveCache = {};
/**
* Resolve a named SM2 curve to its Point constructor, order, hex width and
* random-scalar helper. Builds the underlying Weierstrass curve lazily.
*
* @param {string} name
* @returns {{Point: Function, n: bigint, coordCharLen: number, randomScalar: () => bigint}}
*/
function getCurve(name) {
if (!Object.prototype.hasOwnProperty.call(SM2_CURVES, name)) {
throw new OperationError(`Unsupported SM2 curve: ${name}`);
}
if (curveCache[name]) return curveCache[name];
const params = SM2_CURVES[name];
const Point = weierstrass({
p: params.p,
n: params.n,
h: params.h,
a: params.a,
b: params.b,
Gx: params.Gx,
Gy: params.Gy,
});
const dh = ecdh(Point);
const cached = {
Point,
n: params.n,
coordCharLen: params.coordCharLen,
// Uniform-ish random scalar in [1, n-1]. The modulo reduction keeps
// the existing scalar-generation distribution for SM2 ciphertexts.
randomScalar: () => bytesToNumberBE(dh.utils.randomSecretKey()) % (params.n - 1n) + 1n,
};
curveCache[name] = cached;
return cached;
}
/** /**
* SM2 Class for encryption and decryption operations * SM2 Class for encryption and decryption operations
*/ */
export class SM2 { export class SM2 {
/** /**
* Constructor for SM2 class; sets up with the curve and the output format as specified in user args * @param {string} curve - named SM2 curve (e.g. "sm2p256v1")
* * @param {string} format - "C1C3C2" or "C1C2C3"
* @param {*} curve
* @param {*} format
*/ */
constructor(curve, format) { constructor(curve, format) {
this.ecParams = null; const c = getCurve(curve);
this.rng = new r.SecureRandom(); this.Point = c.Point;
/* this.n = c.n;
For any additional curve definitions utilized by SM2, add another block like the below for that curve, then add the curve name to the Curve selection dropdown this.coordCharLen = c.coordCharLen;
*/ this.randomScalar = c.randomScalar;
r.crypto.ECParameterDB.regist(
"sm2p256v1", // name / p = 2**256 - 2**224 - 2**96 + 2**64 - 1
256,
"FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF", // p
"FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC", // a
"28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93", // b
"FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123", // n
"1", // h
"32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7", // gx
"BC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0", // gy
[]
); // alias
this.ecParams = r.crypto.ECParameterDB.getByName(curve);
this.format = format; this.format = format;
} }
@ -52,13 +90,12 @@ export class SM2 {
* @param {string} publicKeyY * @param {string} publicKeyY
*/ */
setPublicKey(publicKeyX, publicKeyY) { setPublicKey(publicKeyX, publicKeyY) {
/* try {
* TODO: This needs some additional length validation; and checking for errors in the decoding process this.publicKey = this.Point.fromHex("04" + publicKeyX + publicKeyY);
* TODO: Can probably support other public key encoding methods here as well in the future } catch (e) {
*/ throw new OperationError("Invalid Public Key");
this.publicKey = this.ecParams.curve.decodePointHex("04" + publicKeyX + publicKeyY); }
if (this.publicKey.is0()) {
if (this.publicKey.isInfinity()) {
throw new OperationError("Invalid Public Key"); throw new OperationError("Invalid Public Key");
} }
} }
@ -66,28 +103,26 @@ export class SM2 {
/** /**
* Set the private key value for the SM2 class * Set the private key value for the SM2 class
* *
* @param {string} privateKey * @param {string} privateKeyHex
*/ */
setPrivateKey(privateKeyHex) { setPrivateKey(privateKeyHex) {
this.privateKey = new r.BigInteger(privateKeyHex, 16); this.privateKey = BigInt("0x" + privateKeyHex);
} }
/** /**
* Main encryption function; takes user input, processes encryption and returns the result in hex (with the components arranged as configured by the user args) * Main encryption function; takes user input, processes encryption and returns the result in hex (with the components arranged as configured by the user args)
* *
* @param {*} input * @param {Uint8Array} input
* @returns {string} * @returns {string}
*/ */
encrypt(input) { encrypt(input) {
const G = this.ecParams.G;
/* /*
* Compute a new, random public key along the same elliptic curve to form the starting point for our encryption process (record the resulting X and Y as hex to provide as part of the operation output) * Compute a new, random public key along the same elliptic curve to form the starting point for our encryption process (record the resulting X and Y as hex to provide as part of the operation output)
* k: Randomly generated BigInteger * k: Randomly generated bigint in [1, n-1]
* c1: Result of dotting our curve generator point `G` with the value of `k` * c1: Result of dotting our curve generator point with the value of `k`
*/ */
const k = this.generatePublicKey(); const k = this.randomScalar();
const c1 = G.multiply(k); const c1 = this.Point.BASE.multiply(k);
const [hexC1X, hexC1Y] = this.getPointAsHex(c1); const [hexC1X, hexC1Y] = this.getPointAsHex(c1);
/* /*
@ -101,7 +136,7 @@ export class SM2 {
const c3 = this.c3(p2, input); const c3 = this.c3(p2, input);
/* /*
* Genreate a proper length encryption key, XOR iteratively, and convert newly encrypted data to hex * Generate a proper length encryption key, XOR iteratively, and convert newly encrypted data to hex
*/ */
const key = this.kdf(p2, input.byteLength); const key = this.kdf(p2, input.byteLength);
for (let i = 0; i < input.byteLength; i++) { for (let i = 0; i < input.byteLength; i++) {
@ -118,10 +153,12 @@ export class SM2 {
return hexC1X + hexC1Y + c2 + c3; return hexC1X + hexC1Y + c2 + c3;
} }
} }
/** /**
* Function to decrypt an SM2 encrypted message * Function to decrypt an SM2 encrypted message
* *
* @param {*} input * @param {string} input
* @returns {ArrayBuffer}
*/ */
decrypt(input) { decrypt(input) {
const c1X = input.slice(0, 64); const c1X = input.slice(0, 64);
@ -138,7 +175,13 @@ export class SM2 {
c3 = input.slice(-64); c3 = input.slice(-64);
} }
c2 = Uint8Array.from(fromHex(c2)); c2 = Uint8Array.from(fromHex(c2));
const c1 = this.ecParams.curve.decodePointHex("04" + c1X + c1Y);
let c1;
try {
c1 = this.Point.fromHex("04" + c1X + c1Y);
} catch (e) {
throw new OperationError("Decryption Error -- Invalid Ciphertext Point");
}
/* /*
* Compute the p2 (secret) value by taking the C1 point provided in the encrypted package, and multiplying by the private k value * Compute the p2 (secret) value by taking the C1 point provided in the encrypted package, and multiplying by the private k value
@ -162,36 +205,11 @@ export class SM2 {
} }
} }
/**
* Generates a large random number
*
* @param {*} limit
* @returns
*/
getBigRandom(limit) {
return new r.BigInteger(limit.bitLength(), this.rng)
.mod(limit.subtract(r.BigInteger.ONE))
.add(r.BigInteger.ONE);
}
/**
* Helper function for generating a large random K number; utilized for generating our initial C1 point
* TODO: Do we need to do any sort of validation on the resulting k values?
*
* @returns {BigInteger}
*/
generatePublicKey() {
const n = this.ecParams.n;
const k = this.getBigRandom(n);
return k;
}
/** /**
* SM2 Key Derivation Function (KDF); Takes P2 point, and generates a key material stream large enough to encrypt all of the input data * SM2 Key Derivation Function (KDF); Takes P2 point, and generates a key material stream large enough to encrypt all of the input data
* *
* @param {*} p2 * @param {WeierstrassPoint} p2
* @param {*} len * @param {number} len
* @returns {string} * @returns {string}
*/ */
kdf(p2, len) { kdf(p2, len) {
@ -214,8 +232,8 @@ export class SM2 {
/** /**
* Calculates the C3 component of our final encrypted payload; which is the SM3 hash of the P2 point and the original, unencrypted input data * Calculates the C3 component of our final encrypted payload; which is the SM3 hash of the P2 point and the original, unencrypted input data
* *
* @param {*} p2 * @param {WeierstrassPoint} p2
* @param {*} input * @param {Uint8Array} input
* @returns {string} * @returns {string}
*/ */
c3(p2, input) { c3(p2, input) {
@ -224,13 +242,12 @@ export class SM2 {
const overall = fromHex(hX).concat(Array.from(input)).concat(fromHex(hY)); const overall = fromHex(hX).concat(Array.from(input)).concat(fromHex(hY));
return toHex(this.sm3(overall)); return toHex(this.sm3(overall));
} }
/** /**
* SM3 setup helper function; takes input data as an array, processes the hash and returns the result * SM3 setup helper function; takes input data as an array, processes the hash and returns the result
* *
* @param {*} data * @param {number[]} data
* @returns {string} * @returns {string}
*/ */
sm3(data) { sm3(data) {
@ -241,18 +258,16 @@ export class SM2 {
} }
/** /**
* Utility function, returns an elliptic curve points X and Y values as hex; * Utility function, returns an elliptic curve point's X and Y values as fixed-width hex
* *
* @param {EcPointFp} point * @param {WeierstrassPoint} point
* @returns {[]} * @returns {[string, string]}
*/ */
getPointAsHex(point) { getPointAsHex(point) {
const biX = point.getX().toBigInteger(); const { x, y } = point.toAffine();
const biY = point.getY().toBigInteger(); const charlen = this.coordCharLen;
const hX = x.toString(16).padStart(charlen, "0");
const charlen = this.ecParams.keycharlen; const hY = y.toString(16).padStart(charlen, "0");
const hX = ("0000000000" + biX.toString(16)).slice(- charlen);
const hY = ("0000000000" + biY.toString(16)).slice(- charlen);
return [hX, hY]; return [hX, hY];
} }
} }

505
src/core/lib/X509.mjs Normal file
View File

@ -0,0 +1,505 @@
/**
* Shared X.509 / CSR / CRL helpers built on @peculiar/x509 + @peculiar/asn1-*.
*
* Used by ParseX509Certificate / PubKeyFromCert / ParseCSR / ParseX509CRL.
*
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2016
* @license Apache-2.0
*/
import { AsnParser } from "@peculiar/asn1-schema";
import * as asn1X509 from "@peculiar/asn1-x509";
import * as ecc from "@peculiar/asn1-ecc";
import * as rsaSchemas from "@peculiar/asn1-rsa";
import { fromBER, Utf8String, BmpString, PrintableString, IA5String } from "asn1js";
import { bytesToHex } from "@noble/hashes/utils.js";
import OperationError from "../errors/OperationError.mjs";
import { fromBase64 } from "./Base64.mjs";
import { fromHex } from "./Hex.mjs";
import Utils from "../Utils.mjs";
const { SubjectPublicKeyInfo, DirectoryString } = asn1X509;
const { ECParameters } = ecc;
const { RSAPublicKey } = rsaSchemas;
const ID_RSA_ENCRYPTION = "1.2.840.113549.1.1.1";
const ID_EC_PUBLIC_KEY = ecc.id_ecPublicKey;
const ID_DSA = "1.2.840.10040.4.1";
const ID_ED25519 = "1.3.101.112";
const ID_ED448 = "1.3.101.113";
const SIG_ALG_OID_TO_NAME = {
"1.2.840.113549.1.1.4": "MD5withRSA",
"1.2.840.113549.1.1.5": "SHA1withRSA",
"1.2.840.113549.1.1.11": "SHA256withRSA",
"1.2.840.113549.1.1.12": "SHA384withRSA",
"1.2.840.113549.1.1.13": "SHA512withRSA",
"1.2.840.113549.1.1.14": "SHA224withRSA",
"1.2.840.113549.1.1.10": "SHA256withRSAandMGF1",
"1.2.840.10045.4.1": "SHA1withECDSA",
"1.2.840.10045.4.3.1": "SHA224withECDSA",
"1.2.840.10045.4.3.2": "SHA256withECDSA",
"1.2.840.10045.4.3.3": "SHA384withECDSA",
"1.2.840.10045.4.3.4": "SHA512withECDSA",
"1.2.840.10040.4.3": "SHA1withDSA",
"2.16.840.1.101.3.4.3.1": "SHA224withDSA",
"2.16.840.1.101.3.4.3.2": "SHA256withDSA",
"2.16.840.1.101.3.4.3.3": "SHA384withDSA",
"2.16.840.1.101.3.4.3.4": "SHA512withDSA",
[ID_ED25519]: "Ed25519",
[ID_ED448]: "Ed448",
};
const EC_CURVE_OID_TO_NAMES = {
"1.2.840.10045.3.1.1": { asn1: "secp192r1", nist: "P-192", byteLen: 24, bits: 192 },
"1.3.132.0.33": { asn1: "secp224r1", nist: "P-224", byteLen: 28, bits: 224 },
"1.2.840.10045.3.1.7": { asn1: "secp256r1", nist: "P-256", byteLen: 32, bits: 256 },
"1.3.132.0.34": { asn1: "secp384r1", nist: "P-384", byteLen: 48, bits: 384 },
"1.3.132.0.35": { asn1: "secp521r1", nist: "P-521", byteLen: 66, bits: 521 },
};
const OID_TO_SHORT_NAME = {
"2.5.4.3": "CN",
"2.5.4.4": "SN",
"2.5.4.5": "serialNumber",
"2.5.4.6": "C",
"2.5.4.7": "L",
"2.5.4.8": "ST",
"2.5.4.9": "street",
"2.5.4.10": "O",
"2.5.4.11": "OU",
"2.5.4.12": "T",
"2.5.4.42": "G",
"2.5.4.43": "I",
"2.5.4.44": "generationQualifier",
"2.5.4.45": "x500UniqueIdentifier",
"2.5.4.46": "dnQualifier",
"2.5.4.65": "pseudonym",
"1.2.840.113549.1.9.1": "E",
"0.9.2342.19200300.100.1.25": "DC",
"0.9.2342.19200300.100.1.1": "UID",
};
// ----- input decoding -------------------------------------------------------
/**
* Decode the X.509 input string in the requested wire format into a Uint8Array
* of DER bytes. Accepts "PEM", "DER Hex", "Base64", "Raw".
*
* @param {string} input
* @param {string} format
* @returns {Uint8Array}
*/
export function decodeX509Input(input, format) {
switch (format) {
case "PEM": {
const stripped = input
.replace(/-----BEGIN [^-]+-----/g, "")
.replace(/-----END [^-]+-----/g, "")
.replace(/\s+/g, "");
return new Uint8Array(fromBase64(stripped, null, "byteArray"));
}
case "DER Hex":
return new Uint8Array(fromHex(input.replace(/\s/g, "")));
case "Base64":
return new Uint8Array(fromBase64(input, null, "byteArray"));
case "Raw":
return new Uint8Array(Utils.strToArrayBuffer(input));
default:
throw new OperationError(`Undefined input format: ${format}`);
}
}
// ----- signature algorithm --------------------------------------------------
/**
* Map a signature-algorithm OID to the compact display name used by these
* operations (e.g. "1.2.840.113549.1.1.11" -> "SHA256withRSA"). Falls back
* to the raw OID if unknown.
*
* @param {string} oid
* @returns {string}
*/
export function sigAlgOidToName(oid) {
return SIG_ALG_OID_TO_NAME[oid] || oid;
}
// ----- public key info extraction -------------------------------------------
/**
* Decode a SubjectPublicKeyInfo DER blob and return a normalised description.
*
* The return shape varies by algorithm:
* RSA: { type: "RSA", nHex, eValue, bitLength }
* EC: { type: "EC", curveOid, asn1Curve, nistCurve, pubKeyHex, bitLength, x, y }
* DSA: { type: "DSA", yHex, pHex, qHex, gHex, bitLength }
* Ed25519/Ed448: { type: "EdDSA", curveName, pubKeyHex }
* Other: { type: "Unknown", algorithm }
*
* @param {Uint8Array} spkiBytes
* @returns {object}
*/
export function describeSpki(spkiBytes) {
const spki = AsnParser.parse(spkiBytes, SubjectPublicKeyInfo);
const alg = spki.algorithm.algorithm;
const keyBytes = new Uint8Array(spki.subjectPublicKey);
if (alg === ID_RSA_ENCRYPTION) {
const rsa = AsnParser.parse(keyBytes, RSAPublicKey);
const n = stripDerLeadingZero(new Uint8Array(rsa.modulus));
const e = stripDerLeadingZero(new Uint8Array(rsa.publicExponent));
return {
type: "RSA",
nHex: bytesToHex(n),
eValue: Number(BigInt("0x" + (bytesToHex(e) || "0"))),
bitLength: n.length === 0 ? 0 : ((n.length - 1) * 8) + (32 - Math.clz32(n[0])),
};
}
if (alg === ID_EC_PUBLIC_KEY) {
if (!spki.algorithm.parameters) throw new OperationError("EC SubjectPublicKeyInfo missing parameters");
const params = AsnParser.parse(new Uint8Array(spki.algorithm.parameters), ECParameters);
const curveOid = params.namedCurve;
const info = EC_CURVE_OID_TO_NAMES[curveOid] || { asn1: curveOid, nist: curveOid, byteLen: null, bits: null };
if (keyBytes[0] !== 0x04) {
throw new OperationError("Only uncompressed EC public keys are supported");
}
const byteLen = info.byteLen || ((keyBytes.length - 1) / 2);
return {
type: "EC",
curveOid,
asn1Curve: info.asn1,
nistCurve: info.nist,
pubKeyHex: bytesToHex(keyBytes),
bitLength: info.bits || byteLen * 8,
x: keyBytes.slice(1, 1 + byteLen),
y: keyBytes.slice(1 + byteLen, 1 + 2 * byteLen),
};
}
if (alg === ID_DSA) {
if (!spki.algorithm.parameters) throw new OperationError("DSA SubjectPublicKeyInfo missing DSS-Parms");
const { p, q, g } = parseDssParms(new Uint8Array(spki.algorithm.parameters));
const y = parseIntegerBitStringBytes(keyBytes);
const pStripped = stripDerLeadingZero(p);
return {
type: "DSA",
yHex: bytesToHex(y),
pHex: bytesToHex(p),
qHex: bytesToHex(q),
gHex: bytesToHex(g),
bitLength: pStripped.length * 8,
};
}
if (alg === ID_ED25519 || alg === ID_ED448) {
return {
type: "EdDSA",
curveName: alg === ID_ED25519 ? "Ed25519" : "Ed448",
pubKeyHex: bytesToHex(keyBytes),
};
}
return { type: "Unknown", algorithm: alg };
}
// ----- signature value parsing ----------------------------------------------
/**
* Parse a DER-encoded ECDSA/DSA signature (SEQUENCE { r INTEGER, s INTEGER })
* and return r and s as hex strings (without the DER 2's-complement leading
* 0x00, when present).
*
* @param {string} sigHex
* @returns {{r: string, s: string}}
*/
export function parseDerEcdsaSignature(sigHex) {
const bytes = fromHex(sigHex);
let i = 0;
if (bytes[i++] !== 0x30) throw new OperationError("Signature is not an ASN.1 SEQUENCE");
const seqLen = readDerLength(bytes, i); i = seqLen.next;
if (i + seqLen.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 = readDerLength(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 = readDerLength(bytes, i); i = sLen.next;
const s = bytes.slice(i, i + sLen.value);
return {
r: bytesToHex(stripDerLeadingZero(Uint8Array.from(r))),
s: bytesToHex(stripDerLeadingZero(Uint8Array.from(s))),
};
}
/**
* Returns true if the supplied hex bytes parse as a SEQUENCE of two INTEGERs
* (the wire format used for ECDSA/DSA signatures).
*
* @param {string} sigHex
* @returns {boolean}
*/
export function isDerEcdsaSignature(sigHex) {
try {
parseDerEcdsaSignature(sigHex);
return true;
} catch {
return false;
}
}
// ----- formatters -----------------------------------------------------------
/**
* Format a peculiar/x509 `JsonName` (the array-of-records form returned by
* `name.toJSON()`) as a multi-line string of `KEY = value` pairs, indented.
*
* @param {Array<Record<string, string[]>>} jsonName
* @param {number} indent
* @returns {string}
*/
export function formatJsonName(jsonName, indent) {
if (!Array.isArray(jsonName) || jsonName.length === 0) return "";
const rows = [];
for (const rdn of jsonName) {
for (const key of Object.keys(rdn)) {
const values = rdn[key];
for (const value of values) rows.push({ key, value });
}
}
if (rows.length === 0) return "";
const maxKey = rows.reduce((m, row) => Math.max(m, row.key.length), 0);
const pad = " ".repeat(indent);
return rows.map(({ key, value }) => `${pad}${key.padEnd(maxKey, " ")} = ${value}`).join("\n");
}
/**
* Convert peculiar/x509's `JsonName` into the OpenSSL-style
* "/C=…/ST=…/O=…/CN=…" single-line representation.
*
* @param {Array<Record<string, string[]>>} jsonName
* @returns {string}
*/
export function jsonNameToSlashString(jsonName) {
if (!Array.isArray(jsonName) || jsonName.length === 0) return "";
let out = "";
for (const rdn of jsonName) {
for (const key of Object.keys(rdn)) {
for (const value of rdn[key]) out += "/" + key + "=" + value;
}
}
return out;
}
/**
* Format a hex string as `aa:bb:cc:...` groups of `bytesPerLine` bytes per
* line, with each line after the first prefixed by `indent` spaces.
*
* @param {string} hex
* @param {number} bytesPerLine
* @param {number} indent
* @returns {string}
*/
export function formatHexByteLines(hex, bytesPerLine, indent) {
if (hex.length % 2 !== 0) hex = "0" + hex;
const colonHex = hex.replace(/(..)/g, "$1:");
const trimmed = colonHex.slice(0, -1);
const lineLen = bytesPerLine * 3;
let out = "";
for (let i = 0; i < trimmed.length; i += lineLen) {
const chunk = trimmed.slice(i, i + lineLen) + "\n";
out += i === 0 ? chunk : " ".repeat(indent) + chunk;
}
return out.slice(0, -1);
}
/**
* Format a hex string as colon-delimited bytes wrapped to `maxLineChars`
* characters per line, with continuation lines indented by `indent` spaces.
*
* Used by ParseCSR / ParseX509CRL where the wrap width is measured in
* characters rather than bytes per line.
*
* @param {string} hex
* @param {number} maxLineChars
* @param {number} indent
* @returns {string}
*/
export function formatHexColonWrapped(hex, maxLineChars, indent) {
if (hex.length % 2 !== 0) hex = "0" + hex;
const colonHex = hex.replace(/(..)/g, "$1:");
const trimmed = colonHex.slice(0, -1);
const lines = [];
for (let i = 0; i < trimmed.length; i += maxLineChars) {
lines.push(trimmed.substring(i, i + maxLineChars));
}
const pad = " ".repeat(indent);
return lines.join("\n" + pad);
}
// ----- SAN / GeneralName formatting -----------------------------------------
/**
* Format a single ASN.1 GeneralName (from `@peculiar/asn1-x509`). The
* `flavor` arg controls the punctuation: "csr" produces "KEY: value" (with
* a space), "crl" produces "KEY:value" (no space and slightly different
* label set).
*
* @param {object} gn - An asn1-x509 GeneralName instance.
* @param {"csr"|"crl"} flavor
* @returns {string}
*/
export function formatGeneralName(gn, flavor) {
const sep = flavor === "crl" ? ":" : ": ";
if (gn.dNSName !== undefined) return `DNS${sep}${gn.dNSName}`;
if (gn.iPAddress !== undefined) return `IP${sep}${gn.iPAddress}`;
if (gn.rfc822Name !== undefined) return `EMAIL${sep}${gn.rfc822Name}`;
if (gn.uniformResourceIdentifier !== undefined) return `URI${sep}${gn.uniformResourceIdentifier}`;
if (gn.directoryName !== undefined) {
return `DIR${sep}${jsonNameToSlashString(asnNameToJson(gn.directoryName))}`;
}
if (gn.registeredID !== undefined) return `ID${sep}${gn.registeredID}`;
if (gn.otherName !== undefined) {
const value = otherNameValueToString(gn.otherName);
const label = flavor === "crl" ? "OtherName" : "Other";
return `${label}${sep}${gn.otherName.typeId}::${value}`;
}
return `(unsupported general name)`;
}
/**
* Attempt to extract a printable string from an OtherName's ANY-typed value.
*
* @param {{typeId: string, value: ArrayBuffer}} otherName
* @returns {string}
*/
function otherNameValueToString(otherName) {
const bytes = new Uint8Array(otherName.value);
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const parsed = fromBER(ab);
if (parsed.offset === -1) return bytesToHex(bytes);
const node = parsed.result;
if (node instanceof Utf8String || node instanceof BmpString ||
node instanceof PrintableString || node instanceof IA5String) {
return node.valueBlock.value;
}
try {
const ds = AsnParser.parse(bytes, DirectoryString);
return ds.toString();
} catch { /* fall through */ }
return bytesToHex(bytes);
}
/**
* Convert an asn1-x509 Name (CHOICE { RDNSequence }) into the JsonName
* representation used by `formatJsonName` / `jsonNameToSlashString`. Uses
* the same field-name vocabulary as peculiar/x509's `Name.toJSON()`.
*
* @param {object} asnName
* @returns {Array<Record<string, string[]>>}
*/
export function asnNameToJson(asnName) {
const out = [];
if (!asnName || typeof asnName[Symbol.iterator] !== "function") return out;
for (const rdn of asnName) {
const obj = {};
for (const atv of rdn) {
const key = OID_TO_SHORT_NAME[atv.type] || atv.type;
const val = atv.value.toString();
if (!obj[key]) obj[key] = [];
obj[key].push(val);
}
out.push(obj);
}
return out;
}
// ----- byte helpers ---------------------------------------------------------
// bytesToHex is re-exported from @noble/hashes/utils (imported above) so
// operations can continue importing it from this module.
export { bytesToHex };
/**
* Strip a single leading 0x00 byte from a buffer when it's only present to
* keep a DER INTEGER positive (i.e. the next byte's MSB is set).
*
* @param {Uint8Array} bytes
* @returns {Uint8Array}
*/
export function stripDerLeadingZero(bytes) {
if (bytes.length > 1 && bytes[0] === 0 && (bytes[1] & 0x80)) {
return bytes.slice(1);
}
return bytes;
}
/**
* Read a BER/DER length octet sequence.
*
* @param {Uint8Array} bytes
* @param {number} offset
* @returns {{value: number, next: number}}
*/
function readDerLength(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 };
}
/**
* Parse 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 parseIntegerBitStringBytes(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 (preserving the
* DER 2's-complement leading 00 callers strip it if they need a magnitude).
*
* @param {object} node
* @returns {Uint8Array}
*/
function extractIntegerBytes(node) {
const view = node.valueBlock && node.valueBlock.valueHexView;
if (!view) throw new OperationError("Missing INTEGER value");
return new Uint8Array(view);
}

View File

@ -8,7 +8,14 @@ import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs"; import OperationError from "../errors/OperationError.mjs";
import { fromHex } from "../lib/Hex.mjs"; import { fromHex } from "../lib/Hex.mjs";
import { toBase64 } from "../lib/Base64.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 * ECDSA Sign operation
@ -69,38 +76,28 @@ class ECDSASign extends Operation {
throw new OperationError("Please enter a private key."); throw new OperationError("Please enter a private key.");
} }
const internalAlgorithmName = mdAlgo.replace("-", "") + "withECDSA"; const key = loadEcKey(keyPem);
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.");
}
if (!key.isPrivate) { if (!key.isPrivate) {
throw new OperationError("Provided key is not a private key."); 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) { switch (outputFormat) {
case "ASN.1 HEX": case "ASN.1 HEX":
result = signatureASN1Hex; return signatureASN1Hex;
break;
case "P1363 HEX": case "P1363 HEX":
result = r.KJUR.crypto.ECDSA.asn1SigToConcatSig(signatureASN1Hex); return asn1SigToConcatHex(signatureASN1Hex);
break; case "JSON Web Signature": {
case "JSON Web Signature": const concat = asn1SigToConcatHex(signatureASN1Hex);
result = r.KJUR.crypto.ECDSA.asn1SigToConcatSig(signatureASN1Hex); return toBase64(fromHex(concat), "A-Za-z0-9-_"); // base64url
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;
} }
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 OperationError from "../errors/OperationError.mjs";
import { fromBase64, toBase64 } from "../lib/Base64.mjs"; import { fromBase64, toBase64 } from "../lib/Base64.mjs";
import { fromHex, toHexFast } from "../lib/Hex.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 { class ECDSASignatureConversion extends Operation {
@ -75,7 +81,7 @@ class ECDSASignatureConversion extends Operation {
if (inputFormat === "Auto") { if (inputFormat === "Auto") {
const hexRegex = /^[a-f\d]{2,}$/gi; const hexRegex = /^[a-f\d]{2,}$/gi;
if (hexRegex.test(input)) { 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"; inputFormat = "ASN.1 HEX";
} else { } else {
inputFormat = "P1363 HEX"; inputFormat = "P1363 HEX";
@ -100,11 +106,11 @@ class ECDSASignatureConversion extends Operation {
signatureASN1Hex = input; signatureASN1Hex = input;
break; break;
case "P1363 HEX": case "P1363 HEX":
signatureASN1Hex = r.KJUR.crypto.ECDSA.concatSigToASN1Sig(input); signatureASN1Hex = concatHexToAsn1Sig(input);
break; break;
case "JSON Web Signature": case "JSON Web Signature":
if (!inputBase64) inputBase64 = fromBase64(input, "A-Za-z0-9-_"); if (!inputBase64) inputBase64 = fromBase64(input, "A-Za-z0-9-_");
signatureASN1Hex = r.KJUR.crypto.ECDSA.concatSigToASN1Sig(toHexFast(inputBase64)); signatureASN1Hex = concatHexToAsn1Sig(toHexFast(inputBase64));
break; break;
case "Raw JSON": { case "Raw JSON": {
if (!inputJson) inputJson = JSON.parse(input); if (!inputJson) inputJson = JSON.parse(input);
@ -114,32 +120,26 @@ class ECDSASignatureConversion extends Operation {
if (!inputJson.s) { if (!inputJson.s) {
throw new OperationError('No "s" value in the signature JSON'); 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; break;
} }
} }
// convert ASN.1 hex to output format // convert ASN.1 hex to output format
let result;
switch (outputFormat) { switch (outputFormat) {
case "ASN.1 HEX": case "ASN.1 HEX":
result = signatureASN1Hex; return signatureASN1Hex;
break;
case "P1363 HEX": case "P1363 HEX":
result = r.KJUR.crypto.ECDSA.asn1SigToConcatSig(signatureASN1Hex); return asn1SigToConcatHex(signatureASN1Hex);
break; case "JSON Web Signature": {
case "JSON Web Signature": const concat = asn1SigToConcatHex(signatureASN1Hex);
result = r.KJUR.crypto.ECDSA.asn1SigToConcatSig(signatureASN1Hex); return toBase64(fromHex(concat), "A-Za-z0-9-_"); // base64url
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;
} }
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 OperationError from "../errors/OperationError.mjs";
import { fromBase64 } from "../lib/Base64.mjs"; import { fromBase64 } from "../lib/Base64.mjs";
import { toHexFast } from "../lib/Hex.mjs"; import { toHexFast } from "../lib/Hex.mjs";
import r from "jsrsasign";
import Utils from "../Utils.mjs"; import Utils from "../Utils.mjs";
import {
loadEcKey,
digestBytes,
verifyEcdsa,
strToBytesLatin1,
concatHexToAsn1Sig,
hexRSToAsn1Sig,
isAsn1Hex,
} from "../lib/Ecdsa.mjs";
/** /**
* ECDSA Verify operation * ECDSA Verify operation
@ -96,7 +104,7 @@ class ECDSAVerify extends Operation {
if (inputFormat === "Auto") { if (inputFormat === "Auto") {
const hexRegex = /^[a-f\d]{2,}$/gi; const hexRegex = /^[a-f\d]{2,}$/gi;
if (hexRegex.test(input)) { 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"; inputFormat = "ASN.1 HEX";
} else { } else {
inputFormat = "P1363 HEX"; inputFormat = "P1363 HEX";
@ -121,11 +129,11 @@ class ECDSAVerify extends Operation {
signatureASN1Hex = input; signatureASN1Hex = input;
break; break;
case "P1363 HEX": case "P1363 HEX":
signatureASN1Hex = r.KJUR.crypto.ECDSA.concatSigToASN1Sig(input); signatureASN1Hex = concatHexToAsn1Sig(input);
break; break;
case "JSON Web Signature": case "JSON Web Signature":
if (!inputBase64) inputBase64 = fromBase64(input, "A-Za-z0-9-_"); if (!inputBase64) inputBase64 = fromBase64(input, "A-Za-z0-9-_");
signatureASN1Hex = r.KJUR.crypto.ECDSA.concatSigToASN1Sig(toHexFast(inputBase64)); signatureASN1Hex = concatHexToAsn1Sig(toHexFast(inputBase64));
break; break;
case "Raw JSON": { case "Raw JSON": {
if (!inputJson) inputJson = JSON.parse(input); if (!inputJson) inputJson = JSON.parse(input);
@ -135,26 +143,20 @@ class ECDSAVerify extends Operation {
if (!inputJson.s) { if (!inputJson.s) {
throw new OperationError('No "s" value in the signature JSON'); 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; break;
} }
} }
// verify signature const key = loadEcKey(keyPem);
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.");
}
if (!key.isPublic) { if (!key.isPublic) {
throw new OperationError("Provided key is not a public key."); throw new OperationError("Provided key is not a public key.");
} }
sig.init(key);
const messageStr = Utils.convertToByteString(msg, msgFormat); const messageStr = Utils.convertToByteString(msg, msgFormat);
sig.updateString(messageStr); const digest = digestBytes(mdAlgo, strToBytesLatin1(messageStr));
const result = sig.verify(signatureASN1Hex); const ok = verifyEcdsa(key, digest, signatureASN1Hex);
return result ? "Verified OK" : "Verification Failure"; return ok ? "Verified OK" : "Verification Failure";
} }
} }

View File

@ -5,8 +5,14 @@
*/ */
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import { bytesToHex } from "@noble/hashes/utils.js";
import { cryptNotice } from "../lib/Crypt.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 * Generate ECDSA Key Pair operation
@ -54,49 +60,47 @@ class GenerateECDSAKeyPair extends Operation {
*/ */
async run(input, args) { async run(input, args) {
const [curveName, outputFormat] = args; const [curveName, outputFormat] = args;
const pair = generateEcKeyPair(curveName);
return new Promise((resolve, reject) => { switch (outputFormat) {
let internalCurveName; case "PEM":
switch (curveName) { return publicKeyToSpkiPem(pair) + privateKeyToPkcs8Pem(pair);
case "P-256": case "DER":
internalCurveName = "secp256r1"; return bytesToHex(pair.d);
break; case "JWK": {
case "P-384": const pubJwk = {
internalCurveName = "secp384r1"; kty: "EC",
break; crv: curveName,
case "P-521": x: b64url(pair.x),
internalCurveName = "secp521r1"; y: b64url(pair.y),
break; "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); default:
throw new Error(`Unsupported output format: ${outputFormat}`);
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);
});
} }
}
/**
* 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-_");
} }
export default GenerateECDSAKeyPair; export default GenerateECDSAKeyPair;

View File

@ -4,8 +4,8 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import { oidHexToInt } from "../lib/Asn1.mjs";
/** /**
* Hex to Object Identifier operation * Hex to Object Identifier operation
@ -33,7 +33,7 @@ class HexToObjectIdentifier extends Operation {
* @returns {string} * @returns {string}
*/ */
run(input, args) { run(input, args) {
return r.KJUR.asn1.ASN1Util.oidHexToInt(input.replace(/\s/g, "")); return oidHexToInt(input.replace(/\s/g, ""));
} }
} }

View File

@ -4,8 +4,8 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import { derToPem } from "../lib/Asn1.mjs";
/** /**
* Hex to PEM operation * Hex to PEM operation
@ -39,7 +39,7 @@ class HexToPEM extends Operation {
* @returns {string} * @returns {string}
*/ */
run(input, args) { run(input, args) {
return r.KJUR.asn1.ASN1Util.getPEMStringFromHex(input.replace(/\s/g, ""), args[0]); return derToPem(input.replace(/\s/g, ""), args[0]);
} }
} }

View File

@ -4,17 +4,17 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.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() { constructor() {
super(); super();
@ -45,36 +45,27 @@ class PEMToJWK extends Operation {
let keys = []; let keys = [];
if (Array.isArray(inputJson)) { if (Array.isArray(inputJson)) {
// list of keys => transform all keys
keys = inputJson; keys = inputJson;
} else if (Array.isArray(inputJson.keys)) { } else if (Array.isArray(inputJson.keys)) {
// JSON Web Key Set => transform all keys
keys = inputJson.keys; keys = inputJson.keys;
} else if (typeof inputJson === "object") { } else if (typeof inputJson === "object" && inputJson !== null) {
// single key
keys.push(inputJson); keys.push(inputJson);
} else { } else {
throw new OperationError("Input is not a JSON Web Key"); throw new OperationError("Input is not a JSON Web Key");
} }
let output = ""; let output = "";
for (let i=0; i<keys.length; i++) { for (const jwk of keys) {
const jwk = keys[i]; if (!jwk || typeof jwk.kty !== "string") {
if (typeof jwk.kty !== "string") {
throw new OperationError("Invalid JWK format"); throw new OperationError("Invalid JWK format");
} else if ("|RSA|EC|".indexOf(jwk.kty) === -1) {
throw new OperationError(`Unsupported JWK key type '${inputJson.kty}'`);
} }
if (jwk.kty !== "RSA" && jwk.kty !== "EC") {
const key = r.KEYUTIL.getKey(jwk); throw new OperationError(`Unsupported JWK key type '${jwk.kty}'`);
const pem = key.isPrivate ? r.KEYUTIL.getPEM(key, "PKCS8PRV") : r.KEYUTIL.getPEM(key); }
output += keyInfoToPem(keyFromJwk(jwk));
// PEM ends with '\n', so a new key always starts on a new line
output += pem;
} }
return output; return output;
} }
} }
export default PEMToJWK; export default JWKToPem;

View File

@ -4,8 +4,8 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import { oidIntToHex } from "../lib/Asn1.mjs";
/** /**
* Object Identifier to Hex operation * Object Identifier to Hex operation
@ -33,7 +33,7 @@ class ObjectIdentifierToHex extends Operation {
* @returns {string} * @returns {string}
*/ */
run(input, args) { run(input, args) {
return r.KJUR.asn1.ASN1Util.oidIntToHex(input); return oidIntToHex(input);
} }
} }

View File

@ -4,9 +4,9 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs"; import OperationError from "../errors/OperationError.mjs";
import { parseKeyPem, parseCertPublicKey, keyToJwk } from "../lib/KeyConvert.mjs";
/** /**
* PEM to JWK operation * PEM to JWK operation
@ -54,32 +54,25 @@ class PEMToJWK extends Operation {
} }
const pem = input.substring(match.index, indexFooter + footer.length); const pem = input.substring(match.index, indexFooter + footer.length);
let info;
if (match[1].indexOf("KEY") !== -1) { if (match[1].indexOf("KEY") !== -1) {
if (header === "-----BEGIN RSA PUBLIC KEY-----") { if (header === "-----BEGIN RSA PUBLIC KEY-----") {
throw new OperationError("Unsupported RSA public key format. Only PKCS#8 is supported."); throw new OperationError("Unsupported RSA public key format. Only PKCS#8 is supported.");
} }
info = parseKeyPem(pem);
const key = r.KEYUTIL.getKey(pem);
if (key.type === "DSA") {
throw new OperationError("DSA keys are not supported for JWK");
}
const jwk = r.KEYUTIL.getJWKFromKey(key);
if (output.length > 0) {
output += "\n";
}
output += JSON.stringify(jwk);
} else if (match[1] === "CERTIFICATE") { } else if (match[1] === "CERTIFICATE") {
const cert = new r.X509(); info = parseCertPublicKey(pem);
cert.readCertPEM(pem);
const key = cert.getPublicKey();
const jwk = r.KEYUTIL.getJWKFromKey(key);
if (output.length > 0) {
output += "\n";
}
output += JSON.stringify(jwk);
} else { } else {
throw new OperationError(`Unsupported PEM type '${match[1]}'`); 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; return output;
} }

View File

@ -4,8 +4,8 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import { dumpAsn1Hex } from "../lib/Asn1.mjs";
/** /**
* Parse ASN.1 hex string operation * Parse ASN.1 hex string operation
@ -45,9 +45,10 @@ class ParseASN1HexString extends Operation {
*/ */
run(input, args) { run(input, args) {
const [index, truncateLen] = args; const [index, truncateLen] = args;
return r.ASN1HEX.dump(input.replace(/\s/g, "").toLowerCase(), { return dumpAsn1Hex(input.replace(/\s/g, "").toLowerCase(), {
"ommit_long_octet": truncateLen truncate: truncateLen,
}, index); startIndex: index,
});
} }
} }

View File

@ -4,11 +4,34 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign"; import { Pkcs10CertificateRequest } from "@peculiar/x509";
import { AsnParser } from "@peculiar/asn1-schema";
import {
BasicConstraints, ExtendedKeyUsage, Extensions, KeyUsage,
SubjectAlternativeName,
} from "@peculiar/asn1-x509";
import * as asn1X509 from "@peculiar/asn1-x509";
import { CertificationRequest } from "@peculiar/asn1-csr";
import * as asnPkcs9 from "@peculiar/asn1-pkcs9";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import { formatDnObj } from "../lib/PublicKey.mjs"; import { formatDnObj } from "../lib/PublicKey.mjs";
import {
bytesToHex,
describeSpki,
formatGeneralName,
formatHexColonWrapped,
parseDerEcdsaSignature,
sigAlgOidToName,
} from "../lib/X509.mjs";
import Utils from "../Utils.mjs"; import Utils from "../Utils.mjs";
const ID_CE_BASIC_CONSTRAINTS = asn1X509.id_ce_basicConstraints;
const ID_CE_EXT_KEY_USAGE = asn1X509.id_ce_extKeyUsage;
const ID_CE_KEY_USAGE = asn1X509.id_ce_keyUsage;
const ID_CE_SUBJECT_ALT_NAME = asn1X509.id_ce_subjectAltName;
const ID_PKCS9_AT_EXTENSION_REQUEST = asnPkcs9.id_pkcs9_at_extensionRequest;
/** /**
* Parse CSR operation * Parse CSR operation
*/ */
@ -45,333 +68,232 @@ class ParseCSR extends Operation {
/** /**
* @param {string} input * @param {string} input
* @param {Object[]} args * @param {Object[]} args
* @returns {string} Human-readable description of a Certificate Signing Request (CSR). * @returns {string}
*/ */
run(input, args) { run(input, args) {
if (!input.length) { if (!input.length) return "No input";
return "No input";
let csr;
try {
csr = new Pkcs10CertificateRequest(input);
} catch (e) {
throw new OperationError(`Failed to parse CSR: ${e.message}`);
} }
// Parse the CSR into JSON parameters const subjectStr = formatDnObj(csr.subjectName.toJSON(), 2);
const csrParam = new r.KJUR.asn1.csr.CSRUtil.getParam(input); const spki = describeSpki(new Uint8Array(csr.publicKey.rawData));
const asnCsr = AsnParser.parse(new Uint8Array(csr.rawData), CertificationRequest);
const sigAlgName = sigAlgOidToName(asnCsr.signatureAlgorithm.algorithm);
const sigHex = bytesToHex(new Uint8Array(csr.signature));
return `Subject\n${formatDnObj(csrParam.subject, 2)} return `Subject\n${subjectStr}
Public Key${formatSubjectPublicKey(csrParam.sbjpubkey)} Public Key${formatPublicKey(spki)}
Signature${formatSignature(csrParam.sigalg, csrParam.sighex)} Signature${formatSignature(sigAlgName, sigHex)}
Requested Extensions${formatRequestedExtensions(csrParam)}`; Requested Extensions${formatRequestedExtensions(csr)}`;
} }
} }
/** /**
* Format signature of a CSR * Format the public-key section.
* @param {*} sigAlg string *
* @param {*} sigHex string * @param {object} spki
* @returns Multi-line string describing CSR Signature * @returns {string}
*/ */
function formatSignature(sigAlg, sigHex) { function formatPublicKey(spki) {
let out = `\n`;
out += ` Algorithm: ${sigAlg}\n`;
if (new RegExp("withdsa", "i").test(sigAlg)) {
const d = new r.KJUR.crypto.DSA();
const sigParam = d.parseASN1Signature(sigHex);
out += ` Signature:
R: ${formatHexOntoMultiLine(absBigIntToHex(sigParam[0]))}
S: ${formatHexOntoMultiLine(absBigIntToHex(sigParam[1]))}\n`;
} else if (new RegExp("withrsa", "i").test(sigAlg)) {
out += ` Signature: ${formatHexOntoMultiLine(sigHex)}\n`;
} else {
out += ` Signature: ${formatHexOntoMultiLine(ensureHexIsPositiveInTwosComplement(sigHex))}\n`;
}
return chop(out);
}
/**
* Format Subject Public Key from PEM encoded public key string
* @param {*} publicKeyPEM string
* @returns Multi-line string describing Subject Public Key Info
*/
function formatSubjectPublicKey(publicKeyPEM) {
let out = "\n"; let out = "\n";
if (spki.type === "RSA") {
const publicKey = r.KEYUTIL.getKey(publicKeyPEM);
if (publicKey instanceof r.RSAKey) {
out += ` Algorithm: RSA out += ` Algorithm: RSA
Length: ${publicKey.n.bitLength()} bits Length: ${spki.bitLength} bits
Modulus: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.n))} Modulus: ${formatHexColonWrapped(prefix00IfMsbSet(spki.nHex), 48, 18)}
Exponent: ${publicKey.e} (0x${Utils.hex(publicKey.e)})\n`; Exponent: ${spki.eValue} (0x${Utils.hex(spki.eValue)})\n`;
} else if (publicKey instanceof r.KJUR.crypto.ECDSA) { } else if (spki.type === "EC") {
out += ` Algorithm: ECDSA out += ` Algorithm: ECDSA
Length: ${publicKey.ecparams.keylen} bits Length: ${spki.bitLength} bits
Pub: ${formatHexOntoMultiLine(publicKey.pubKeyHex)} Pub: ${formatHexColonWrapped(spki.pubKeyHex, 48, 18)}
ASN1 OID: ${r.KJUR.crypto.ECDSA.getName(publicKey.getShortNISTPCurveName())} ASN1 OID: ${spki.asn1Curve}
NIST CURVE: ${publicKey.getShortNISTPCurveName()}\n`; NIST CURVE: ${spki.nistCurve}\n`;
} else if (publicKey instanceof r.KJUR.crypto.DSA) { } else if (spki.type === "DSA") {
out += ` Algorithm: DSA out += ` Algorithm: DSA
Length: ${publicKey.p.toString(16).length * 4} bits Length: ${spki.bitLength} bits
Pub: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.y))} Pub: ${formatHexColonWrapped(prefix00IfMsbSet(spki.yHex), 48, 18)}
P: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.p))} P: ${formatHexColonWrapped(prefix00IfMsbSet(spki.pHex), 48, 18)}
Q: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.q))} Q: ${formatHexColonWrapped(prefix00IfMsbSet(spki.qHex), 48, 18)}
G: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.g))}\n`; G: ${formatHexColonWrapped(prefix00IfMsbSet(spki.gHex), 48, 18)}\n`;
} else { } else {
out += `unsupported public key algorithm\n`; out += `unsupported public key algorithm\n`;
} }
return chop(out); return chop(out);
} }
/** /**
* Format known extensions of a CSR * Prefix the hex string with "00" when its most significant bit is set.
* @param {*} csrParam object * Mirrors the legacy "ensureHexIsPositiveInTwosComplement" behaviour the
* @returns Multi-line string describing CSR Requested Extensions * golden CSR fixtures depend on.
*
* @param {string} hex
* @returns {string}
*/ */
function formatRequestedExtensions(csrParam) { function prefix00IfMsbSet(hex) {
const formattedExtensions = new Array(4).fill(""); if (hex.length % 2 !== 0) hex = "0" + hex;
if (hex.length >= 2 && (parseInt(hex.substring(0, 2), 16) & 0x80)) {
if (Object.hasOwn(csrParam, "extreq")) {
for (const extension of csrParam.extreq) {
let parts = [];
switch (extension.extname) {
case "basicConstraints" :
parts = describeBasicConstraints(extension);
formattedExtensions[0] = ` Basic Constraints:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`;
break;
case "keyUsage" :
parts = describeKeyUsage(extension);
formattedExtensions[1] = ` Key Usage:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`;
break;
case "extKeyUsage" :
parts = describeExtendedKeyUsage(extension);
formattedExtensions[2] = ` Extended Key Usage:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`;
break;
case "subjectAltName" :
parts = describeSubjectAlternativeName(extension);
formattedExtensions[3] = ` Subject Alternative Name:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`;
break;
default :
parts = ["(unsuported extension)"];
formattedExtensions.push(` ${extension.extname}:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`);
}
}
}
let out = "\n";
formattedExtensions.forEach((formattedExtension) => {
if (formattedExtension !== undefined && formattedExtension !== null && formattedExtension.length !== 0) {
out += formattedExtension;
}
});
return chop(out);
}
/**
* Format extension critical tag
* @param {*} extension Object
* @returns String describing whether the extension is critical or not
*/
function formatExtensionCriticalTag(extension) {
return Object.hasOwn(extension, "critical") && extension.critical ? " critical" : "";
}
/**
* Format string input as a comma separated hex string on multiple lines
* @param {*} hex String
* @returns Multi-line string describing the Hex input
*/
function formatHexOntoMultiLine(hex) {
if (hex.length % 2 !== 0) {
hex = "0" + hex;
}
return formatMultiLine(chop(hex.replace(/(..)/g, "$&:")));
}
/**
* Convert BigInt to abs value in Hex
* @param {*} int BigInt
* @returns String representing absolute value in Hex
*/
function absBigIntToHex(int) {
int = int < 0n ? -int : int;
return ensureHexIsPositiveInTwosComplement(int.toString(16));
}
/**
* Ensure Hex String remains positive in 2's complement
* @param {*} hex String
* @returns Hex String ensuring value remains positive in 2's complement
*/
function ensureHexIsPositiveInTwosComplement(hex) {
if (hex.length % 2 !== 0) {
return "0" + hex;
}
// prepend 00 if most significant bit is 1 (sign bit)
if (hex.length >=2 && (parseInt(hex.substring(0, 2), 16) & 128)) {
hex = "00" + hex; hex = "00" + hex;
} }
return hex; return hex;
} }
/** /**
* Format string onto multiple lines * Format the signature section.
* @param {*} longStr *
* @returns String as a multi-line string * @param {string} sigAlgName
* @param {string} sigHex
* @returns {string}
*/ */
function formatMultiLine(longStr) { function formatSignature(sigAlgName, sigHex) {
const lines = []; let out = `\n Algorithm: ${sigAlgName}\n`;
for (let remain = longStr ; remain !== "" ; remain = remain.substring(48)) { if (/withdsa/i.test(sigAlgName)) {
lines.push(remain.substring(0, 48)); const { r, s } = parseDerEcdsaSignature(sigHex);
out += ` Signature:
R: ${formatHexColonWrapped(prefix00IfMsbSet(r), 48, 18)}
S: ${formatHexColonWrapped(prefix00IfMsbSet(s), 48, 18)}\n`;
} else if (/withrsa/i.test(sigAlgName)) {
out += ` Signature: ${formatHexColonWrapped(sigHex, 48, 18)}\n`;
} else {
out += ` Signature: ${formatHexColonWrapped(prefix00IfMsbSet(sigHex), 48, 18)}\n`;
} }
return lines.join("\n "); return chop(out);
} }
/** /**
* Describe Basic Constraints * Format the Requested Extensions section. Reads the extensionRequest
* @see RFC 5280 4.2.1.9. Basic Constraints https://www.ietf.org/rfc/rfc5280.txt * attribute (OID 1.2.840.113549.1.9.14) and dispatches the well-known
* @param {*} extension CSR extension with the name `basicConstraints` * extension types to per-type formatters. Unknown extensions render as
* @returns Array of strings describing Basic Constraints * `(unsuported extension)` to match the existing golden output.
*
* @param {object} csr
* @returns {string}
*/ */
function describeBasicConstraints(extension) { function formatRequestedExtensions(csr) {
const constraints = []; const extReqAttr = csr.attributes.find(a => a.type === ID_PKCS9_AT_EXTENSION_REQUEST);
if (!extReqAttr || !extReqAttr.values || extReqAttr.values.length === 0) {
constraints.push(`CA = ${Object.hasOwn(extension, "cA") && extension.cA ? "true" : "false"}`); return "\n";
if (Object.hasOwn(extension, "pathLen")) constraints.push(`PathLenConstraint = ${extension.pathLen}`);
return constraints;
}
/**
* Describe Key Usage extension permitted use cases
* @see RFC 5280 4.2.1.3. Key Usage https://www.ietf.org/rfc/rfc5280.txt
* @param {*} extension CSR extension with the name `keyUsage`
* @returns Array of strings describing Key Usage extension permitted use cases
*/
function describeKeyUsage(extension) {
const usage = [];
const kuIdentifierToName = {
digitalSignature: "Digital Signature",
nonRepudiation: "Non-repudiation",
keyEncipherment: "Key encipherment",
dataEncipherment: "Data encipherment",
keyAgreement: "Key agreement",
keyCertSign: "Key certificate signing",
cRLSign: "CRL signing",
encipherOnly: "Encipher Only",
decipherOnly: "Decipher Only",
};
if (Object.hasOwn(extension, "names")) {
extension.names.forEach((ku) => {
if (Object.hasOwn(kuIdentifierToName, ku)) {
usage.push(kuIdentifierToName[ku]);
} else {
usage.push(`unknown key usage (${ku})`);
}
});
} }
if (usage.length === 0) usage.push("(none)"); let extensions;
try {
return usage; extensions = AsnParser.parse(new Uint8Array(extReqAttr.values[0]), Extensions);
} } catch {
return "\n";
/**
* Describe Extended Key Usage extension permitted use cases
* @see RFC 5280 4.2.1.12. Extended Key Usage https://www.ietf.org/rfc/rfc5280.txt
* @param {*} extension CSR extension with the name `extendedKeyUsage`
* @returns Array of strings describing Extended Key Usage extension permitted use cases
*/
function describeExtendedKeyUsage(extension) {
const usage = [];
const ekuIdentifierToName = {
"serverAuth": "TLS Web Server Authentication",
"clientAuth": "TLS Web Client Authentication",
"codeSigning": "Code signing",
"emailProtection": "E-mail Protection (S/MIME)",
"timeStamping": "Trusted Timestamping",
"1.3.6.1.4.1.311.2.1.21": "Microsoft Individual Code Signing", // msCodeInd
"1.3.6.1.4.1.311.2.1.22": "Microsoft Commercial Code Signing", // msCodeCom
"1.3.6.1.4.1.311.10.3.1": "Microsoft Trust List Signing", // msCTLSign
"1.3.6.1.4.1.311.10.3.3": "Microsoft Server Gated Crypto", // msSGC
"1.3.6.1.4.1.311.10.3.4": "Microsoft Encrypted File System", // msEFS
"1.3.6.1.4.1.311.20.2.2": "Microsoft Smartcard Login", // msSmartcardLogin
"2.16.840.1.113730.4.1": "Netscape Server Gated Crypto", // nsSGC
};
if (Object.hasOwn(extension, "array")) {
extension.array.forEach((eku) => {
if (Object.hasOwn(ekuIdentifierToName, eku)) {
usage.push(ekuIdentifierToName[eku]);
} else {
usage.push(eku);
}
});
} }
if (usage.length === 0) usage.push("(none)"); const formatted = new Array(4).fill("");
const tail = [];
return usage; for (const ext of extensions) {
} const criticalTag = ext.critical ? " critical" : "";
const value = new Uint8Array(ext.extnValue.buffer);
/** switch (ext.extnID) {
* Format Subject Alternative Names from the name `subjectAltName` extension case ID_CE_BASIC_CONSTRAINTS: {
* @see RFC 5280 4.2.1.6. Subject Alternative Name https://www.ietf.org/rfc/rfc5280.txt const bc = AsnParser.parse(value, BasicConstraints);
* @param {*} extension object const parts = [`CA = ${bc.cA ? "true" : "false"}`];
* @returns Array of strings describing Subject Alternative Name extension if (bc.pathLenConstraint !== undefined) parts.push(`PathLenConstraint = ${bc.pathLenConstraint}`);
*/ formatted[0] = ` Basic Constraints:${criticalTag}\n${indent(4, parts)}`;
function describeSubjectAlternativeName(extension) { break;
const names = [];
if (Object.hasOwn(extension, "extname") && extension.extname === "subjectAltName") {
if (Object.hasOwn(extension, "array")) {
for (const altName of extension.array) {
Object.keys(altName).forEach((key) => {
switch (key) {
case "rfc822":
names.push(`EMAIL: ${altName[key]}`);
break;
case "dns":
names.push(`DNS: ${altName[key]}`);
break;
case "uri":
names.push(`URI: ${altName[key]}`);
break;
case "ip":
names.push(`IP: ${altName[key]}`);
break;
case "dn":
names.push(`DIR: ${altName[key].str}`);
break;
case "other" :
names.push(`Other: ${altName[key].oid}::${altName[key].value.utf8str.str}`);
break;
default:
names.push(`(unable to format SAN '${key}':${altName[key]})\n`);
}
});
} }
case ID_CE_KEY_USAGE: {
const ku = AsnParser.parse(value, KeyUsage);
formatted[1] = ` Key Usage:${criticalTag}\n${indent(4, describeKeyUsage(ku.toNumber()))}`;
break;
}
case ID_CE_EXT_KEY_USAGE: {
const eku = AsnParser.parse(value, ExtendedKeyUsage);
formatted[2] = ` Extended Key Usage:${criticalTag}\n${indent(4, describeExtendedKeyUsage(Array.from(eku)))}`;
break;
}
case ID_CE_SUBJECT_ALT_NAME: {
const san = AsnParser.parse(value, SubjectAlternativeName);
const items = san.map(gn => formatGeneralName(gn, "csr"));
formatted[3] = ` Subject Alternative Name:${criticalTag}\n${indent(4, items)}`;
break;
}
default:
tail.push(` ${ext.extnID}:${criticalTag}\n${indent(4, ["(unsuported extension)"])}`);
} }
} }
return names; let out = "\n";
for (const block of [...formatted, ...tail]) {
if (block && block.length !== 0) out += block;
}
return chop(out);
}
/**
* Translate the KeyUsage bit-string flags into the bit-order list of
* human-readable names the existing golden fixtures expect.
*
* @param {number} flags
* @returns {string[]}
*/
function describeKeyUsage(flags) {
const bitOrder = [
[0x001, "Digital Signature"],
[0x002, "Non-repudiation"],
[0x004, "Key encipherment"],
[0x008, "Data encipherment"],
[0x010, "Key agreement"],
[0x020, "Key certificate signing"],
[0x040, "CRL signing"],
[0x080, "Encipher Only"],
[0x100, "Decipher Only"],
];
const out = [];
for (const [bit, label] of bitOrder) {
if (flags & bit) out.push(label);
}
if (out.length === 0) out.push("(none)");
return out;
}
/**
* Translate the EKU OIDs (and aliases) into the human-readable names the
* existing golden fixtures expect.
*
* @param {string[]} usages - List of OIDs/short names.
* @returns {string[]}
*/
function describeExtendedKeyUsage(usages) {
const ekuIdentifierToName = {
"1.3.6.1.5.5.7.3.1": "TLS Web Server Authentication",
"1.3.6.1.5.5.7.3.2": "TLS Web Client Authentication",
"1.3.6.1.5.5.7.3.3": "Code signing",
"1.3.6.1.5.5.7.3.4": "E-mail Protection (S/MIME)",
"1.3.6.1.5.5.7.3.8": "Trusted Timestamping",
"serverAuth": "TLS Web Server Authentication",
"clientAuth": "TLS Web Client Authentication",
"codeSigning": "Code signing",
"emailProtection": "E-mail Protection (S/MIME)",
"timeStamping": "Trusted Timestamping",
"1.3.6.1.4.1.311.2.1.21": "Microsoft Individual Code Signing",
"1.3.6.1.4.1.311.2.1.22": "Microsoft Commercial Code Signing",
"1.3.6.1.4.1.311.10.3.1": "Microsoft Trust List Signing",
"1.3.6.1.4.1.311.10.3.3": "Microsoft Server Gated Crypto",
"1.3.6.1.4.1.311.10.3.4": "Microsoft Encrypted File System",
"1.3.6.1.4.1.311.20.2.2": "Microsoft Smartcard Login",
"2.16.840.1.113730.4.1": "Netscape Server Gated Crypto",
};
const out = usages.map(eku => ekuIdentifierToName[eku] || eku);
if (out.length === 0) out.push("(none)");
return out;
} }
/** /**
* Join an array of strings and add leading spaces to each line. * Join an array of strings and add leading spaces to each line.
* @param {*} n How many leading spaces *
* @param {*} parts Array of strings * @param {number} n
* @returns Joined and indented string. * @param {string[]} parts
* @returns {string}
*/ */
function indent(n, parts) { function indent(n, parts) {
const fluff = " ".repeat(n); const fluff = " ".repeat(n);
@ -379,12 +301,13 @@ function indent(n, parts) {
} }
/** /**
* Remove last character from a string. * Remove the last character from a string.
* @param {*} s String *
* @returns Chopped string. * @param {string} s
* @returns {string}
*/ */
function chop(s) { function chop(s) {
return s.substring(0, s.length - 1); return s.length === 0 ? s : s.substring(0, s.length - 1);
} }
export default ParseCSR; export default ParseCSR;

View File

@ -4,13 +4,50 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign"; import { X509Crl } from "@peculiar/x509";
import { AsnParser } from "@peculiar/asn1-schema";
import {
AuthorityKeyIdentifier, CertificateList, CRLDistributionPoints, CRLNumber, CRLReason,
InvalidityDate, IssueAlternativeName,
} from "@peculiar/asn1-x509";
import * as asn1X509 from "@peculiar/asn1-x509";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import { fromBase64 } from "../lib/Base64.mjs";
import { toHex } from "../lib/Hex.mjs";
import { formatDnObj } from "../lib/PublicKey.mjs";
import OperationError from "../errors/OperationError.mjs"; import OperationError from "../errors/OperationError.mjs";
import Utils from "../Utils.mjs"; import { formatDnObj } from "../lib/PublicKey.mjs";
import {
bytesToHex,
decodeX509Input,
formatGeneralName,
sigAlgOidToName,
} from "../lib/X509.mjs";
const ID_CE_AUTHORITY_KEY_IDENTIFIER = asn1X509.id_ce_authorityKeyIdentifier;
const ID_CE_CRL_DISTRIBUTION_POINTS = asn1X509.id_ce_cRLDistributionPoints;
const ID_CE_CRL_NUMBER = asn1X509.id_ce_cRLNumber;
const ID_CE_CRL_REASONS = asn1X509.id_ce_cRLReasons;
const ID_CE_INVALIDITY_DATE = asn1X509.id_ce_invalidityDate;
const ID_CE_ISSUER_ALT_NAME = asn1X509.id_ce_issuerAltName;
const HOLD_INSTRUCTION_EXT_OID = "2.5.29.23";
const CRL_REASON_TO_NAME = {
0: "Unspecified",
1: "Key Compromise",
2: "CA Compromise",
3: "Affiliation Changed",
4: "Superseded",
5: "Cessation Of Operation",
6: "Certificate Hold",
8: "Remove From CRL",
9: "Privilege Withdrawn",
10: "AA Compromise",
};
const HOLD_INSTRUCTION_OID_TO_NAME = {
"1.2.840.10040.2.1": "Hold Instruction None",
"1.2.840.10040.2.2": "Hold Instruction Call Issuer",
"1.2.840.10040.2.3": "Hold Instruction Reject",
};
/** /**
* Parse X.509 CRL operation * Parse X.509 CRL operation
@ -48,344 +85,311 @@ class ParseX509CRL extends Operation {
/** /**
* @param {string} input * @param {string} input
* @param {Object[]} args * @param {Object[]} args
* @returns {string} Human-readable description of a Certificate Revocation List (CRL). * @returns {string}
*/ */
run(input, args) { run(input, args) {
if (!input.length) { if (!input.length) return "No input";
return "No input";
}
const inputFormat = args[0]; const inputFormat = args[0];
let derBytes;
let undefinedInputFormat = false;
try { try {
switch (inputFormat) { derBytes = decodeX509Input(input, inputFormat);
case "DER Hex":
input = input.replace(/\s/g, "").toLowerCase();
break;
case "PEM":
break;
case "Base64":
input = toHex(fromBase64(input, null, "byteArray"), "");
break;
case "Raw":
input = toHex(Utils.strToArrayBuffer(input), "");
break;
default:
undefinedInputFormat = true;
}
} catch (e) { } catch (e) {
throw "Certificate load error (non-certificate input?)"; throw new OperationError(`Certificate load error (non-certificate input?): ${e.message}`);
} }
if (undefinedInputFormat) throw "Undefined input format";
const crl = new r.X509CRL(input); let crl;
try {
crl = new X509Crl(derBytes);
} catch (e) {
throw new OperationError(`Certificate load error (non-certificate input?): ${e.message}`);
}
const asnCrl = AsnParser.parse(new Uint8Array(crl.rawData), CertificateList);
const sigAlgName = sigAlgOidToName(asnCrl.signatureAlgorithm.algorithm);
let out = `Certificate Revocation List (CRL): let out = `Certificate Revocation List (CRL):
Version: ${crl.getVersion() === null ? "1 (0x0)" : "2 (0x1)"} Version: ${crl.version === undefined || crl.version === 0 ? "1 (0x0)" : "2 (0x1)"}
Signature Algorithm: ${crl.getSignatureAlgorithmField()} Signature Algorithm: ${sigAlgName}
Issuer:\n${formatDnObj(crl.getIssuer(), 8)} Issuer:\n${formatDnObj(crl.issuerName.toJSON(), 8)}
Last Update: ${generalizedDateTimeToUTC(crl.getThisUpdate())} Last Update: ${crl.thisUpdate.toUTCString()}
Next Update: ${generalizedDateTimeToUTC(crl.getNextUpdate())}\n`; Next Update: ${crl.nextUpdate ? crl.nextUpdate.toUTCString() : "undefined"}\n`;
if (crl.getParam().ext !== undefined) { if (crl.extensions && crl.extensions.length > 0) {
out += `\tCRL extensions:\n${formatCRLExtensions(crl.getParam().ext, 8)}\n`; out += `\tCRL extensions:\n${formatCRLExtensions(crl.extensions, 8)}\n`;
} }
out += `Revoked Certificates:\n${formatRevokedCertificates(crl.getRevCertArray(), 4)} out += `Revoked Certificates:\n${formatRevokedCertificates(crl.entries, 4)}
Signature Value:\n${formatCRLSignature(crl.getSignatureValueHex(), 8)}`; Signature Value:\n${formatCRLSignature(bytesToHex(new Uint8Array(crl.signature)), 8)}`;
return out; return out;
} }
} }
/** /**
* Generalized date time string to UTC. * Format the CRL extensions block.
* @param {string} datetime *
* @returns UTC datetime string. * Extensions are emitted in OID-ascending order to match the legacy output:
* the old code sorted by `extname` string, but the asn1-x509 OID constants
* we get back from peculiar/x509 sort identically for the supported types.
*
* Unsupported extensions are listed as `<oid>:` followed by an
* "Unsupported CRL extension. Try openssl CLI." line same as before.
*
* @param {object[]} extensions - peculiar/x509 Extension objects.
* @param {number} indentSpaces
* @returns {string}
*/ */
function generalizedDateTimeToUTC(datetime) { function formatCRLExtensions(extensions, indentSpaces) {
// Ensure the string is in the correct format if (!Array.isArray(extensions) || extensions.length === 0) {
if (!/^\d{12,14}Z$/.test(datetime)) { return indentString("No CRL extensions.", indentSpaces);
throw new OperationError(`failed to format datetime string ${datetime}`);
} }
// Extract components // Sort to match the legacy alphabetical-by-extname ordering as closely
let centuary = "20"; // as possible. Use a synthetic name per OID.
if (datetime.length === 15) { const sorted = [...extensions].sort((a, b) => {
centuary = datetime.substring(0, 2); const an = extDisplayName(a.type);
datetime = datetime.slice(2); const bn = extDisplayName(b.type);
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
});
let out = "";
for (const ext of sorted) {
out += formatCRLExtension(ext) + "\n";
} }
const year = centuary + datetime.substring(0, 2);
const month = datetime.substring(2, 4);
const day = datetime.substring(4, 6);
const hour = datetime.substring(6, 8);
const minute = datetime.substring(8, 10);
const second = datetime.substring(10, 12);
// Construct ISO 8601 format string return indentString(chop(out), indentSpaces);
const isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
// Parse using standard Date object
const isoDateTime = new Date(isoString);
return isoDateTime.toUTCString();
} }
/** /**
* Format CRL extensions. * Pick a display name for an extension OID (used only for sort stability).
* @param {r.ExtParam[] | undefined} extensions *
* @param {Number} indent * @param {string} oid
* @returns Formatted string detailing CRL extensions. * @returns {string}
*/ */
function formatCRLExtensions(extensions, indent) { function extDisplayName(oid) {
if (Array.isArray(extensions) === false || extensions.length === 0) { switch (oid) {
return indentString(`No CRL extensions.`, indent); case ID_CE_AUTHORITY_KEY_IDENTIFIER: return "authorityKeyIdentifier";
case ID_CE_CRL_DISTRIBUTION_POINTS: return "cRLDistributionPoints";
case ID_CE_CRL_NUMBER: return "cRLNumber";
case ID_CE_ISSUER_ALT_NAME: return "issuerAltName";
default: return oid;
}
}
/**
* Format a single CRL extension.
*
* @param {object} ext - peculiar/x509 Extension
* @returns {string}
*/
function formatCRLExtension(ext) {
const value = new Uint8Array(ext.value);
if (ext.type === ID_CE_AUTHORITY_KEY_IDENTIFIER) {
let out = `X509v3 Authority Key Identifier:\n`;
const aki = AsnParser.parse(value, AuthorityKeyIdentifier);
if (aki.keyIdentifier) {
out += `\tkeyid:${colonHex(bytesToHex(new Uint8Array(aki.keyIdentifier.buffer))).toUpperCase()}\n`;
}
if (aki.authorityCertIssuer && aki.authorityCertIssuer.length > 0) {
for (const gn of aki.authorityCertIssuer) {
if (gn.directoryName) {
out += `\tDirName:${slashName(gn.directoryName)}\n`;
} else {
out += `\t${formatGeneralName(gn, "crl")}\n`;
}
}
}
if (aki.authorityCertSerialNumber) {
const serial = bytesToHex(new Uint8Array(aki.authorityCertSerialNumber)).toUpperCase();
out += `\tserial:${colonHex(serial)}\n`;
}
return chop(out);
} }
let out = ``; if (ext.type === ID_CE_CRL_DISTRIBUTION_POINTS) {
const dps = AsnParser.parse(value, CRLDistributionPoints);
extensions.sort((a, b) => { let out = `X509v3 CRL Distribution Points:\n`;
if (!Object.hasOwn(a, "extname") || !Object.hasOwn(b, "extname")) { for (const dp of dps) {
return 0; if (dp.distributionPoint && dp.distributionPoint.fullName) {
const fullName = `Full Name:\n${dp.distributionPoint.fullName.map(gn => ` ${formatGeneralName(gn, "crl")}`).join("\n")}`;
out += indentString(fullName, 4) + "\n";
}
} }
if (a.extname < b.extname) { return chop(out);
return -1; }
} else if (a.extname === b.extname) {
return 0; if (ext.type === ID_CE_CRL_NUMBER) {
const num = AsnParser.parse(value, CRLNumber);
const hex = BigInt(num.value).toString(16).toUpperCase();
return `X509v3 CRL Number:\n\t${hex}`;
}
if (ext.type === ID_CE_ISSUER_ALT_NAME) {
const ian = AsnParser.parse(value, IssueAlternativeName);
const lines = ian.map(gn => ` ${formatGeneralName(gn, "crl")}`).join("\n");
return `X509v3 Issuer Alternative Name:\n${lines}`;
}
return `${ext.type}:\n\tUnsupported CRL extension. Try openssl CLI.`;
}
/**
* Format an asn1-x509 Name as the OpenSSL slash representation
* `/C=…/ST=…/…`.
*
* @param {object} asnName
* @returns {string}
*/
function slashName(asnName) {
const OID_SHORT = {
"2.5.4.3": "CN", "2.5.4.4": "SN", "2.5.4.5": "serialNumber",
"2.5.4.6": "C", "2.5.4.7": "L", "2.5.4.8": "ST",
"2.5.4.9": "street", "2.5.4.10": "O", "2.5.4.11": "OU",
"2.5.4.12": "T", "2.5.4.42": "G", "2.5.4.43": "I",
"1.2.840.113549.1.9.1": "E",
};
let out = "";
for (const rdn of asnName) {
for (const atv of rdn) {
const key = OID_SHORT[atv.type] || atv.type;
out += `/${key}=${atv.value.toString()}`;
}
}
return out;
}
/**
* Format an array of bytes as `AA:BB:CC:...`.
*
* @param {string} hex
* @returns {string}
*/
function colonHex(hex) {
if (hex.length % 2 !== 0) hex = "0" + hex;
return chop(hex.replace(/(..)/g, "$&:"));
}
/**
* Format the revoked certificates list.
*
* @param {readonly object[]} entries - peculiar/x509 X509CrlEntry objects.
* @param {number} indentSpaces
* @returns {string}
*/
function formatRevokedCertificates(entries, indentSpaces) {
if (!entries || entries.length === 0) {
return indentString("No Revoked Certificates.", indentSpaces);
}
let out = "";
for (const entry of entries) {
out += `Serial Number: ${entry.serialNumber.toUpperCase()}
Revocation Date: ${entry.revocationDate.toUTCString()}\n`;
if (entry.extensions && entry.extensions.length > 0) {
out += `\tCRL entry extensions:\n${indentString(formatCRLEntryExtensions(entry.extensions), 2 * indentSpaces)}\n`;
}
}
return indentString(chop(out), indentSpaces);
}
/**
* Format the CRL entry extensions for a single revoked-certificate row.
*
* @param {object[]} extensions
* @returns {string}
*/
function formatCRLEntryExtensions(extensions) {
let out = "";
for (const ext of extensions) {
const value = new Uint8Array(ext.value);
if (ext.type === ID_CE_CRL_REASONS) {
const reason = AsnParser.parse(value, CRLReason);
const code = reason.reason;
const name = Object.prototype.hasOwnProperty.call(CRL_REASON_TO_NAME, code) ?
CRL_REASON_TO_NAME[code] :
`invalid reason code: ${code}`;
out += `X509v3 CRL Reason Code:\n ${name}\n`;
} else if (ext.type === HOLD_INSTRUCTION_EXT_OID) {
// Hold Instruction; payload is an OID
const oid = decodeOidValue(value);
const name = HOLD_INSTRUCTION_OID_TO_NAME[oid] || `${oid}: unknown hold instruction OID`;
out += `Hold Instruction Code:\n\t${name}\n`;
} else if (ext.type === ID_CE_INVALIDITY_DATE) {
const inv = AsnParser.parse(value, InvalidityDate);
out += `Invalidity Date:\n\t${inv.value.toUTCString()}\n`;
} else { } else {
return 1; out += `${ext.type}:\n\tUnsupported CRL entry extension. Try openssl CLI.\n`;
} }
});
extensions.forEach((ext) => {
if (!Object.hasOwn(ext, "extname")) {
throw new OperationError(`CRL entry extension object missing 'extname' key: ${ext}`);
}
switch (ext.extname) {
case "authorityKeyIdentifier":
out += `X509v3 Authority Key Identifier:\n`;
if (Object.hasOwn(ext, "kid")) {
out += `\tkeyid:${colonDelimitedHexFormatString(ext.kid.hex.toUpperCase())}\n`;
}
if (Object.hasOwn(ext, "issuer")) {
out += `\tDirName:${ext.issuer.str}\n`;
}
if (Object.hasOwn(ext, "sn")) {
out += `\tserial:${colonDelimitedHexFormatString(ext.sn.hex.toUpperCase())}\n`;
}
break;
case "cRLDistributionPoints":
out += `X509v3 CRL Distribution Points:\n`;
ext.array.forEach((distPoint) => {
const fullName = `Full Name:\n${formatGeneralNames(distPoint.dpname.full, 4)}`;
out += indentString(fullName, 4) + "\n";
});
break;
case "cRLNumber":
if (!Object.hasOwn(ext, "num")) {
throw new OperationError(`'cRLNumber' CRL entry extension missing 'num' key: ${ext}`);
}
out += `X509v3 CRL Number:\n\t${ext.num.hex.toUpperCase()}\n`;
break;
case "issuerAltName":
out += `X509v3 Issuer Alternative Name:\n${formatGeneralNames(ext.array, 4)}\n`;
break;
default:
out += `${ext.extname}:\n`;
out += `\tUnsupported CRL extension. Try openssl CLI.\n`;
break;
}
});
return indentString(chop(out), indent);
}
/**
* Format general names array.
* @param {Object[]} names
* @returns Multi-line formatted string describing all supported general name types.
*/
function formatGeneralNames(names, indent) {
let out = ``;
names.forEach((name) => {
const key = Object.keys(name)[0];
switch (key) {
case "ip":
out += `IP:${name.ip}\n`;
break;
case "dns":
out += `DNS:${name.dns}\n`;
break;
case "uri":
out += `URI:${name.uri}\n`;
break;
case "rfc822":
out += `EMAIL:${name.rfc822}\n`;
break;
case "dn":
out += `DIR:${name.dn.str}\n`;
break;
case "other":
out += `OtherName:${name.other.oid}::${Object.values(name.other.value)[0].str}\n`;
break;
default:
out += `${key}: unsupported general name type`;
break;
}
});
return indentString(chop(out), indent);
}
/**
* Colon-delimited hex formatted output.
* @param {string} hexString Hex String
* @returns String representing input hex string with colon delimiter.
*/
function colonDelimitedHexFormatString(hexString) {
if (hexString.length % 2 !== 0) {
hexString = "0" + hexString;
} }
return chop(hexString.replace(/(..)/g, "$&:"));
}
/**
* Format revoked certificates array
* @param {r.RevokedCertificate[] | null} revokedCertificates
* @param {Number} indent
* @returns Multi-line formatted string output of revoked certificates array
*/
function formatRevokedCertificates(revokedCertificates, indent) {
if (Array.isArray(revokedCertificates) === false || revokedCertificates.length === 0) {
return indentString("No Revoked Certificates.", indent);
}
let out=``;
revokedCertificates.forEach((revCert) => {
if (!Object.hasOwn(revCert, "sn") || !Object.hasOwn(revCert, "date")) {
throw new OperationError("invalid revoked certificate object, missing either serial number or date");
}
out += `Serial Number: ${revCert.sn.hex.toUpperCase()}
Revocation Date: ${generalizedDateTimeToUTC(revCert.date)}\n`;
if (Object.hasOwn(revCert, "ext") && Array.isArray(revCert.ext) && revCert.ext.length !== 0) {
out += `\tCRL entry extensions:\n${indentString(formatCRLEntryExtensions(revCert.ext), 2*indent)}\n`;
}
});
return indentString(chop(out), indent);
}
/**
* Format CRL entry extensions.
* @param {Object[]} exts
* @returns Formatted multi-line string describing CRL entry extensions.
*/
function formatCRLEntryExtensions(exts) {
let out = ``;
const crlReasonCodeToReasonMessage = {
0: "Unspecified",
1: "Key Compromise",
2: "CA Compromise",
3: "Affiliation Changed",
4: "Superseded",
5: "Cessation Of Operation",
6: "Certificate Hold",
8: "Remove From CRL",
9: "Privilege Withdrawn",
10: "AA Compromise",
};
const holdInstructionOIDToName = {
"1.2.840.10040.2.1": "Hold Instruction None",
"1.2.840.10040.2.2": "Hold Instruction Call Issuer",
"1.2.840.10040.2.3": "Hold Instruction Reject",
};
exts.forEach((ext) => {
if (!Object.hasOwn(ext, "extname")) {
throw new OperationError(`CRL entry extension object missing 'extname' key: ${ext}`);
}
switch (ext.extname) {
case "cRLReason":
if (!Object.hasOwn(ext, "code")) {
throw new OperationError(`'cRLReason' CRL entry extension missing 'code' key: ${ext}`);
}
out += `X509v3 CRL Reason Code:
${Object.hasOwn(crlReasonCodeToReasonMessage, ext.code) ? crlReasonCodeToReasonMessage[ext.code] : `invalid reason code: ${ext.code}`}\n`;
break;
case "2.5.29.23": // Hold instruction
out += `Hold Instruction Code:\n\t${Object.hasOwn(holdInstructionOIDToName, ext.extn.oid) ? holdInstructionOIDToName[ext.extn.oid] : `${ext.extn.oid}: unknown hold instruction OID`}\n`;
break;
case "2.5.29.24": // Invalidity Date
out += `Invalidity Date:\n\t${generalizedDateTimeToUTC(ext.extn.gentime.str)}\n`;
break;
default:
out += `${ext.extname}:\n`;
out += `\tUnsupported CRL entry extension. Try openssl CLI.\n`;
break;
}
});
return chop(out); return chop(out);
} }
/** /**
* Format CRL signature. * Decode a DER OBJECT IDENTIFIER from raw bytes (including the 0x06 tag).
* @param {String} sigHex *
* @param {Number} indent * @param {Uint8Array} bytes
* @returns String representing hex signature value formatted on multiple lines. * @returns {string}
*/ */
function formatCRLSignature(sigHex, indent) { function decodeOidValue(bytes) {
if (sigHex.length % 2 !== 0) { if (bytes[0] !== 0x06) throw new OperationError("Expected OBJECT IDENTIFIER");
sigHex = "0" + sigHex; // Length octet(s) follow tag; for any sensible OID this is one byte.
let i = 1;
if (bytes[i] >= 0x80) i += (bytes[i] & 0x7f);
i += 1;
const first = bytes[i++];
const arcs = [Math.floor(first / 40).toString(), (first % 40).toString()];
let acc = 0n;
for (; i < bytes.length; i++) {
acc = (acc << 7n) | BigInt(bytes[i] & 0x7f);
if ((bytes[i] & 0x80) === 0) {
arcs.push(acc.toString());
acc = 0n;
}
} }
return arcs.join(".");
return indentString(formatMultiLine(chop(sigHex.replace(/(..)/g, "$&:"))), indent);
} }
/** /**
* Format string onto multiple lines. * Format the CRL signature as colon-delimited byte pairs wrapped to 54
* @param {string} longStr * characters per line, indented.
* @returns String as a multi-line string. *
* @param {string} sigHex
* @param {number} indentSpaces
* @returns {string}
*/ */
function formatMultiLine(longStr) { function formatCRLSignature(sigHex, indentSpaces) {
if (sigHex.length % 2 !== 0) sigHex = "0" + sigHex;
const colonHexStr = chop(sigHex.replace(/(..)/g, "$&:"));
const lines = []; const lines = [];
for (let remain = colonHexStr; remain !== ""; remain = remain.substring(54)) {
for (let remain = longStr ; remain !== "" ; remain = remain.substring(54)) {
lines.push(remain.substring(0, 54)); lines.push(remain.substring(0, 54));
} }
return indentString(lines.join("\n"), indentSpaces);
return lines.join("\n");
} }
/** /**
* Indent a multi-line string by n spaces. * Indent a multi-line string by n spaces.
* @param {string} input String *
* @param {number} spaces How many leading spaces * @param {string} input
* @returns Indented string. * @param {number} spaces
* @returns {string}
*/ */
function indentString(input, spaces) { function indentString(input, spaces) {
const indent = " ".repeat(spaces); const pad = " ".repeat(spaces);
return input.replace(/^/gm, indent); return input.replace(/^/gm, pad);
} }
/** /**
* Remove last character from a string. * Remove the last character from a string.
* @param {string} s String *
* @returns Chopped string. * @param {string} s
* @returns {string}
*/ */
function chop(s) { function chop(s) {
if (s.length < 1) { return s.length === 0 ? s : s.substring(0, s.length - 1);
return s;
}
return s.substring(0, s.length - 1);
} }
export default ParseX509CRL; export default ParseX509CRL;

View File

@ -4,12 +4,22 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign"; import { X509Certificate, KeyUsagesExtension, BasicConstraintsExtension, ExtendedKeyUsageExtension, SubjectAlternativeNameExtension, SubjectKeyIdentifierExtension, AuthorityKeyIdentifierExtension, CRLDistributionPointsExtension } from "@peculiar/x509";
import { fromBase64 } from "../lib/Base64.mjs"; import { AsnParser } from "@peculiar/asn1-schema";
import { Certificate, SubjectAlternativeName, IssueAlternativeName } from "@peculiar/asn1-x509";
import { runHash } from "../lib/Hash.mjs"; import { runHash } from "../lib/Hash.mjs";
import { fromHex, toHex } from "../lib/Hex.mjs";
import { formatByteStr, formatDnObj } from "../lib/PublicKey.mjs"; import { formatByteStr, formatDnObj } from "../lib/PublicKey.mjs";
import {
bytesToHex,
decodeX509Input,
describeSpki,
formatGeneralName,
isDerEcdsaSignature,
parseDerEcdsaSignature,
sigAlgOidToName,
} from "../lib/X509.mjs";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import Utils from "../Utils.mjs"; import Utils from "../Utils.mjs";
/** /**
@ -51,162 +61,195 @@ class ParseX509Certificate extends Operation {
* @returns {string} * @returns {string}
*/ */
run(input, args) { run(input, args) {
if (!input.length) { if (!input.length) return "No input";
return "No input";
}
const cert = new r.X509(), const inputFormat = args[0];
inputFormat = args[0]; let derBytes;
let undefinedInputFormat = false;
try { try {
switch (inputFormat) { derBytes = decodeX509Input(input, inputFormat);
case "DER Hex":
input = input.replace(/\s/g, "").toLowerCase();
cert.readCertHex(input);
break;
case "PEM":
cert.readCertPEM(input);
break;
case "Base64":
cert.readCertHex(toHex(fromBase64(input, null, "byteArray"), ""));
break;
case "Raw":
cert.readCertHex(toHex(Utils.strToArrayBuffer(input), ""));
break;
default:
undefinedInputFormat = true;
}
} catch (e) { } catch (e) {
throw "Certificate load error (non-certificate input?)"; throw new OperationError(`Certificate load error (non-certificate input?): ${e.message}`);
} }
if (undefinedInputFormat) throw "Undefined input format";
const hex = Utils.strToArrayBuffer(Utils.byteArrayToChars(fromHex(cert.hex))), let cert;
sn = cert.getSerialNumberHex(), try {
issuer = cert.getIssuer(), cert = new X509Certificate(derBytes);
subject = cert.getSubject(), } catch (e) {
pk = cert.getPublicKey(), throw new OperationError(`Certificate load error (non-certificate input?): ${e.message}`);
pkFields = [], }
sig = cert.getSignatureValueHex();
let pkStr = "", const fingerprintInput = Utils.strToArrayBuffer(Utils.byteArrayToChars(Array.from(new Uint8Array(cert.rawData))));
sigStr = "",
extensions = "";
// Public Key fields const asnCert = AsnParser.parse(new Uint8Array(cert.rawData), Certificate);
pkFields.push({ const versionInt = (asnCert.tbsCertificate.version || 0) + 1;
key: "Algorithm", const serialHex = bytesToHex(new Uint8Array(asnCert.tbsCertificate.serialNumber));
value: pk.type const serialDecimal = serialHex.length ? BigInt("0x" + serialHex).toString() : "0";
}); const sigAlgOid = asnCert.signatureAlgorithm.algorithm;
const sigAlgName = sigAlgOidToName(sigAlgOid);
if (pk.type === "EC") { // ECDSA const spki = describeSpki(new Uint8Array(cert.publicKey.rawData));
pkFields.push({ const pkFields = [{ key: "Algorithm", value: spkiAlgorithmLabel(spki) }];
key: "Curve Name",
value: pk.curveName switch (spki.type) {
}); case "EC":
pkFields.push({ pkFields.push({ key: "Curve Name", value: spki.asn1Curve });
key: "Length", pkFields.push({ key: "Length", value: spki.bitLength + " bits" });
value: (((new r.BigInteger(pk.pubKeyHex, 16)).bitLength()-3) /2) + " bits" pkFields.push({ key: "pub", value: formatByteStr(spki.pubKeyHex, 16, 18) });
}); break;
pkFields.push({ case "DSA":
key: "pub", pkFields.push({ key: "pub", value: formatByteStr(spki.yHex, 16, 18) });
value: formatByteStr(pk.pubKeyHex, 16, 18) pkFields.push({ key: "P", value: formatByteStr(spki.pHex, 16, 18) });
}); pkFields.push({ key: "Q", value: formatByteStr(spki.qHex, 16, 18) });
} else if (pk.type === "DSA") { // DSA pkFields.push({ key: "G", value: formatByteStr(spki.gHex, 16, 18) });
pkFields.push({ break;
key: "pub", case "RSA":
value: formatByteStr(pk.y.toString(16), 16, 18) pkFields.push({ key: "Length", value: spki.bitLength + " bits" });
}); pkFields.push({ key: "Modulus", value: formatByteStr(spki.nHex, 16, 18) });
pkFields.push({ pkFields.push({ key: "Exponent", value: spki.eValue + " (0x" + spki.eValue.toString(16) + ")" });
key: "P", break;
value: formatByteStr(pk.p.toString(16), 16, 18) case "EdDSA":
}); pkFields.push({ key: "Curve Name", value: spki.curveName });
pkFields.push({ pkFields.push({ key: "pub", value: formatByteStr(spki.pubKeyHex, 16, 18) });
key: "Q", break;
value: formatByteStr(pk.q.toString(16), 16, 18) default:
}); pkFields.push({ key: "Error", value: "Unknown Public Key type" });
pkFields.push({ }
key: "G",
value: formatByteStr(pk.g.toString(16), 16, 18) let pkStr = "";
}); for (const field of pkFields) {
} else if (pk.e) { // RSA pkStr += ` ${field.key}:${(field.value + "\n").padStart(
pkFields.push({ 18 - (field.key.length + 3) + field.value.length + 1, " ")}`;
key: "Length", }
value: pk.n.bitLength() + " bits"
}); const sigHex = bytesToHex(new Uint8Array(cert.signature));
pkFields.push({ let sigStr;
key: "Modulus", if (isDerEcdsaSignature(sigHex)) {
value: formatByteStr(pk.n.toString(16), 16, 18) const { r, s } = parseDerEcdsaSignature(sigHex);
}); sigStr = ` r: ${formatByteStr(r, 16, 18)}\n s: ${formatByteStr(s, 16, 18)}`;
pkFields.push({
key: "Exponent",
value: pk.e + " (0x" + pk.e.toString(16) + ")"
});
} else { } else {
pkFields.push({ sigStr = ` Signature: ${formatByteStr(sigHex, 16, 18)}`;
key: "Error",
value: "Unknown Public Key type"
});
} }
// Format Public Key fields const nbDate = formatDate(formatAsn1Date(cert.notBefore));
for (let i = 0; i < pkFields.length; i++) { const naDate = formatDate(formatAsn1Date(cert.notAfter));
pkStr += ` ${pkFields[i].key}:${(pkFields[i].value + "\n").padStart( const issuerStr = formatDnObj(cert.issuerName.toJSON(), 2);
18 - (pkFields[i].key.length + 3) + pkFields[i].value.length + 1, const subjectStr = formatDnObj(cert.subjectName.toJSON(), 2);
" "
)}`;
}
// Signature fields const versionDisplay = `${versionInt} (0x${Utils.hex(versionInt - 1)})`;
let breakoutSig = false;
try {
breakoutSig = r.ASN1HEX.dump(sig).indexOf("SEQUENCE") === 0;
} catch (err) {
// Error processing signature, output without further breakout
}
if (breakoutSig) { // DSA or ECDSA const extensionsText = formatExtensions(cert);
sigStr = ` r: ${formatByteStr(r.ASN1HEX.getV(sig, 4), 16, 18)}
s: ${formatByteStr(r.ASN1HEX.getV(sig, 48), 16, 18)}`;
} else { // RSA or unknown
sigStr = ` Signature: ${formatByteStr(sig, 16, 18)}`;
}
// Extensions return `Version: ${versionDisplay}
try { Serial number: ${serialDecimal} (0x${serialHex})
extensions = cert.getInfo().split("X509v3 Extensions:\n")[1].split("signature")[0]; Algorithm ID: ${sigAlgName}
} catch (err) {}
const issuerStr = formatDnObj(issuer, 2),
nbDate = formatDate(cert.getNotBefore()),
naDate = formatDate(cert.getNotAfter()),
subjectStr = formatDnObj(subject, 2);
return `Version: ${cert.version} (0x${Utils.hex(cert.version - 1)})
Serial number: ${new r.BigInteger(sn, 16).toString()} (0x${sn})
Algorithm ID: ${cert.getSignatureAlgorithmField()}
Validity Validity
Not Before: ${nbDate} (dd-mm-yyyy hh:mm:ss) (${cert.getNotBefore()}) Not Before: ${nbDate} (dd-mm-yyyy hh:mm:ss) (${formatAsn1Date(cert.notBefore)})
Not After: ${naDate} (dd-mm-yyyy hh:mm:ss) (${cert.getNotAfter()}) Not After: ${naDate} (dd-mm-yyyy hh:mm:ss) (${formatAsn1Date(cert.notAfter)})
Issuer Issuer
${issuerStr} ${issuerStr}
Subject Subject
${subjectStr} ${subjectStr}
Fingerprints Fingerprints
MD5: ${runHash("md5", hex)} MD5: ${runHash("md5", fingerprintInput)}
SHA1: ${runHash("sha1", hex)} SHA1: ${runHash("sha1", fingerprintInput)}
SHA256: ${runHash("sha256", hex)} SHA256: ${runHash("sha256", fingerprintInput)}
Public Key Public Key
${pkStr.slice(0, -1)} ${pkStr.slice(0, -1)}
Certificate Signature Certificate Signature
Algorithm: ${cert.getSignatureAlgorithmName()} Algorithm: ${sigAlgName}
${sigStr} ${sigStr}
Extensions Extensions
${extensions}`; ${extensionsText}`;
} }
}
/**
* Format the algorithm label for the Public Key block. The golden fixtures
* use short labels for the classic key families: "EC", "DSA", "RSA".
*
* @param {object} spki
* @returns {string}
*/
function spkiAlgorithmLabel(spki) {
if (spki.type === "EC") return "EC";
if (spki.type === "DSA") return "DSA";
if (spki.type === "RSA") return "RSA";
if (spki.type === "EdDSA") return spki.curveName;
return "Unknown";
}
/**
* Format the certificate extensions block. Uses the rich peculiar/x509
* extension types where available, falls back to the OID for unknown ones.
*
* @param {object} cert
* @returns {string}
*/
function formatExtensions(cert) {
const lines = [];
for (const ext of cert.extensions) {
if (ext instanceof SubjectKeyIdentifierExtension) {
lines.push(` subjectKeyIdentifier${ext.critical ? " CRITICAL" : ""} :`);
lines.push(` ${ext.keyId}`);
} else if (ext instanceof AuthorityKeyIdentifierExtension) {
lines.push(` authorityKeyIdentifier${ext.critical ? " CRITICAL" : ""} :`);
if (ext.keyId) lines.push(` kid=${ext.keyId}`);
if (ext.certId && ext.certId.serialNumber) {
lines.push(` serial=${ext.certId.serialNumber.toUpperCase()}`);
}
} else if (ext instanceof BasicConstraintsExtension) {
lines.push(` basicConstraints${ext.critical ? " CRITICAL" : ""}:`);
lines.push(` cA=${ext.ca}` + (ext.pathLength !== undefined ? `,pathLen=${ext.pathLength}` : ""));
} else if (ext instanceof KeyUsagesExtension) {
lines.push(` keyUsage${ext.critical ? " CRITICAL" : ""}:`);
lines.push(` ${ext.usages.toString()}`);
} else if (ext instanceof ExtendedKeyUsageExtension) {
lines.push(` extKeyUsage${ext.critical ? " CRITICAL" : ""}:`);
for (const usage of ext.usages) lines.push(` ${usage}`);
} else if (ext instanceof SubjectAlternativeNameExtension) {
lines.push(` subjectAltName${ext.critical ? " CRITICAL" : ""}:`);
const asn = AsnParser.parse(new Uint8Array(ext.value), SubjectAlternativeName);
for (const gn of asn) lines.push(` ${formatGeneralName(gn, "csr")}`);
} else if (ext instanceof CRLDistributionPointsExtension) {
lines.push(` cRLDistributionPoints${ext.critical ? " CRITICAL" : ""}:`);
for (const dp of ext.distributionPoints) {
if (dp.distributionPoint && dp.distributionPoint.fullName) {
for (const gn of dp.distributionPoint.fullName) {
lines.push(` ${formatGeneralName(gn, "csr")}`);
}
}
}
} else if (ext.type === "2.5.29.18") { // issuerAltName
lines.push(` issuerAltName${ext.critical ? " CRITICAL" : ""}:`);
const asn = AsnParser.parse(new Uint8Array(ext.value), IssueAlternativeName);
for (const gn of asn) lines.push(` ${formatGeneralName(gn, "csr")}`);
} else {
lines.push(` ${ext.type}${ext.critical ? " CRITICAL" : ""} :`);
lines.push(` ${bytesToHex(new Uint8Array(ext.value))}`);
}
}
return lines.join("\n");
}
/**
* Format a JS Date as an ASN.1 UTCTime/GeneralizedTime string: `yymmddHHMMSSZ`
* or `yyyymmddHHMMSSZ` for dates past 2049.
*
* @param {Date} date
* @returns {string}
*/
function formatAsn1Date(date) {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hour = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
const sec = String(date.getUTCSeconds()).padStart(2, "0");
if (year < 1950 || year > 2049) {
return `${year}${month}${day}${hour}${min}${sec}Z`;
}
return `${String(year).slice(2)}${month}${day}${hour}${min}${sec}Z`;
} }
/** /**
@ -215,8 +258,8 @@ ${extensions}`;
* @param {string} dateStr * @param {string} dateStr
* @returns {string} * @returns {string}
*/ */
function formatDate (dateStr) { function formatDate(dateStr) {
if (dateStr.length === 13) { // UTC Time if (dateStr.length === 13) {
dateStr = (dateStr[0] < "5" ? "20" : "19") + dateStr; dateStr = (dateStr[0] < "5" ? "20" : "19") + dateStr;
} }
return dateStr[6] + dateStr[7] + "/" + return dateStr[6] + dateStr[7] + "/" +

View File

@ -4,7 +4,7 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign"; import { X509Certificate } from "@peculiar/x509";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs"; import OperationError from "../errors/OperationError.mjs";
@ -39,27 +39,31 @@ class PubKeyFromCert extends Operation {
let match; let match;
const regex = /-----BEGIN CERTIFICATE-----/g; const regex = /-----BEGIN CERTIFICATE-----/g;
while ((match = regex.exec(input)) !== null) { while ((match = regex.exec(input)) !== null) {
// find corresponding end tag
const indexBase64 = match.index + match[0].length; const indexBase64 = match.index + match[0].length;
const footer = "-----END CERTIFICATE-----"; const footer = "-----END CERTIFICATE-----";
const indexFooter = input.indexOf(footer, indexBase64); const indexFooter = input.indexOf(footer, indexBase64);
if (indexFooter === -1) { if (indexFooter === -1) {
throw new OperationError(`PEM footer '${footer}' not found`); throw new OperationError(`PEM footer '${footer}' not found`);
} }
const certPem = input.substring(match.index, indexFooter + footer.length); const certPem = input.substring(match.index, indexFooter + footer.length);
const cert = new r.X509();
cert.readCertPEM(certPem); let cert;
let pubKey;
try { try {
pubKey = cert.getPublicKey(); cert = new X509Certificate(certPem);
} catch { } catch {
throw new OperationError("Unsupported public key type"); throw new OperationError("Unsupported public key type");
} }
const pubKeyPem = r.KEYUTIL.getPEM(pubKey);
// PEM ends with '\n', so a new key always starts on a new line let pubKeyPem;
output += pubKeyPem; try {
pubKeyPem = cert.publicKey.toString("pem");
} catch {
throw new OperationError("Unsupported public key type");
}
// Normalise to LF endings + trailing newline so multi-cert input
// produces a clean separator between successive keys.
output += pubKeyPem.replace(/\r\n/g, "\n").replace(/\n?$/, "\n");
} }
return output; return output;
} }

View File

@ -4,9 +4,13 @@
* @license Apache-2.0 * @license Apache-2.0
*/ */
import r from "jsrsasign";
import Operation from "../Operation.mjs"; import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs"; import OperationError from "../errors/OperationError.mjs";
import {
parseKeyPem,
derivePublicKeyInfo,
keyInfoToPem,
} from "../lib/KeyConvert.mjs";
/** /**
* Public Key from Private Key operation * Public Key from Private Key operation
@ -39,7 +43,6 @@ class PubKeyFromPrivKey extends Operation {
let match; let match;
const regex = /-----BEGIN ((RSA |EC |DSA )?PRIVATE KEY)-----/g; const regex = /-----BEGIN ((RSA |EC |DSA )?PRIVATE KEY)-----/g;
while ((match = regex.exec(input)) !== null) { while ((match = regex.exec(input)) !== null) {
// find corresponding end tag
const indexBase64 = match.index + match[0].length; const indexBase64 = match.index + match[0].length;
const footer = `-----END ${match[1]}-----`; const footer = `-----END ${match[1]}-----`;
const indexFooter = input.indexOf(footer, indexBase64); const indexFooter = input.indexOf(footer, indexBase64);
@ -48,32 +51,15 @@ class PubKeyFromPrivKey extends Operation {
} }
const privKeyPem = input.substring(match.index, indexFooter + footer.length); const privKeyPem = input.substring(match.index, indexFooter + footer.length);
let privKey; let privInfo;
try { try {
privKey = r.KEYUTIL.getKey(privKeyPem); privInfo = parseKeyPem(privKeyPem);
} catch (err) { } catch (err) {
throw new OperationError(`Unsupported key type: ${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 const pubInfo = derivePublicKeyInfo(privInfo);
output += pubKeyPem; output += keyInfoToPem(pubInfo);
} }
return output; return output;
} }

View File

@ -318,31 +318,31 @@ TestRegister.addApiTests([
}), }),
it("chef.bake: should take compact JSON format from Chef Website as recipe", async () => { it("chef.bake: should take compact JSON format from Chef Website as recipe", async () => {
const result = await chef.bake("some input", [{"op": "To Morse Code", "args": ["Dash/Dot", "Backslash", "Comma"]}, {"op": "Hex to PEM", "args": ["SOMETHING"]}, {"op": "To Snake case", "args": [false]}]); const result = await chef.bake("some input", [{"op": "To Hex", "args": ["Space", 0]}, {"op": "Hex to PEM", "args": ["SOMETHING"]}, {"op": "To Snake case", "args": [false]}]);
assert.strictEqual(result.toString(), "begin_something_anananaaaaak_da_aaak_da_aaaaananaaaaaaan_da_aaaaaaanan_da_aaak_end_something"); assert.strictEqual(result.toString(), "begin_something_c_29_t_zs_bpbn_b_1_d_a_end_something");
}), }),
it("chef.bake: should accept Clean JSON format from Chef website as recipe", async () => { it("chef.bake: should accept Clean JSON format from Chef website as recipe", async () => {
const result = await chef.bake("some input", [ const result = await chef.bake("some input", [
{ "op": "To Morse Code", { "op": "To Hex",
"args": ["Dash/Dot", "Backslash", "Comma"] }, "args": ["Space", 0] },
{ "op": "Hex to PEM", { "op": "Hex to PEM",
"args": ["SOMETHING"] }, "args": ["SOMETHING"] },
{ "op": "To Snake case", { "op": "To Snake case",
"args": [false] } "args": [false] }
]); ]);
assert.strictEqual(result.toString(), "begin_something_anananaaaaak_da_aaak_da_aaaaananaaaaaaan_da_aaaaaaanan_da_aaak_end_something"); assert.strictEqual(result.toString(), "begin_something_c_29_t_zs_bpbn_b_1_d_a_end_something");
}), }),
it("chef.bake: should accept Clean JSON format from Chef website - args optional", async () => { it("chef.bake: should accept Clean JSON format from Chef website - args optional", async () => {
const result = await chef.bake("some input", [ const result = await chef.bake("some input", [
{ "op": "To Morse Code" }, { "op": "To Hex" },
{ "op": "Hex to PEM", { "op": "Hex to PEM",
"args": ["SOMETHING"] }, "args": ["SOMETHING"] },
{ "op": "To Snake case", { "op": "To Snake case",
"args": [false] } "args": [false] }
]); ]);
assert.strictEqual(result.toString(), "begin_something_aaaaaaaaaaaaaa_end_something"); assert.strictEqual(result.toString(), "begin_something_c_29_t_zs_bpbn_b_1_d_a_end_something");
}), }),
it("chef.bake: should accept operation names from Chef Website which contain forward slash", async () => { it("chef.bake: should accept operation names from Chef Website which contain forward slash", async () => {
@ -366,7 +366,7 @@ TestRegister.addApiTests([
{ "op": "Parse ASN.1 hex string", { "op": "Parse ASN.1 hex string",
"args": [0, 32] } "args": [0, 32] }
]); ]);
assert.strictEqual(result.toString(), `SEQUENCE\n INTEGER 05\n IA5String 'Anybody there?'\n`); assert.strictEqual(result.toString(), `SEQUENCE\n INTEGER 5\n IA5String "Anybody there?"`);
}), }),
it("Excluded operations: throw a sensible error when you try and call one", () => { it("Excluded operations: throw a sensible error when you try and call one", () => {

View File

@ -651,9 +651,10 @@ Password: 282760`;
it("Hex to PEM", () => { it("Hex to PEM", () => {
const result = chef.hexToPEM(chef.toHex("Yada Yada")); const result = chef.hexToPEM(chef.toHex("Yada Yada"));
const expected = `-----BEGIN CERTIFICATE-----\r const expected = `-----BEGIN CERTIFICATE-----
WWFkYSBZYWRh\r WWFkYSBZYWRh
-----END CERTIFICATE-----\r\n`; -----END CERTIFICATE-----
`;
assert.strictEqual(result.toString(), expected); assert.strictEqual(result.toString(), expected);
}), }),
@ -686,7 +687,13 @@ WWFkYSBZYWRh\r
}), }),
it("Parse ASN.1 Hex string", () => { it("Parse ASN.1 Hex string", () => {
assert.strictEqual(chef.parseASN1HexString(chef.toHex("Mouth-watering")).toString(), "UNKNOWN(77) 7574682d7761746572696e67\n"); // The bytes for "Mouth-watering" don't form a well-formed ASN.1 structure
// (tag 0x4d declares length 0x6f but only 12 bytes follow), so we report
// a parse error rather than a partial best-effort dump.
assert.strictEqual(
chef.parseASN1HexString(chef.toHex("Mouth-watering")).toString(),
"ASN.1 parse error: End of input reached before message was fully decoded (inconsistent offset and length values)"
);
}), }),
it("Parse DateTime", () => { it("Parse DateTime", () => {
@ -1178,4 +1185,3 @@ ExifImageHeight: 57`);
]); ]);

218
tests/operations/index.mjs Normal file
View File

@ -0,0 +1,218 @@
/**
* THIS FILE IS AUTOMATICALLY GENERATED BY src/core/config/scripts/generateOpsIndex.mjs
*
* @author john [john19696@protonmail.com]
* @author tlwr [toby@toby.codes]
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import {
setLongTestFailure,
logTestReport,
} from "../lib/utils.mjs";
import "../lib/wasmFetchPolyfill.mjs";
import TestRegister from "../lib/TestRegister.mjs";
import "./tests/A1Z26CipherDecode.mjs";
import "./tests/AESKeyWrap.mjs";
import "./tests/ASN1.mjs";
import "./tests/AlternatingCaps.mjs";
import "./tests/AnalyseUUID.mjs";
import "./tests/AvroToJSON.mjs";
import "./tests/BCD.mjs";
import "./tests/BLAKE2b.mjs";
import "./tests/BLAKE2s.mjs";
import "./tests/BLAKE3.mjs";
import "./tests/BSON.mjs";
import "./tests/BaconCipher.mjs";
import "./tests/Base32.mjs";
import "./tests/Base45.mjs";
import "./tests/Base58.mjs";
import "./tests/Base62.mjs";
import "./tests/Base64.mjs";
import "./tests/Base85.mjs";
import "./tests/Base92.mjs";
import "./tests/Bech32.mjs";
import "./tests/BitwiseOp.mjs";
import "./tests/Bombe.mjs";
import "./tests/ByteRepr.mjs";
import "./tests/CBORDecode.mjs";
import "./tests/CBOREncode.mjs";
import "./tests/CMAC.mjs";
import "./tests/CRCChecksum.mjs";
import "./tests/CSV.mjs";
import "./tests/CaesarBoxCipher.mjs";
import "./tests/CaretMdecode.mjs";
import "./tests/CartesianProduct.mjs";
import "./tests/CetaceanCipherDecode.mjs";
import "./tests/CetaceanCipherEncode.mjs";
import "./tests/ChaCha.mjs";
import "./tests/ChangeIPFormat.mjs";
import "./tests/CharEnc.mjs";
import "./tests/Charts.mjs";
import "./tests/CipherSaber2.mjs";
import "./tests/Ciphers.mjs";
import "./tests/Code.mjs";
import "./tests/Colossus.mjs";
import "./tests/Comment.mjs";
import "./tests/Compress.mjs";
import "./tests/ConditionalJump.mjs";
import "./tests/ConvertCoordinateFormat.mjs";
import "./tests/ConvertLeetSpeak.mjs";
import "./tests/ConvertToNATOAlphabet.mjs";
import "./tests/Crypt.mjs";
import "./tests/DateTime.mjs";
import "./tests/DefangIP.mjs";
import "./tests/DisassembleARM.mjs";
import "./tests/DropNthBytes.mjs";
import "./tests/ECDSA.mjs";
import "./tests/ELFInfo.mjs";
import "./tests/Enigma.mjs";
import "./tests/EscapeSmartCharacters.mjs";
import "./tests/ExtractAudioMetadata.mjs";
import "./tests/ExtractEmailAddresses.mjs";
import "./tests/ExtractHashes.mjs";
import "./tests/ExtractIPAddresses.mjs";
import "./tests/Fernet.mjs";
import "./tests/FileTree.mjs";
import "./tests/FlaskSession.mjs";
import "./tests/FletcherChecksum.mjs";
import "./tests/Float.mjs";
import "./tests/Fork.mjs";
import "./tests/FromDecimal.mjs";
import "./tests/GOST.mjs";
import "./tests/GenerateAllChecksums.mjs";
import "./tests/GenerateAllHashes.mjs";
import "./tests/GenerateDeBruijnSequence.mjs";
import "./tests/GenerateQRCode.mjs";
import "./tests/GetAllCasings.mjs";
import "./tests/Gunzip.mjs";
import "./tests/Gzip.mjs";
import "./tests/HASSH.mjs";
import "./tests/HKDF.mjs";
import "./tests/Hash.mjs";
import "./tests/HaversineDistance.mjs";
import "./tests/Hex.mjs";
import "./tests/Hexdump.mjs";
import "./tests/IPv6Transition.mjs";
import "./tests/Image.mjs";
import "./tests/IndexOfCoincidence.mjs";
import "./tests/JA3Fingerprint.mjs";
import "./tests/JA3SFingerprint.mjs";
import "./tests/JA4.mjs";
import "./tests/JSONBeautify.mjs";
import "./tests/JSONMinify.mjs";
import "./tests/JSONtoCSV.mjs";
import "./tests/JSONtoYAML.mjs";
import "./tests/JWK.mjs";
import "./tests/JWTDecode.mjs";
import "./tests/JWTSign.mjs";
import "./tests/JWTVerify.mjs";
import "./tests/Jq.mjs";
import "./tests/Jsonata.mjs";
import "./tests/Jump.mjs";
import "./tests/LS47.mjs";
import "./tests/LZNT1Decompress.mjs";
import "./tests/LZString.mjs";
import "./tests/LevenshteinDistance.mjs";
import "./tests/Lorenz.mjs";
import "./tests/LuhnChecksum.mjs";
import "./tests/MIMEDecoding.mjs";
import "./tests/MS.mjs";
import "./tests/Magic.mjs";
import "./tests/Media.mjs";
import "./tests/Modhex.mjs";
import "./tests/MorseCode.mjs";
import "./tests/MultipleBombe.mjs";
import "./tests/MurmurHash3.mjs";
import "./tests/NTLM.mjs";
import "./tests/NetBIOS.mjs";
import "./tests/NormaliseUnicode.mjs";
import "./tests/OTP.mjs";
import "./tests/PEMtoHex.mjs";
import "./tests/PGP.mjs";
import "./tests/PHP.mjs";
import "./tests/PHPSerialize.mjs";
import "./tests/ParityBit.mjs";
import "./tests/ParseCSR.mjs";
import "./tests/ParseEthernetFrame.mjs";
import "./tests/ParseIPRange.mjs";
import "./tests/ParseIPv4Header.mjs";
import "./tests/ParseObjectIDTimestamp.mjs";
import "./tests/ParseQRCode.mjs";
import "./tests/ParseSSHHostKey.mjs";
import "./tests/ParseTCP.mjs";
import "./tests/ParseTLSRecord.mjs";
import "./tests/ParseTLV.mjs";
import "./tests/ParseUDP.mjs";
import "./tests/ParseX509CRL.mjs";
import "./tests/ParseX509Certificate.mjs";
import "./tests/PowerSet.mjs";
import "./tests/Protobuf.mjs";
import "./tests/PubKeyFromCert.mjs";
import "./tests/PubKeyFromPrivKey.mjs";
import "./tests/RAKE.mjs";
import "./tests/RC6.mjs";
import "./tests/RSA.mjs";
import "./tests/Rabbit.mjs";
import "./tests/Regex.mjs";
import "./tests/Register.mjs";
import "./tests/RegularExpression.mjs";
import "./tests/RemoveANSIEscapeCodes.mjs";
import "./tests/RenderMarkdown.mjs";
import "./tests/RisonEncodeDecode.mjs";
import "./tests/Rotate.mjs";
import "./tests/SIGABA.mjs";
import "./tests/SM2.mjs";
import "./tests/SM4.mjs";
import "./tests/SQLBeautify.mjs";
import "./tests/Salsa20.mjs";
import "./tests/SeqUtils.mjs";
import "./tests/SetDifference.mjs";
import "./tests/SetIntersection.mjs";
import "./tests/SetUnion.mjs";
import "./tests/Shuffle.mjs";
// Cannot test operations that use the File type yet
// import "./tests/SplitColourChannels.mjs";
import "./tests/StrUtils.mjs";
import "./tests/StripIPv4Header.mjs";
import "./tests/StripTCPHeader.mjs";
import "./tests/StripUDPHeader.mjs";
import "./tests/Subsection.mjs";
import "./tests/SwapCase.mjs";
import "./tests/SymmetricDifference.mjs";
import "./tests/TakeNthBytes.mjs";
import "./tests/Template.mjs";
import "./tests/TextEncodingBruteForce.mjs";
import "./tests/TextIntegerConverter.mjs";
import "./tests/ToFromInsensitiveRegex.mjs";
import "./tests/TranslateDateTimeFormat.mjs";
import "./tests/Typex.mjs";
import "./tests/URLEncodeDecode.mjs";
import "./tests/UnescapeString.mjs";
import "./tests/Unicode.mjs";
import "./tests/Wrap.mjs";
import "./tests/XORChecksum.mjs";
import "./tests/XSalsa20.mjs";
import "./tests/XXTEA.mjs";
import "./tests/YARA.mjs";
const testStatus = {
allTestsPassing: true,
counts: {
total: 0,
}
};
setLongTestFailure();
const logOpsTestReport = logTestReport.bind(null, testStatus);
(async function() {
const results = await TestRegister.runTests();
logOpsTestReport(results);
})();

View File

@ -0,0 +1,94 @@
/**
* ASN.1 / OID / PEM tests.
*
* Covers the four operations backed by the in-house Asn1.mjs helper:
* - Hex to Object Identifier
* - Object Identifier to Hex
* - Hex to PEM
* - Parse ASN.1 hex string
*
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import TestRegister from "../../lib/TestRegister.mjs";
TestRegister.addTests([
{
name: "Hex to Object Identifier: rsaEncryption (1.2.840.113549.1.1.1)",
input: "2a864886f70d010101",
expectedOutput: "1.2.840.113549.1.1.1",
recipeConfig: [{ op: "Hex to Object Identifier", args: [] }],
},
{
name: "Hex to Object Identifier: commonName (2.5.4.3)",
input: "550403",
expectedOutput: "2.5.4.3",
recipeConfig: [{ op: "Hex to Object Identifier", args: [] }],
},
{
name: "Hex to Object Identifier: Microsoft CTL signing (1.3.6.1.4.1.311.2.1.4) — multi-byte arc",
input: "2b060104018237020104",
expectedOutput: "1.3.6.1.4.1.311.2.1.4",
recipeConfig: [{ op: "Hex to Object Identifier", args: [] }],
},
{
name: "Hex to Object Identifier: handles whitespace in input",
input: "55 04 03",
expectedOutput: "2.5.4.3",
recipeConfig: [{ op: "Hex to Object Identifier", args: [] }],
},
{
name: "Object Identifier to Hex: rsaEncryption",
input: "1.2.840.113549.1.1.1",
expectedOutput: "2a864886f70d010101",
recipeConfig: [{ op: "Object Identifier to Hex", args: [] }],
},
{
name: "Object Identifier to Hex: commonName",
input: "2.5.4.3",
expectedOutput: "550403",
recipeConfig: [{ op: "Object Identifier to Hex", args: [] }],
},
{
name: "Object Identifier to Hex: Microsoft CTL signing (multi-byte arc)",
input: "1.3.6.1.4.1.311.2.1.4",
expectedOutput: "2b060104018237020104",
recipeConfig: [{ op: "Object Identifier to Hex", args: [] }],
},
{
name: "Object Identifier to Hex: 2.999 (multi-byte first combined arc)",
input: "2.999",
expectedOutput: "8837",
recipeConfig: [{ op: "Object Identifier to Hex", args: [] }],
},
{
name: "Hex to PEM: short payload",
input: "48656c6c6f",
expectedOutput: "-----BEGIN CERTIFICATE-----\nSGVsbG8=\n-----END CERTIFICATE-----\n",
recipeConfig: [{ op: "Hex to PEM", args: ["CERTIFICATE"] }],
},
{
name: "Hex to PEM: wraps at 64 base64 characters",
// 60 bytes -> 80 base64 chars (no padding) -> wraps after 64
input: "00".repeat(60),
expectedOutput: "-----BEGIN PUBLIC KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAA\n-----END PUBLIC KEY-----\n",
recipeConfig: [{ op: "Hex to PEM", args: ["PUBLIC KEY"] }],
},
{
name: "Parse ASN.1 hex string: simple SEQUENCE of two INTEGERs",
input: "3006020101020102",
expectedOutput: "SEQUENCE\n INTEGER 1\n INTEGER 2",
recipeConfig: [{ op: "Parse ASN.1 hex string", args: [0, 32] }],
},
{
name: "Parse ASN.1 hex string: SEQUENCE { OID, NULL }",
input: "300d06092a864886f70d0107010500",
expectedOutput: "SEQUENCE\n ObjectIdentifier 1.2.840.113549.1.7.1\n NULL",
recipeConfig: [{ op: "Parse ASN.1 hex string", args: [0, 32] }],
},
]);

View File

@ -286,6 +286,22 @@ TestRegister.addTests([
} }
] ]
}, },
{
// Published known-answer test pinning correctness against the spec
// (not against the previous jsrsasign output). RFC 6979 Appendix
// A.2.5: curve P-256 (prime256v1), message "sample", SHA-256. The
// public key below is the SPKI form of the (Ux, Uy) point from the
// RFC; the r||s signature is the deterministic signature it lists.
name: "ECDSA Verify: RFC 6979 A.2.5 P-256/SHA-256 known-answer vector",
input: "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8",
expectedOutput: "Verified OK",
recipeConfig: [
{
"op": "ECDSA Verify",
"args": ["Auto", "SHA-256", "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYP7UuiVanTHJYet0xjVtaMBJuJI7\nYfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ==\n-----END PUBLIC KEY-----", "sample", "Raw"]
}
]
},
{ {
name: "ECDSA Verify: JSON signature missing r", name: "ECDSA Verify: JSON signature missing r",
input: JSON.stringify({s: JSON.parse(P256.signature.sha256.json).s}), input: JSON.stringify({s: JSON.parse(P256.signature.sha256.json).s}),

View File

@ -290,7 +290,7 @@ TestRegister.addTests([
{ {
name: "JWK to PEM: RSA Private Key", name: "JWK to PEM: RSA Private Key",
input: JSON.stringify(RSA_512.private.jwk), 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: [ recipeConfig: [
{ {
op: "JWK to PEM", op: "JWK to PEM",
@ -301,7 +301,7 @@ TestRegister.addTests([
{ {
name: "JWK to PEM: RSA Public Key", name: "JWK to PEM: RSA Public Key",
input: JSON.stringify(RSA_512.public.jwk), 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: [ recipeConfig: [
{ {
op: "JWK to PEM", op: "JWK to PEM",
@ -314,7 +314,7 @@ TestRegister.addTests([
{ {
name: "JWK to PEM: EC Private Key", name: "JWK to PEM: EC Private Key",
input: JSON.stringify(EC_P256.private.jwk), 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: [ recipeConfig: [
{ {
op: "JWK to PEM", op: "JWK to PEM",
@ -325,7 +325,7 @@ TestRegister.addTests([
{ {
name: "JWK to PEM: EC Public Key", name: "JWK to PEM: EC Public Key",
input: JSON.stringify(EC_P256.public.jwk), 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: [ recipeConfig: [
{ {
op: "JWK to PEM", op: "JWK to PEM",
@ -337,7 +337,7 @@ TestRegister.addTests([
{ {
name: "JWK to PEM: Array of keys", name: "JWK to PEM: Array of keys",
input: JSON.stringify([RSA_512.public.jwk, EC_P256.public.jwk]), 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: [ recipeConfig: [
{ {
op: "JWK to PEM", op: "JWK to PEM",
@ -348,7 +348,7 @@ TestRegister.addTests([
{ {
name: "JWK to PEM: JSON Web Key Set", name: "JWK to PEM: JSON Web Key Set",
input: JSON.stringify({"keys": [RSA_512.public.jwk, EC_P256.public.jwk]}), 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: [ recipeConfig: [
{ {
op: "JWK to PEM", op: "JWK to PEM",

View File

@ -0,0 +1,138 @@
/**
* Parse X.509 Certificate tests.
*
* Added as part of the @peculiar/x509 migration (PR 5) to give the
* operation regression coverage it previously lacked. The certificate
* fixtures are reused from PubKeyFromCert.mjs.
*
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2016
* @license Apache-2.0
*/
import TestRegister from "../../lib/TestRegister.mjs";
const RSA_CERT = `-----BEGIN CERTIFICATE-----
MIIBfTCCASegAwIBAgIUeisK5Nwss2DGg5PCs4uSxxXyyNkwDQYJKoZIhvcNAQEL
BQAwEzERMA8GA1UEAwwIUlNBIHRlc3QwHhcNMjExMTE5MTcyMDI2WhcNMzExMTE3
MTcyMDI2WjATMREwDwYDVQQDDAhSU0EgdGVzdDBcMA0GCSqGSIb3DQEBAQUAA0sA
MEgCQQDyq9A6emHSLczn5Omu5muy+AReC53pTGCrW6Bi65OoobahT2RUSzXCYuvB
757fLLTKz+dLeo6sFkNhIzHZI+n7AgMBAAGjUzBRMB0GA1UdDgQWBBRO+jvkqq5p
pnQgwMMnRoun6e7eiTAfBgNVHSMEGDAWgBRO+jvkqq5ppnQgwMMnRoun6e7eiTAP
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA0EAR/5HAZM5qBhU/ezDUIFx
gmUGoFbIb5kJD41YCnaSdrgWglh4He4melSs42G/oxBBjuCJ0bUpqWnLl+lJkv1z
IA==
-----END CERTIFICATE-----`;
const RSA_EXPECTED = `Version: 3 (0x02)
Serial number: 697456755083946472681082503344832984412880816345 (0x7a2b0ae4dc2cb360c68393c2b38b92c715f2c8d9)
Algorithm ID: SHA256withRSA
Validity
Not Before: 19/11/2021 17:20:26 (dd-mm-yyyy hh:mm:ss) (211119172026Z)
Not After: 17/11/2031 17:20:26 (dd-mm-yyyy hh:mm:ss) (311117172026Z)
Issuer
CN = RSA test
Subject
CN = RSA test
Fingerprints
MD5: 0807638eee1403fbcacc1b6d25e00d95
SHA1: cae48f6fac74e143e10e0d8db1597385d62f24c4
SHA256: cd9125e6e7caa729c766e4d9ed84ef44e6bc57d00614c5f051af661e9ce3e436
Public Key
Algorithm: RSA
Length: 512 bits
Modulus: f2:ab:d0:3a:7a:61:d2:2d:cc:e7:e4:e9:ae:e6:6b:b2:
f8:04:5e:0b:9d:e9:4c:60:ab:5b:a0:62:eb:93:a8:a1:
b6:a1:4f:64:54:4b:35:c2:62:eb:c1:ef:9e:df:2c:b4:
ca:cf:e7:4b:7a:8e:ac:16:43:61:23:31:d9:23:e9:fb
Exponent: 65537 (0x10001)
Certificate Signature
Algorithm: SHA256withRSA
Signature: 47:fe:47:01:93:39:a8:18:54:fd:ec:c3:50:81:71:82:
65:06:a0:56:c8:6f:99:09:0f:8d:58:0a:76:92:76:b8:
16:82:58:78:1d:ee:26:7a:54:ac:e3:61:bf:a3:10:41:
8e:e0:89:d1:b5:29:a9:69:cb:97:e9:49:92:fd:73:20
Extensions
subjectKeyIdentifier :
4efa3be4aaae69a67420c0c327468ba7e9eede89
authorityKeyIdentifier :
kid=4efa3be4aaae69a67420c0c327468ba7e9eede89
basicConstraints CRITICAL:
cA=true`;
const EC_P256_CERT = `-----BEGIN CERTIFICATE-----
MIIBfzCCASWgAwIBAgIUK4H8J3Hr7NpRLPrACj8Pje4JJJ0wCgYIKoZIzj0EAwIw
FTETMBEGA1UEAwwKUC0yNTYgdGVzdDAeFw0yMTExMTkxNzE5NDVaFw0zMTExMTcx
NzE5NDVaMBUxEzARBgNVBAMMClAtMjU2IHRlc3QwWTATBgcqhkjOPQIBBggqhkjO
PQMBBwNCAAQNRzwDQQM0qgJgg9YwfPXJTOoTmYmC6yBwATwfrzXR+QnxmZM2IIJr
qwuBHa8PVU2HZ2KKtaAo8fg9Uwpq/l7po1MwUTAdBgNVHQ4EFgQU/SxodXrpkybM
gcIgkxnRKd7HMzowHwYDVR0jBBgwFoAU/SxodXrpkybMgcIgkxnRKd7HMzowDwYD
VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiBU9PrOa/kXCpTTBInRf/sN
ac2iDHmbdpWzcXI+xLKNYAIhAIRR1LRSHVwOTLQ/iBXd+8LCkm5aTB27RW46LN80
ylxt
-----END CERTIFICATE-----`;
const EC_P256_EXPECTED = `Version: 3 (0x02)
Serial number: 248385364994530420657018049397175541334954026141 (0x2b81fc2771ebecda512cfac00a3f0f8dee09249d)
Algorithm ID: SHA256withECDSA
Validity
Not Before: 19/11/2021 17:19:45 (dd-mm-yyyy hh:mm:ss) (211119171945Z)
Not After: 17/11/2031 17:19:45 (dd-mm-yyyy hh:mm:ss) (311117171945Z)
Issuer
CN = P-256 test
Subject
CN = P-256 test
Fingerprints
MD5: d58a07c73ac5353acd1799174472478f
SHA1: 562feb8b1a2c9808a98b350557ab80eab619ed48
SHA256: 584662f4632e221a1d58f91f772fb6f617af7aa3d8542281af2efcc93c1b79eb
Public Key
Algorithm: EC
Curve Name: secp256r1
Length: 256 bits
pub: 04:0d:47:3c:03:41:03:34:aa:02:60:83:d6:30:7c:f5:
c9:4c:ea:13:99:89:82:eb:20:70:01:3c:1f:af:35:d1:
f9:09:f1:99:93:36:20:82:6b:ab:0b:81:1d:af:0f:55:
4d:87:67:62:8a:b5:a0:28:f1:f8:3d:53:0a:6a:fe:5e:
e9
Certificate Signature
Algorithm: SHA256withECDSA
r: 54:f4:fa:ce:6b:f9:17:0a:94:d3:04:89:d1:7f:fb:0d:
69:cd:a2:0c:79:9b:76:95:b3:71:72:3e:c4:b2:8d:60
s: 84:51:d4:b4:52:1d:5c:0e:4c:b4:3f:88:15:dd:fb:c2:
c2:92:6e:5a:4c:1d:bb:45:6e:3a:2c:df:34:ca:5c:6d
Extensions
subjectKeyIdentifier :
fd2c68757ae99326cc81c2209319d129dec7333a
authorityKeyIdentifier :
kid=fd2c68757ae99326cc81c2209319d129dec7333a
basicConstraints CRITICAL:
cA=true`;
TestRegister.addTests([
{
name: "Parse X.509 certificate: No input",
input: "",
expectedOutput: "No input",
recipeConfig: [
{ op: "Parse X.509 certificate", args: ["PEM"] }
],
},
{
name: "Parse X.509 certificate: RSA / PEM",
input: RSA_CERT,
expectedOutput: RSA_EXPECTED,
recipeConfig: [
{ op: "Parse X.509 certificate", args: ["PEM"] }
],
},
{
name: "Parse X.509 certificate: EC P-256 / PEM",
input: EC_P256_CERT,
expectedOutput: EC_P256_EXPECTED,
recipeConfig: [
{ op: "Parse X.509 certificate", args: ["PEM"] }
],
},
]);

View File

@ -99,11 +99,10 @@ HRMBAf8EBTADAQH/MAUGAytlcANBAI/+03iVq4yJ+DaLVs61w41cVX2UxKvquSzv
lllkpkclM9LH5dLrw4ArdTjS9zAjzY/02WkphHhICHXt3KqZTwI= lllkpkclM9LH5dLrw4ArdTjS9zAjzY/02WkphHhICHXt3KqZTwI=
-----END CERTIFICATE-----`; -----END CERTIFICATE-----`;
/* // Cross-checked against `openssl x509 -pubkey -noout` on ED25519_CERT.
const ED25519_PUBKEY = `-----BEGIN PUBLIC KEY----- const ED25519_PUBKEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAELP6AflXwsuZ5q4NDIO0LP2iCdKRvds4nwsUmRhOw3g= MCowBQYDK2VwAyEAELP6AflXwsuZ5q4NDIO0LP2iCdKRvds4nwsUmRhOw3g=
-----END PUBLIC KEY-----`; -----END PUBLIC KEY-----`;
*/
const ED448_CERT = `-----BEGIN CERTIFICATE----- const ED448_CERT = `-----BEGIN CERTIFICATE-----
MIIBijCCAQqgAwIBAgIUZaCS7zEjOnQ7O4KUFym6fJF5vl8wBQYDK2VxMBUxEzAR MIIBijCCAQqgAwIBAgIUZaCS7zEjOnQ7O4KUFym6fJF5vl8wBQYDK2VxMBUxEzAR
@ -117,12 +116,12 @@ VkLqpoDNMRcM3Eb6h3AJpQM0oxGj8q9arjDXqJkXgaO2e0tVn8KKVfy7S8qO72Kd
rWzZowcOjnWKhXm7JgA= rWzZowcOjnWKhXm7JgA=
-----END CERTIFICATE-----`; -----END CERTIFICATE-----`;
/* // Cross-checked against the SPKI extracted directly from ED448_CERT's DER
// bytes (openssl 3.6.2 in this environment does not support Ed448).
const ED448_PUBKEY = `-----BEGIN PUBLIC KEY----- const ED448_PUBKEY = `-----BEGIN PUBLIC KEY-----
MEMwBQYDK2VxAzoAVN8kG0TMVyGOu/OvBTe8H0Wi4HJrQAlSv4XLwJbkuoi4EeRl MEMwBQYDK2VxAzoAVN8kG0TMVyGOu/OvBTe8H0Wi4HJrQAlSv4XLwJbkuoi4EeRl
EHQwXsNYLZTtY2Jra6AWhbVYYaEA EHQwXsNYLZTtY2Jra6AWhbVYYaEA
-----END PUBLIC KEY-----` -----END PUBLIC KEY-----`;
*/
TestRegister.addTests([ TestRegister.addTests([
{ {
@ -141,7 +140,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Certificate: RSA", name: "Public Key from Certificate: RSA",
input: RSA_CERT, input: RSA_CERT,
expectedOutput: (RSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), expectedOutput: (RSA_PUBKEY + "\n").replace(/\r/g, ""),
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Certificate", op: "Public Key from Certificate",
@ -154,7 +153,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Certificate: EC", name: "Public Key from Certificate: EC",
input: EC_P256_CERT, input: EC_P256_CERT,
expectedOutput: (EC_P256_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), expectedOutput: (EC_P256_PUBKEY + "\n").replace(/\r/g, ""),
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Certificate", op: "Public Key from Certificate",
@ -167,7 +166,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Certificate: DSA", name: "Public Key from Certificate: DSA",
input: DSA_CERT, input: DSA_CERT,
expectedOutput: (DSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), expectedOutput: (DSA_PUBKEY + "\n").replace(/\r/g, ""),
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Certificate", op: "Public Key from Certificate",
@ -180,7 +179,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Certificate: Ed25519", name: "Public Key from Certificate: Ed25519",
input: ED25519_CERT, input: ED25519_CERT,
expectedOutput: "Unsupported public key type", expectedOutput: ED25519_PUBKEY + "\n",
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Certificate", op: "Public Key from Certificate",
@ -191,7 +190,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Certificate: Ed448", name: "Public Key from Certificate: Ed448",
input: ED448_CERT, input: ED448_CERT,
expectedOutput: "Unsupported public key type", expectedOutput: ED448_PUBKEY + "\n",
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Certificate", op: "Public Key from Certificate",
@ -204,7 +203,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Certificate: Multiple certificates", name: "Public Key from Certificate: Multiple certificates",
input: RSA_CERT + "\n" + EC_P256_CERT, input: RSA_CERT + "\n" + EC_P256_CERT,
expectedOutput: (RSA_PUBKEY + "\n" + EC_P256_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), expectedOutput: (RSA_PUBKEY + "\n" + EC_P256_PUBKEY + "\n").replace(/\r/g, ""),
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Certificate", op: "Public Key from Certificate",

View File

@ -147,7 +147,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Private Key: RSA PKCS#1", name: "Public Key from Private Key: RSA PKCS#1",
input: RSA_PRIVKEY_PKCS1, input: RSA_PRIVKEY_PKCS1,
expectedOutput: (RSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), expectedOutput: (RSA_PUBKEY + "\n"),
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Private Key", op: "Public Key from Private Key",
@ -158,7 +158,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Private Key: RSA PKCS#8", name: "Public Key from Private Key: RSA PKCS#8",
input: RSA_PRIVKEY_PKCS8, input: RSA_PRIVKEY_PKCS8,
expectedOutput: (RSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), expectedOutput: (RSA_PUBKEY + "\n"),
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Private Key", op: "Public Key from Private Key",
@ -171,7 +171,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Private Key: EC SEC1", name: "Public Key from Private Key: EC SEC1",
input: EC_P256_PRIVKEY_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: [ recipeConfig: [
{ {
op: "Public Key from Private Key", op: "Public Key from Private Key",
@ -182,7 +182,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Private Key: EC PKCS#8", name: "Public Key from Private Key: EC PKCS#8",
input: EC_P256_PRIVKEY_PKCS8, input: EC_P256_PRIVKEY_PKCS8,
expectedOutput: (EC_P256_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), expectedOutput: (EC_P256_PUBKEY + "\n"),
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Private Key", op: "Public Key from Private Key",
@ -195,7 +195,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Private Key: DSA Traditional", name: "Public Key from Private Key: DSA Traditional",
input: DSA_PRIVKEY_TRAD, input: DSA_PRIVKEY_TRAD,
expectedOutput: (DSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), expectedOutput: (DSA_PUBKEY + "\n"),
recipeConfig: [ recipeConfig: [
{ {
op: "Public Key from Private Key", op: "Public Key from Private Key",
@ -219,7 +219,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Private Key: Ed25519", name: "Public Key from Private Key: Ed25519",
input: ED25519_PRIVKEY, 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: [ recipeConfig: [
{ {
op: "Public Key from Private Key", op: "Public Key from Private Key",
@ -230,7 +230,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Private Key: Ed448", name: "Public Key from Private Key: Ed448",
input: ED448_PRIVKEY, 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: [ recipeConfig: [
{ {
op: "Public Key from Private Key", op: "Public Key from Private Key",
@ -243,7 +243,7 @@ TestRegister.addTests([
{ {
name: "Public Key from Private Key: Multiple keys", name: "Public Key from Private Key: Multiple keys",
input: RSA_PRIVKEY_PKCS8 + "\n" + EC_P256_PRIVKEY_PKCS8, 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: [ recipeConfig: [
{ {
op: "Public Key from Private Key", op: "Public Key from Private Key",