Setup + ASN.1 utilities (PublicKey bundle)

This commit is contained in:
Leon Zandman 2026-05-17 00:24:39 +02:00
parent f942cf2d41
commit e5ba3d4b7e
12 changed files with 497 additions and 41 deletions

43
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",
@ -4237,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",
@ -4253,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",
@ -4267,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",
@ -4280,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",
@ -4293,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",
@ -4308,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",
@ -4321,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",
@ -4338,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",
@ -4351,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",
@ -4363,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",
@ -4376,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",
@ -4389,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",
@ -5489,14 +5496,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": {
@ -15323,7 +15329,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"
@ -15333,7 +15338,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"
@ -17418,14 +17422,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"
@ -17438,7 +17440,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

@ -96,11 +96,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",

View File

@ -4,14 +4,16 @@
## Status ## Status
- [ ] PR 1 — Setup + ASN.1 utilities - [x] PR 1 — Setup + ASN.1 utilities
- [ ] PR 2 — SM2 rewrite - [ ] PR 2 — SM2 rewrite
- [ ] PR 3 — ECDSA primitives - [ ] PR 3 — ECDSA primitives
- [ ] PR 4 — PEM/JWK conversion + key extraction - [ ] PR 4 — PEM/JWK conversion + key extraction
- [ ] PR 5 — X.509 / CSR / CRL parsing - [ ] PR 5 — X.509 / CSR / CRL parsing
- [ ] PR 6 — Removal - [ ] PR 6 — Removal
_Notes for next session:_ (none yet) _Notes for next session:_
- **PR 2 blocker:** `@noble/curves` v2 dropped the `/sm2` subpath. v2 exposes only `nist`, `secp256k1`, `bls12-381`, `bn254`, `ed25519`, `ed448` and the `abstract/*` primitives. Before starting PR 2, either pin `@noble/curves` to v1 (which still ships sm2 — but check what other v1→v2 API gaps that introduces) or build SM2 on top of the abstract Weierstrass primitive in `@noble/curves/abstract/weierstrass.js` (curve parameters published in GM/T 0003-2012).
- **PR 5 blocker:** `@peculiar/x509` v2 needs a `reflect-metadata` polyfill at every entry point — PR 1 pinned to `^1.14.3` to avoid that. Stay on v1 unless the polyfill cost gets resolved.
## Context ## Context
@ -236,6 +238,14 @@ After **PR 6:**
Record deviations from the original plan here, newest at the top. One bullet per change: what changed, why, and which PR. Record deviations from the original plan here, newest at the top. One bullet per change: what changed, why, and which PR.
### 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. <!-- e.g.
### PR 2 — 2026-05-20 ### PR 2 — 2026-05-20
- Added `@noble/hashes` peer-dep bump (was on ^1.x, needed ^2 for the `legacy` subpath). No code impact. - Added `@noble/hashes` peer-dep bump (was on ^1.x, needed ^2 for the `legacy` subpath). No code impact.

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

@ -0,0 +1,338 @@
/**
* ASN.1 / OID / PEM helpers.
*
* Replacements for the small jsrsasign utilities 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 (the old jsrsasign output used CRLF).
*
* Input parsing is intentionally lenient to match the previous jsrsasign
* behaviour: 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;
}
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 an indented dump similar to the
* one jsrsasign's ASN1HEX.dump produced.
*
* @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);
}

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

@ -319,7 +319,7 @@ 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 Morse Code", "args": ["Dash/Dot", "Backslash", "Comma"]}, {"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_dqanaaaa_cg_aka_ao_a_2_g_da_aaaaaaana_no_adqana_ao_a_cg_aaaaana_ao_a_2_g_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 () => {
@ -331,7 +331,7 @@ TestRegister.addApiTests([
{ "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_dqanaaaa_cg_aka_ao_a_2_g_da_aaaaaaana_no_adqana_ao_a_cg_aaaaana_ao_a_2_g_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 () => {
@ -342,7 +342,7 @@ TestRegister.addApiTests([
{ "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_aaaaaaaaaaaaaaa_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

@ -612,9 +612,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);
}), }),
@ -647,7 +648,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 the partial best-effort dump jsrsasign emitted.
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", () => {

View File

@ -17,6 +17,7 @@ import { setLongTestFailure, logTestReport } from "../lib/utils.mjs";
import TestRegister from "../lib/TestRegister.mjs"; import TestRegister from "../lib/TestRegister.mjs";
import "./tests/A1Z26CipherDecode.mjs"; import "./tests/A1Z26CipherDecode.mjs";
import "./tests/AESKeyWrap.mjs"; import "./tests/AESKeyWrap.mjs";
import "./tests/ASN1.mjs";
import "./tests/AnalyseUUID.mjs"; import "./tests/AnalyseUUID.mjs";
import "./tests/AlternatingCaps.mjs"; import "./tests/AlternatingCaps.mjs";
import "./tests/AvroToJSON.mjs"; import "./tests/AvroToJSON.mjs";

View File

@ -0,0 +1,95 @@
/**
* ASN.1 / OID / PEM tests.
*
* Covers the four operations migrated from jsrsasign to 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] }],
},
]);