Release Crypto Tools 0.2.0
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
- name: Select declared npm version
|
||||
run: npm install --global npm@11.17.0
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Audit runtime dependencies
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
- name: Check, test, and build
|
||||
run: npm run check
|
||||
- name: Install browser engines
|
||||
run: npx playwright install --with-deps chromium firefox webkit
|
||||
- name: Browser tests
|
||||
run: npm run test:browser
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Added strict PBES2/PBKDF2 encrypted PKCS #8 inspection and password-gated WebCrypto decryption for supported AES profiles.
|
||||
- Added explicit-input certificate path analysis without trust claims.
|
||||
- Added bounded PKCS #12 AuthenticatedSafe/SafeContents and bag inventory, supported MacData verification, and password-gated PBES2/PBKDF2/AES decryption for encrypted content and shrouded PKCS #8 keys.
|
||||
- Wipe standalone decrypted PKCS #8 buffers after inspection and WebCrypto
|
||||
operational imports, and enforce a sequential, aggregate PBKDF2 work budget
|
||||
across multi-object PEM and PKCS #12 inspection.
|
||||
- Added fixed-profile WebCrypto sign/verify and authenticated/limited encrypt/decrypt workspaces.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Added the initial local-first Crypto Tools workbench.
|
||||
|
||||
@@ -4,15 +4,21 @@ Inspect keys, certificates and signatures locally in the browser.
|
||||
|
||||
Crypto Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
|
||||
|
||||
## Version 0.1 scope
|
||||
## Current scope
|
||||
|
||||
- Bounded PEM and DER inspection for X.509 certificates, CSRs and CRLs
|
||||
- Identification and fingerprinting of public/private PEM key material without decryption
|
||||
- PBES2/PBKDF2 encrypted PKCS #8 parameter inspection and password-gated in-memory decryption for WebCrypto-supported AES-CBC/AES-GCM profiles
|
||||
- Bounded PKCS #12/PFX AuthenticatedSafe, SafeContents and nested bag inventory, including friendly names, local key IDs, X.509 certificates and private-key container metadata
|
||||
- Password-gated PKCS #12 MacData verification for SHA-1/SHA-2 plus modern PBES2/PBKDF2 AES-CBC/AES-GCM decryption for encrypted SafeContents and shrouded PKCS #8 keys
|
||||
- Identification and fingerprinting of public/private PEM key material
|
||||
- JWK/JWKS inspection, private-member warnings and RFC 7638 SHA-256 thumbprints
|
||||
- Explicit issuer-certificate signature links and DNS SAN hostname checks
|
||||
- Explicit certificate-path analysis across supplied certificates: issuer DN, AKI/SKI, signature, CA/keyCertSign, path-length and time checks, without claiming trust
|
||||
- DNS SAN hostname checks
|
||||
- Concrete, fixed-profile WebCrypto sign/verify operations (RSA-PSS, RSASSA-PKCS1-v1_5, ECDSA P-256 and browser-supported Ed25519)
|
||||
- Concrete RSA-OAEP SHA-256 and AES-256-GCM text encryption/decryption, with authenticated AES envelopes and explicit size/encoding limits
|
||||
- A local JSON inspection report with an explicit non-trust model
|
||||
|
||||
This is inspection, not complete RFC 5280 path validation, certificate trust, revocation checking, issuance, key generation or general signature verification. Inputs are bounded to 8 MiB and 256 PEM blocks. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
||||
This is not complete RFC 5280 validation, certificate trust, revocation checking, issuance, CMS/JWS tooling, legacy PKCS #1/SEC1 conversion or general-purpose file cryptography. PKCS #12 support intentionally rejects legacy PKCS #12 PBE/RC2/3DES, unknown CMS content, unsupported certificate types and excessive/malformed nesting; it does not implicitly export bag values. Inputs are bounded to 8 MiB and 256 PEM blocks, and password-gated inspection has a shared PBKDF2 work budget. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -26,7 +32,7 @@ npm run test:browser
|
||||
|
||||
## Release
|
||||
|
||||
`npm run release:artifact` creates a deterministic `release/crypto-tools-0.1.0.zip` and checksum sidecar.
|
||||
`npm run release:artifact` creates a deterministic `release/crypto-tools-0.2.0.zip` and checksum sidecar.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding source for Crypto Tools 0.1.0 is available at:
|
||||
The corresponding source for Crypto Tools 0.2.0 is available at:
|
||||
|
||||
https://git.add-ideas.de/lotobo/crypto-tools/src/tag/v0.1.0
|
||||
https://git.add-ideas.de/lotobo/crypto-tools/src/tag/v0.2.0
|
||||
|
||||
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||
|
||||
@@ -4,10 +4,11 @@ Crypto Tools 0.1.0 directly depends on these runtime packages:
|
||||
|
||||
| Package | Pinned version | Declared licence |
|
||||
| -------------------------------- | -------------: | ---------------- |
|
||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
|
||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
|
||||
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
|
||||
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
|
||||
| `@peculiar/x509` | 2.0.0 | MIT |
|
||||
| `asn1js` | 3.0.10 | BSD-3-Clause |
|
||||
| `reflect-metadata` | 0.2.2 | Apache-2.0 |
|
||||
| `react` | 19.2.8 | MIT |
|
||||
| `react-dom` | 19.2.8 | MIT |
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# Architecture
|
||||
|
||||
Crypto Tools is a static React/Vite inspection application wrapped in the shared Toolbox shell. `crypto/inspection.ts` accepts a bounded string or byte buffer, separates PEM blocks or JSON, and produces one plain report model. It uses `@peculiar/x509` for X.509/PKCS #10/CRL parsing and signature primitives, and browser WebCrypto for SHA-256 fingerprints and RFC 7638 thumbprints.
|
||||
Crypto Tools is a static React/Vite application wrapped in the shared Toolbox shell. `crypto/inspection.ts` accepts a bounded string or byte buffer, separates PEM blocks or JSON, and produces one plain report model. It uses `@peculiar/x509` for X.509/PKCS #10/CRL parsing and signature primitives, strict `asn1js` DER parsing for the supported PKCS containers, and browser WebCrypto for fingerprints, key import and operations.
|
||||
|
||||
Certificate linking is deliberately explicit and shallow: certificates in the same input are matched by issuer/subject and the child signature is checked with that candidate. The app does not build a trust path, load platform roots, fetch intermediates or revocation data, or infer trust from a valid signature. DNS SAN matching is an isolated syntax check against a user-entered host name.
|
||||
Certificate paths are built only from the bounded explicit input. Issuer names select candidates, AKI/SKI disambiguates when available, and each link checks its signature, Basic Constraints, keyCertSign, path-length and inspection-time validity. Ambiguous, missing and looping paths stop rather than guess. A self-signed terminus is labelled as present but never trusted. The app does not load platform roots, fetch intermediates/revocation data or infer trust from a valid signature. DNS SAN matching remains an isolated syntax check against a user-entered host name.
|
||||
|
||||
The 8 MiB/256-PEM-block bounds are checked before expensive parsing. Version 0.1 does not use a worker, persistence, or a server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
|
||||
`crypto/pbes2.ts` accepts DER EncryptedPrivateKeyInfo and reusable AlgorithmIdentifier/ciphertext pairs using PBES2 + PBKDF2, bounded iterations/salts, supported HMAC PRFs and WebCrypto AES-CBC/AES-GCM schemes. Successful private-key decryption must produce a structurally valid PKCS #8 PrivateKeyInfo. Passwords and plaintext key bytes are not returned in reports.
|
||||
|
||||
`crypto/pkcs12.ts` parses a version-3 PFX, its CMS-data AuthenticatedSafe, bounded `data`/`encryptedData` ContentInfos, SafeContents and at most 512 bags across four nested levels. It inventories attributes, unencrypted and shrouded PKCS #8 keys, X.509 certificate bags and nested SafeContents; CRL, secret and unknown bag values are identified without being decoded. MacData is verified before password-based decryption when SHA-1/SHA-256/SHA-384/SHA-512 is declared, using the RFC 7292 PKCS #12 MAC KDF with at most 50,000 iterations. Modern PBES2/PBKDF2 AES content is decrypted through the shared WebCrypto path. Legacy PKCS #12 PBE, non-data CMS content, unknown certificate types, wrong passwords and malformed or excessive nesting fail closed with contextual diagnostics. Plaintext SafeContents and private-key buffers are overwritten after extracting metadata; certificates are retained as public inputs for the existing path analyzer.
|
||||
|
||||
`crypto/operations.ts` exposes fixed algorithm profiles rather than parameter auto-detection. It imports PKCS #8/SPKI/certificate/JWK keys, rejects implicit legacy conversions, enforces RSA/AES/curve bounds, and overwrites temporary decoded/decrypted private-key buffers after WebCrypto import on both success and failure. It generates a fresh 96-bit IV for each AES-GCM encryption and releases no plaintext after an authenticated-decryption failure. The AES JSON envelope and asymmetric signatures/ciphertexts use explicit unpadded Base64url encodings.
|
||||
|
||||
The 8 MiB/256-PEM-block bounds are checked before expensive parsing. Encrypted PEM blocks are processed sequentially; PBKDF2 is capped at 10,000,000 iterations per object and 20,000,000 aggregate iterations per inspection, including encrypted PKCS #12 content and shrouded key bags. PKCS #12 MAC iterations are capped at 50,000 and explicit PKCS #12 passwords at 4,096 UTF-16 units. The app does not use persistence or a server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Certificate, key and JWK material is parsed locally and kept only in page memory. The app makes no network request, does not consult a trust store, and has no telemetry, analytics, account or persistence. File input is size-checked before it is read; text/DER input is limited to 8 MiB, PEM input to 256 blocks, and JWKS input to 1,000 valid JWK objects.
|
||||
|
||||
Private or symmetric material is identified and prominently warned about, but never decrypted, generated, exported or stored by the app. A compromised browser, extension, device or copied report can still expose sensitive material, so users should prefer public test material whenever possible.
|
||||
Private or symmetric material is prominently warned about. Password use has a separate explicit opt-in; leaving the enabled field blank deliberately requests an empty PKCS #12 password. A supplied password can verify supported PKCS #12 MacData and decrypt modern PBES2 PKCS #8/SafeContents in page memory. The password and decrypted key/SafeContents bytes are not put in inspection reports or persistence, and transient plaintext buffers are overwritten after metadata extraction. Public certificates may remain in the result for path analysis. The operations workspace can generate an AES key and display cryptographic output so the user can copy it, but it does not store it. A compromised browser, extension, device, clipboard or screenshot can still expose sensitive material, so users should prefer public test material whenever possible and leave the page when finished.
|
||||
|
||||
Fingerprints and thumbprints identify exact bytes or canonical JWK members; they do not establish identity or trust. Issuer signature links are not RFC 5280 validation, validity dates use the local clock, and no live revocation check is performed. Rendered values are React text rather than imported HTML.
|
||||
Fingerprints and thumbprints identify exact bytes or canonical JWK members; they do not establish identity or trust. Explicit-input path analysis is not complete RFC 5280 validation, validity dates use the selected inspection time, and no live revocation check is performed. A verified PFX MacData authenticates the encoded AuthenticatedSafe under the supplied password; it does not establish certificate trust or validate every semantic use of a bag. PFX files without MacData are explicitly reported as unauthenticated. Rendered values are React text rather than imported HTML.
|
||||
|
||||
The fixed WebCrypto profiles are narrow interoperability tools, not a cryptographic protocol. RSA-OAEP is limited to short UTF-8 messages; AES-GCM authenticates the tool's JSON envelope; ECDSA signatures use the browser WebCrypto representation. Authentication/decryption failures return only an error. Algorithm availability, including Ed25519, depends on the current browser.
|
||||
|
||||
PBES2 AES-GCM authenticates its ciphertext. PBES2 AES-CBC does not provide integrity; a successful password, padding and PKCS #8 structure check cannot authenticate the encrypted container. Password text is converted to PBKDF2 bytes as UTF-8, which may not match every legacy producer's password convention. Encrypted PEM objects are inspected sequentially, and one shared 20,000,000-iteration PBKDF2 budget prevents a multi-object input from multiplying the per-object work limit.
|
||||
|
||||
PKCS #12 legacy PBE (including RC2/3DES), unsupported CMS content, non-X.509 certificate bags and excessive nesting are rejected instead of partially interpreted. CRL, secret and unknown bag values are inventoried by type/OID but deliberately not decoded or exported. The parser is a narrow local inspection implementation, not a general PKCS #12 writer or a replacement for hardware-backed key storage.
|
||||
|
||||
Generated
+25
-23
@@ -1,24 +1,25 @@
|
||||
{
|
||||
"name": "crypto-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "crypto-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"@peculiar/x509": "2.0.0",
|
||||
"asn1js": "3.0.10",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"reflect-metadata": "0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@@ -44,24 +45,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.3",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-helpers": {
|
||||
"version": "0.1.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
|
||||
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -69,17 +70,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
|
||||
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -1718,6 +1718,8 @@
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
|
||||
+8
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crypto-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect keys, certificates and signatures locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -39,16 +39,17 @@
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"@peculiar/x509": "2.0.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"asn1js": "3.0.10",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"reflect-metadata": "0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
||||
timeout: 180_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-chromium",
|
||||
testMatch: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Added strict PBES2/PBKDF2 encrypted PKCS #8 inspection and password-gated WebCrypto decryption for supported AES profiles.
|
||||
- Added explicit-input certificate path analysis without trust claims.
|
||||
- Added bounded PKCS #12 AuthenticatedSafe/SafeContents and bag inventory, supported MacData verification, and password-gated PBES2/PBKDF2/AES decryption for encrypted content and shrouded PKCS #8 keys.
|
||||
- Wipe standalone decrypted PKCS #8 buffers after inspection and WebCrypto
|
||||
operational imports, and enforce a sequential, aggregate PBKDF2 work budget
|
||||
across multi-object PEM and PKCS #12 inspection.
|
||||
- Added fixed-profile WebCrypto sign/verify and authenticated/limited encrypt/decrypt workspaces.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Added the initial local-first Crypto Tools workbench.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-contract@0.2.3
|
||||
@add-ideas/toolbox-contract@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-helpers@0.1.0
|
||||
@add-ideas/toolbox-helpers@0.2.0
|
||||
Declared licence: GPL-3.0-or-later
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-shell-react@0.2.3
|
||||
@add-ideas/toolbox-shell-react@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
|
||||
+11
-5
@@ -4,15 +4,21 @@ Inspect keys, certificates and signatures locally in the browser.
|
||||
|
||||
Crypto Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
|
||||
|
||||
## Version 0.1 scope
|
||||
## Current scope
|
||||
|
||||
- Bounded PEM and DER inspection for X.509 certificates, CSRs and CRLs
|
||||
- Identification and fingerprinting of public/private PEM key material without decryption
|
||||
- PBES2/PBKDF2 encrypted PKCS #8 parameter inspection and password-gated in-memory decryption for WebCrypto-supported AES-CBC/AES-GCM profiles
|
||||
- Bounded PKCS #12/PFX AuthenticatedSafe, SafeContents and nested bag inventory, including friendly names, local key IDs, X.509 certificates and private-key container metadata
|
||||
- Password-gated PKCS #12 MacData verification for SHA-1/SHA-2 plus modern PBES2/PBKDF2 AES-CBC/AES-GCM decryption for encrypted SafeContents and shrouded PKCS #8 keys
|
||||
- Identification and fingerprinting of public/private PEM key material
|
||||
- JWK/JWKS inspection, private-member warnings and RFC 7638 SHA-256 thumbprints
|
||||
- Explicit issuer-certificate signature links and DNS SAN hostname checks
|
||||
- Explicit certificate-path analysis across supplied certificates: issuer DN, AKI/SKI, signature, CA/keyCertSign, path-length and time checks, without claiming trust
|
||||
- DNS SAN hostname checks
|
||||
- Concrete, fixed-profile WebCrypto sign/verify operations (RSA-PSS, RSASSA-PKCS1-v1_5, ECDSA P-256 and browser-supported Ed25519)
|
||||
- Concrete RSA-OAEP SHA-256 and AES-256-GCM text encryption/decryption, with authenticated AES envelopes and explicit size/encoding limits
|
||||
- A local JSON inspection report with an explicit non-trust model
|
||||
|
||||
This is inspection, not complete RFC 5280 path validation, certificate trust, revocation checking, issuance, key generation or general signature verification. Inputs are bounded to 8 MiB and 256 PEM blocks. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
||||
This is not complete RFC 5280 validation, certificate trust, revocation checking, issuance, CMS/JWS tooling, legacy PKCS #1/SEC1 conversion or general-purpose file cryptography. PKCS #12 support intentionally rejects legacy PKCS #12 PBE/RC2/3DES, unknown CMS content, unsupported certificate types and excessive/malformed nesting; it does not implicitly export bag values. Inputs are bounded to 8 MiB and 256 PEM blocks, and password-gated inspection has a shared PBKDF2 work budget. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -26,7 +32,7 @@ npm run test:browser
|
||||
|
||||
## Release
|
||||
|
||||
`npm run release:artifact` creates a deterministic `release/crypto-tools-0.1.0.zip` and checksum sidecar.
|
||||
`npm run release:artifact` creates a deterministic `release/crypto-tools-0.2.0.zip` and checksum sidecar.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding source for Crypto Tools 0.1.0 is available at:
|
||||
The corresponding source for Crypto Tools 0.2.0 is available at:
|
||||
|
||||
https://git.add-ideas.de/lotobo/crypto-tools/src/tag/v0.1.0
|
||||
https://git.add-ideas.de/lotobo/crypto-tools/src/tag/v0.2.0
|
||||
|
||||
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||
|
||||
@@ -4,10 +4,11 @@ Crypto Tools 0.1.0 directly depends on these runtime packages:
|
||||
|
||||
| Package | Pinned version | Declared licence |
|
||||
| -------------------------------- | -------------: | ---------------- |
|
||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
|
||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
|
||||
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
|
||||
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
|
||||
| `@peculiar/x509` | 2.0.0 | MIT |
|
||||
| `asn1js` | 3.0.10 | BSD-3-Clause |
|
||||
| `reflect-metadata` | 0.2.2 | Apache-2.0 |
|
||||
| `react` | 19.2.8 | MIT |
|
||||
| `react-dom` | 19.2.8 | MIT |
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# Architecture
|
||||
|
||||
Crypto Tools is a static React/Vite inspection application wrapped in the shared Toolbox shell. `crypto/inspection.ts` accepts a bounded string or byte buffer, separates PEM blocks or JSON, and produces one plain report model. It uses `@peculiar/x509` for X.509/PKCS #10/CRL parsing and signature primitives, and browser WebCrypto for SHA-256 fingerprints and RFC 7638 thumbprints.
|
||||
Crypto Tools is a static React/Vite application wrapped in the shared Toolbox shell. `crypto/inspection.ts` accepts a bounded string or byte buffer, separates PEM blocks or JSON, and produces one plain report model. It uses `@peculiar/x509` for X.509/PKCS #10/CRL parsing and signature primitives, strict `asn1js` DER parsing for the supported PKCS containers, and browser WebCrypto for fingerprints, key import and operations.
|
||||
|
||||
Certificate linking is deliberately explicit and shallow: certificates in the same input are matched by issuer/subject and the child signature is checked with that candidate. The app does not build a trust path, load platform roots, fetch intermediates or revocation data, or infer trust from a valid signature. DNS SAN matching is an isolated syntax check against a user-entered host name.
|
||||
Certificate paths are built only from the bounded explicit input. Issuer names select candidates, AKI/SKI disambiguates when available, and each link checks its signature, Basic Constraints, keyCertSign, path-length and inspection-time validity. Ambiguous, missing and looping paths stop rather than guess. A self-signed terminus is labelled as present but never trusted. The app does not load platform roots, fetch intermediates/revocation data or infer trust from a valid signature. DNS SAN matching remains an isolated syntax check against a user-entered host name.
|
||||
|
||||
The 8 MiB/256-PEM-block bounds are checked before expensive parsing. Version 0.1 does not use a worker, persistence, or a server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
|
||||
`crypto/pbes2.ts` accepts DER EncryptedPrivateKeyInfo and reusable AlgorithmIdentifier/ciphertext pairs using PBES2 + PBKDF2, bounded iterations/salts, supported HMAC PRFs and WebCrypto AES-CBC/AES-GCM schemes. Successful private-key decryption must produce a structurally valid PKCS #8 PrivateKeyInfo. Passwords and plaintext key bytes are not returned in reports.
|
||||
|
||||
`crypto/pkcs12.ts` parses a version-3 PFX, its CMS-data AuthenticatedSafe, bounded `data`/`encryptedData` ContentInfos, SafeContents and at most 512 bags across four nested levels. It inventories attributes, unencrypted and shrouded PKCS #8 keys, X.509 certificate bags and nested SafeContents; CRL, secret and unknown bag values are identified without being decoded. MacData is verified before password-based decryption when SHA-1/SHA-256/SHA-384/SHA-512 is declared, using the RFC 7292 PKCS #12 MAC KDF with at most 50,000 iterations. Modern PBES2/PBKDF2 AES content is decrypted through the shared WebCrypto path. Legacy PKCS #12 PBE, non-data CMS content, unknown certificate types, wrong passwords and malformed or excessive nesting fail closed with contextual diagnostics. Plaintext SafeContents and private-key buffers are overwritten after extracting metadata; certificates are retained as public inputs for the existing path analyzer.
|
||||
|
||||
`crypto/operations.ts` exposes fixed algorithm profiles rather than parameter auto-detection. It imports PKCS #8/SPKI/certificate/JWK keys, rejects implicit legacy conversions, enforces RSA/AES/curve bounds, and overwrites temporary decoded/decrypted private-key buffers after WebCrypto import on both success and failure. It generates a fresh 96-bit IV for each AES-GCM encryption and releases no plaintext after an authenticated-decryption failure. The AES JSON envelope and asymmetric signatures/ciphertexts use explicit unpadded Base64url encodings.
|
||||
|
||||
The 8 MiB/256-PEM-block bounds are checked before expensive parsing. Encrypted PEM blocks are processed sequentially; PBKDF2 is capped at 10,000,000 iterations per object and 20,000,000 aggregate iterations per inspection, including encrypted PKCS #12 content and shrouded key bags. PKCS #12 MAC iterations are capped at 50,000 and explicit PKCS #12 passwords at 4,096 UTF-16 units. The app does not use persistence or a server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Certificate, key and JWK material is parsed locally and kept only in page memory. The app makes no network request, does not consult a trust store, and has no telemetry, analytics, account or persistence. File input is size-checked before it is read; text/DER input is limited to 8 MiB, PEM input to 256 blocks, and JWKS input to 1,000 valid JWK objects.
|
||||
|
||||
Private or symmetric material is identified and prominently warned about, but never decrypted, generated, exported or stored by the app. A compromised browser, extension, device or copied report can still expose sensitive material, so users should prefer public test material whenever possible.
|
||||
Private or symmetric material is prominently warned about. Password use has a separate explicit opt-in; leaving the enabled field blank deliberately requests an empty PKCS #12 password. A supplied password can verify supported PKCS #12 MacData and decrypt modern PBES2 PKCS #8/SafeContents in page memory. The password and decrypted key/SafeContents bytes are not put in inspection reports or persistence, and transient plaintext buffers are overwritten after metadata extraction. Public certificates may remain in the result for path analysis. The operations workspace can generate an AES key and display cryptographic output so the user can copy it, but it does not store it. A compromised browser, extension, device, clipboard or screenshot can still expose sensitive material, so users should prefer public test material whenever possible and leave the page when finished.
|
||||
|
||||
Fingerprints and thumbprints identify exact bytes or canonical JWK members; they do not establish identity or trust. Issuer signature links are not RFC 5280 validation, validity dates use the local clock, and no live revocation check is performed. Rendered values are React text rather than imported HTML.
|
||||
Fingerprints and thumbprints identify exact bytes or canonical JWK members; they do not establish identity or trust. Explicit-input path analysis is not complete RFC 5280 validation, validity dates use the selected inspection time, and no live revocation check is performed. A verified PFX MacData authenticates the encoded AuthenticatedSafe under the supplied password; it does not establish certificate trust or validate every semantic use of a bag. PFX files without MacData are explicitly reported as unauthenticated. Rendered values are React text rather than imported HTML.
|
||||
|
||||
The fixed WebCrypto profiles are narrow interoperability tools, not a cryptographic protocol. RSA-OAEP is limited to short UTF-8 messages; AES-GCM authenticates the tool's JSON envelope; ECDSA signatures use the browser WebCrypto representation. Authentication/decryption failures return only an error. Algorithm availability, including Ed25519, depends on the current browser.
|
||||
|
||||
PBES2 AES-GCM authenticates its ciphertext. PBES2 AES-CBC does not provide integrity; a successful password, padding and PKCS #8 structure check cannot authenticate the encrypted container. Password text is converted to PBKDF2 bytes as UTF-8, which may not match every legacy producer's password convention. Encrypted PEM objects are inspected sequentially, and one shared 20,000,000-iteration PBKDF2 budget prevents a multi-object input from multiplying the per-object work limit.
|
||||
|
||||
PKCS #12 legacy PBE (including RC2/3DES), unsupported CMS content, non-X.509 certificate bags and excessive nesting are rejected instead of partially interpreted. CRL, secret and unknown bag values are inventoried by type/OID but deliberately not decoded or exported. The parser is a narrow local inspection implementation, not a general PKCS #12 writer or a replacement for hardware-backed key storage.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
const CACHE_PREFIX = "crypto-tools-shell-";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
|
||||
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
|
||||
+30
-3
@@ -3,12 +3,20 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.crypto-tools",
|
||||
"name": "Crypto Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect keys, certificates and signatures locally in the browser.",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect keys, certificates, PKCS #12 and signatures locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["security", "cryptography", "developer"],
|
||||
"tags": ["x509", "certificate", "jwk", "signature", "crypto"],
|
||||
"tags": [
|
||||
"x509",
|
||||
"certificate",
|
||||
"pkcs12",
|
||||
"pfx",
|
||||
"jwk",
|
||||
"signature",
|
||||
"crypto"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +29,25 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "application/pem-certificate-chain",
|
||||
"extensions": [".pem", ".crt", ".cer", ".csr", ".key"]
|
||||
},
|
||||
{ "mediaType": "application/pkix-cert", "extensions": [".der", ".cer"] },
|
||||
{ "mediaType": "application/pkcs12", "extensions": [".p12", ".pfx"] },
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json", ".jwk", ".jwks"]
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{ "mediaType": "application/json", "extensions": [".json"] },
|
||||
{ "mediaType": "text/plain", "extensions": [".pem", ".txt"] }
|
||||
]
|
||||
},
|
||||
"capabilities": { "required": [], "optional": ["web-crypto"] },
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
decryptText,
|
||||
encryptText,
|
||||
generateAesKeySource,
|
||||
OPERATION_ALGORITHMS,
|
||||
signText,
|
||||
verifyText,
|
||||
type CryptoOperationResult,
|
||||
type OperationAlgorithm,
|
||||
} from "../crypto/operations";
|
||||
|
||||
type Operation = CryptoOperationResult["operation"];
|
||||
|
||||
const DEFAULT_ALGORITHM: Record<Operation, OperationAlgorithm> = {
|
||||
sign: "rsa-pss-sha256",
|
||||
verify: "rsa-pss-sha256",
|
||||
encrypt: "aes-gcm-256",
|
||||
decrypt: "aes-gcm-256",
|
||||
};
|
||||
|
||||
export function OperationsWorkspace() {
|
||||
const [operation, setOperation] = useState<Operation>("encrypt");
|
||||
const [algorithm, setAlgorithm] = useState<OperationAlgorithm>("aes-gcm-256");
|
||||
const [keySource, setKeySource] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState("Local-only example");
|
||||
const [artifact, setArtifact] = useState("");
|
||||
const [result, setResult] = useState<CryptoOperationResult>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const algorithms = useMemo(
|
||||
() =>
|
||||
OPERATION_ALGORITHMS.filter((candidate) =>
|
||||
candidate.operations.includes(operation),
|
||||
),
|
||||
[operation],
|
||||
);
|
||||
const selected = OPERATION_ALGORITHMS.find(
|
||||
(candidate) => candidate.id === algorithm,
|
||||
);
|
||||
|
||||
const chooseOperation = (next: Operation) => {
|
||||
setOperation(next);
|
||||
setAlgorithm(DEFAULT_ALGORITHM[next]);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const execute = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const next =
|
||||
operation === "sign"
|
||||
? await signText(algorithm, keySource, password, message)
|
||||
: operation === "verify"
|
||||
? await verifyText(algorithm, keySource, message, artifact)
|
||||
: operation === "encrypt"
|
||||
? await encryptText(algorithm, keySource, message)
|
||||
: await decryptText(algorithm, keySource, password, artifact);
|
||||
setResult(next);
|
||||
if (operation === "sign" || operation === "encrypt")
|
||||
setArtifact(next.output);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Cryptographic operation failed.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isPrivateOperation = operation === "sign" || operation === "decrypt";
|
||||
const artifactLabel =
|
||||
operation === "verify" ? "Signature · unpadded Base64url" : "Ciphertext";
|
||||
|
||||
return (
|
||||
<section className="panel workspace" aria-labelledby="operations-heading">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Concrete WebCrypto operations</p>
|
||||
<h2 id="operations-heading">Sign, verify, encrypt or decrypt</h2>
|
||||
</div>
|
||||
<span className="privacy-pill">Explicit limits</span>
|
||||
</div>
|
||||
<p className="notice">
|
||||
Inputs, passwords and plaintext stay in this page. Successful results
|
||||
remain visible while a new operation runs; private material is never
|
||||
included in downloads or inspection reports.
|
||||
</p>
|
||||
<div className="operation-tabs" role="group" aria-label="Operation">
|
||||
{(["sign", "verify", "encrypt", "decrypt"] as const).map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={operation === name}
|
||||
key={name}
|
||||
onClick={() => chooseOperation(name)}
|
||||
>
|
||||
{name[0]!.toUpperCase() + name.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="operation-grid">
|
||||
<label className="field">
|
||||
<span>Algorithm profile</span>
|
||||
<select
|
||||
value={algorithm}
|
||||
onChange={(event) =>
|
||||
setAlgorithm(event.target.value as OperationAlgorithm)
|
||||
}
|
||||
>
|
||||
{algorithms.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="field limit-card">
|
||||
<span>Enforced profile</span>
|
||||
<p>{selected?.limit}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>
|
||||
{algorithm === "aes-gcm-256"
|
||||
? "AES key · 32-byte unpadded Base64url or oct JWK"
|
||||
: isPrivateOperation
|
||||
? "Private key · PKCS #8 PEM, PBES2 PEM or JWK"
|
||||
: "Public key · SPKI PEM, certificate PEM or JWK"}
|
||||
</span>
|
||||
<textarea
|
||||
value={keySource}
|
||||
onChange={(event) => setKeySource(event.target.value)}
|
||||
spellCheck={false}
|
||||
aria-label="Operation key"
|
||||
/>
|
||||
</label>
|
||||
{algorithm === "aes-gcm-256" && (
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKeySource(generateAesKeySource())}
|
||||
>
|
||||
Generate 256-bit AES key
|
||||
</button>
|
||||
<span className="muted">Copy and store the key before leaving.</span>
|
||||
</div>
|
||||
)}
|
||||
{isPrivateOperation && algorithm !== "aes-gcm-256" && (
|
||||
<label className="field">
|
||||
<span>PBES2 password · used only when the key is encrypted</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{operation !== "decrypt" && (
|
||||
<label className="field">
|
||||
<span>{operation === "encrypt" ? "Plaintext" : "Message"}</span>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{(operation === "verify" || operation === "decrypt") && (
|
||||
<label className="field">
|
||||
<span>{artifactLabel}</span>
|
||||
<textarea
|
||||
value={artifact}
|
||||
onChange={(event) => setArtifact(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => void execute()}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy
|
||||
? `${operation[0]!.toUpperCase() + operation.slice(1)}ing…`
|
||||
: `${operation[0]!.toUpperCase() + operation.slice(1)} locally`}
|
||||
</button>
|
||||
<span className="muted">8 MiB text/key input hard limit</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{result && (
|
||||
<article className="operation-result" aria-live="polite">
|
||||
<p className="eyebrow">Last successful operation</p>
|
||||
<pre>{result.output}</pre>
|
||||
<dl className="facts">
|
||||
{Object.entries(result.facts).map(([name, value]) => (
|
||||
<div key={name}>
|
||||
<dt>{name}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+103
-23
@@ -4,6 +4,7 @@ import {
|
||||
inspectCryptoInput,
|
||||
type CryptoInspection,
|
||||
} from "../crypto/inspection";
|
||||
import { OperationsWorkspace } from "./OperationsWorkspace";
|
||||
|
||||
const example = `{
|
||||
"kty": "EC",
|
||||
@@ -22,6 +23,7 @@ function reportJson(inspection: CryptoInspection): string {
|
||||
"Explicit inputs only; browser and operating-system trust stores were not used.",
|
||||
findings: inspection.findings,
|
||||
chain: inspection.chain,
|
||||
paths: inspection.paths,
|
||||
items: inspection.items.map(({ id, type, title, facts, findings }) => ({
|
||||
id,
|
||||
type,
|
||||
@@ -51,8 +53,11 @@ export function Workbench() {
|
||||
const [inspection, setInspection] = useState<CryptoInspection>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [inspectionPassword, setInspectionPassword] = useState("");
|
||||
const [useInspectionPassword, setUseInspectionPassword] = useState(false);
|
||||
const [hostname, setHostname] = useState("example.com");
|
||||
const generation = useRef(0);
|
||||
const currentInput = useRef<string | Uint8Array>(example);
|
||||
const certificates = useMemo(
|
||||
() => inspection?.items.filter((item) => item.certificate) ?? [],
|
||||
[inspection],
|
||||
@@ -65,12 +70,14 @@ export function Workbench() {
|
||||
[certificates, hostname],
|
||||
);
|
||||
|
||||
const run = async (input: string | Uint8Array = source) => {
|
||||
const run = async (input: string | Uint8Array = currentInput.current) => {
|
||||
const current = ++generation.current;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await inspectCryptoInput(input);
|
||||
const next = await inspectCryptoInput(input, new Date(), {
|
||||
...(useInspectionPassword ? { password: inspectionPassword } : {}),
|
||||
});
|
||||
if (generation.current === current) setInspection(next);
|
||||
} catch (reason) {
|
||||
if (generation.current === current)
|
||||
@@ -88,16 +95,20 @@ export function Workbench() {
|
||||
setError("File exceeds the 8 MiB inspection limit.");
|
||||
return;
|
||||
}
|
||||
setSource(
|
||||
`Selected binary input: ${file.name} (${file.size.toLocaleString()} bytes)`,
|
||||
);
|
||||
const textual =
|
||||
/(?:json|jwk|jwks|pem|crt|cer|csr|key)$/iu.test(file.name) ||
|
||||
file.type.startsWith("text/") ||
|
||||
file.type === "application/json";
|
||||
await run(
|
||||
textual ? await file.text() : new Uint8Array(await file.arrayBuffer()),
|
||||
const input = textual
|
||||
? await file.text()
|
||||
: new Uint8Array(await file.arrayBuffer());
|
||||
currentInput.current = input;
|
||||
setSource(
|
||||
textual
|
||||
? input.toString()
|
||||
: `Selected binary input: ${file.name} (${file.size.toLocaleString()} bytes)`,
|
||||
);
|
||||
await run(input);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -131,9 +142,39 @@ export function Workbench() {
|
||||
<textarea
|
||||
aria-label="Cryptographic input"
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setSource(event.target.value);
|
||||
currentInput.current = event.target.value;
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<label className="field">
|
||||
<span>
|
||||
PBES2 / PKCS #12 password · used only after explicit opt-in and
|
||||
never included in the report
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={inspectionPassword}
|
||||
onChange={(event) => setInspectionPassword(event.target.value)}
|
||||
autoComplete="off"
|
||||
disabled={!useInspectionPassword}
|
||||
/>
|
||||
</label>
|
||||
<label className="password-consent">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useInspectionPassword}
|
||||
onChange={(event) => {
|
||||
setUseInspectionPassword(event.target.checked);
|
||||
if (!event.target.checked) setInspectionPassword("");
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
Use this password in memory for this inspection (a blank value
|
||||
explicitly means an empty PKCS #12 password)
|
||||
</span>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -212,41 +253,71 @@ export function Workbench() {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{inspection.chain.length > 0 && (
|
||||
{inspection.paths.length > 0 && (
|
||||
<section
|
||||
className="panel workspace"
|
||||
aria-labelledby="chain-heading"
|
||||
>
|
||||
<div>
|
||||
<p className="eyebrow">Explicit path</p>
|
||||
<h2 id="chain-heading">Issuer signatures</h2>
|
||||
<p className="eyebrow">Explicit-input path analysis</p>
|
||||
<h2 id="chain-heading">Certificate paths</h2>
|
||||
</div>
|
||||
<div className="item-list">
|
||||
{inspection.paths.map((path) => (
|
||||
<article className="crypto-item" key={path.leaf}>
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{path.status}</p>
|
||||
<h3>{path.leaf}</h3>
|
||||
</div>
|
||||
<span className="privacy-pill">Not trusted</span>
|
||||
</header>
|
||||
<p className="path-line">{path.certificates.join(" → ")}</p>
|
||||
{path.links.length > 0 && (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Certificate</th>
|
||||
<th>Issuer</th>
|
||||
<th>Signature</th>
|
||||
<th>CA / keyCertSign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspection.chain.map((link) => (
|
||||
{path.links.map((link) => (
|
||||
<tr key={`${link.child}-${link.issuer}`}>
|
||||
<td>{link.child}</td>
|
||||
<td>{link.issuer}</td>
|
||||
<td>
|
||||
{link.signatureValid
|
||||
? "Valid"
|
||||
: "Invalid / unsupported"}
|
||||
{link.signatureValid ? "Valid" : "Invalid"}
|
||||
</td>
|
||||
<td>
|
||||
{link.issuerIsCa ? "CA" : "Not CA"} ·{" "}
|
||||
{link.keyCertSignAllowed
|
||||
? "permitted"
|
||||
: "not permitted"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{path.findings.map((finding) => (
|
||||
<p
|
||||
className={`finding finding-${finding.severity}`}
|
||||
key={finding.message}
|
||||
>
|
||||
{finding.message}
|
||||
</p>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted">
|
||||
A valid signature link alone does not establish trust, purpose,
|
||||
revocation status, name validity, or complete RFC 5280 path
|
||||
validation.
|
||||
Issuer DN, AKI/SKI, signatures, CA constraints, keyCertSign,
|
||||
approximate path length and validity are checked. A successful
|
||||
explicit-input path still does not establish trust, policy,
|
||||
purpose or revocation status.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
@@ -282,19 +353,28 @@ export function Workbench() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<OperationsWorkspace />
|
||||
<section className="panel workspace" aria-labelledby="boundary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">v0.1 boundary</p>
|
||||
<p className="eyebrow">Deliberate boundary</p>
|
||||
<h2 id="boundary-heading">What this release does not claim</h2>
|
||||
</div>
|
||||
<ul className="compact-list">
|
||||
<li>
|
||||
Private keys are identified and fingerprinted, never persisted,
|
||||
generated, or silently decrypted.
|
||||
Private keys are never persisted or silently decrypted. PBES2 import
|
||||
requires an explicit password and decrypted bytes remain in page
|
||||
memory only.
|
||||
</li>
|
||||
<li>
|
||||
PKCS #12/PFX, encrypted PKCS #8, CMS/JWS signature workflows and
|
||||
certificate issuance are deferred.
|
||||
PKCS #12 parses bounded AuthenticatedSafe/SafeContents structures,
|
||||
verifies supported MacData, and decrypts only modern
|
||||
PBES2/PBKDF2/AES content. Legacy PKCS #12 PBE, unknown CMS content,
|
||||
PKCS #1/SEC1, CMS/JWS and certificate issuance are not implemented.
|
||||
</li>
|
||||
<li>
|
||||
Operations use only the listed, fixed WebCrypto profiles. They do
|
||||
not auto-detect algorithms, hash files, stream large inputs or claim
|
||||
protocol-level interoperability beyond their displayed encoding.
|
||||
</li>
|
||||
<li>
|
||||
No online OCSP, CRL download, Certificate Transparency query,
|
||||
|
||||
+576
-22
@@ -1,16 +1,31 @@
|
||||
import "reflect-metadata";
|
||||
import {
|
||||
BasicConstraintsExtension,
|
||||
AuthorityKeyIdentifierExtension,
|
||||
ExtendedKeyUsageExtension,
|
||||
KeyUsageFlags,
|
||||
KeyUsagesExtension,
|
||||
PemConverter,
|
||||
Pkcs10CertificateRequest,
|
||||
PublicKey,
|
||||
SubjectAlternativeNameExtension,
|
||||
SubjectKeyIdentifierExtension,
|
||||
X509Certificate,
|
||||
X509Crl,
|
||||
} from "@peculiar/x509";
|
||||
import { bytesToBase64Url, bytesToHex } from "@add-ideas/toolbox-helpers";
|
||||
import {
|
||||
decryptEncryptedPkcs8,
|
||||
inspectEncryptedPkcs8,
|
||||
inspectPkcs8,
|
||||
reservePbkdf2Work,
|
||||
type Pbkdf2WorkBudget,
|
||||
} from "./pbes2";
|
||||
import {
|
||||
inspectPkcs12,
|
||||
recognizesPkcs12,
|
||||
type Pkcs12BagInventory,
|
||||
} from "./pkcs12";
|
||||
|
||||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_PEM_BLOCKS = 256;
|
||||
@@ -34,6 +49,31 @@ export interface CryptoInspection {
|
||||
items: CryptoItem[];
|
||||
findings: CryptoFinding[];
|
||||
chain: { child: string; issuer: string; signatureValid: boolean }[];
|
||||
paths: CertificatePathAnalysis[];
|
||||
}
|
||||
|
||||
export interface CertificatePathLink {
|
||||
child: string;
|
||||
issuer: string;
|
||||
signatureValid: boolean;
|
||||
issuerIsCa: boolean;
|
||||
keyCertSignAllowed: boolean;
|
||||
authorityKeyIdentifierMatched?: boolean;
|
||||
findings: CryptoFinding[];
|
||||
}
|
||||
|
||||
export interface CertificatePathAnalysis {
|
||||
leaf: string;
|
||||
certificates: string[];
|
||||
status:
|
||||
| "self-signed-anchor-present"
|
||||
| "incomplete"
|
||||
| "ambiguous"
|
||||
| "loop"
|
||||
| "invalid";
|
||||
links: CertificatePathLink[];
|
||||
findings: CryptoFinding[];
|
||||
trusted: false;
|
||||
}
|
||||
|
||||
export interface PemBlock {
|
||||
@@ -199,6 +239,8 @@ async function inspectBlock(
|
||||
block: PemBlock,
|
||||
index: number,
|
||||
now: Date,
|
||||
password: string | undefined,
|
||||
pbkdf2Budget: Pbkdf2WorkBudget,
|
||||
): Promise<CryptoItem> {
|
||||
if (block.label === "CERTIFICATE" || block.label === "X509 CERTIFICATE")
|
||||
return inspectCertificate(block.pem, index, now);
|
||||
@@ -260,12 +302,77 @@ async function inspectBlock(
|
||||
};
|
||||
}
|
||||
if (block.label.includes("PRIVATE KEY")) {
|
||||
if (block.label === "ENCRYPTED PRIVATE KEY") {
|
||||
const encryption = inspectEncryptedPkcs8(block.bytes);
|
||||
if (password !== undefined)
|
||||
reservePbkdf2Work(
|
||||
pbkdf2Budget,
|
||||
encryption.iterations,
|
||||
`Encrypted private key ${index + 1}`,
|
||||
);
|
||||
const decrypted =
|
||||
password === undefined
|
||||
? undefined
|
||||
: await decryptEncryptedPkcs8(block.bytes, password);
|
||||
try {
|
||||
return {
|
||||
id: `private-${index}`,
|
||||
type: block.label,
|
||||
title: `Encrypted private key ${index + 1}`,
|
||||
facts: {
|
||||
Encrypted: "Yes",
|
||||
Container: "PKCS #8 EncryptedPrivateKeyInfo",
|
||||
Encryption: `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`,
|
||||
Salt: `${encryption.saltBytes} bytes`,
|
||||
"IV / nonce": `${encryption.ivOrNonceBytes} bytes`,
|
||||
"Encrypted size": `${encryption.encryptedBytes.toLocaleString()} bytes`,
|
||||
"Private-key algorithm": decrypted
|
||||
? `${decrypted.key.algorithm}${decrypted.key.curve ? ` · ${decrypted.key.curve}` : ""}`
|
||||
: "Password required to inspect",
|
||||
"Decryption status": decrypted
|
||||
? "Decrypted and structurally validated in memory"
|
||||
: "Not attempted",
|
||||
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
|
||||
},
|
||||
findings: [
|
||||
...(encryption.cipher === "AES-CBC"
|
||||
? [
|
||||
{
|
||||
severity: "warning" as const,
|
||||
message:
|
||||
"AES-CBC PBES2 does not authenticate the ciphertext; a successful padding/structure check is not an integrity guarantee.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
severity: decrypted ? "warning" : "info",
|
||||
message: decrypted
|
||||
? "The PBES2 key was decrypted only in page memory. Private-key material is sensitive and was not added to the report."
|
||||
: "PBES2 parameters were inspected without decryption. Enter a password explicitly to validate/import the key in memory.",
|
||||
},
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
decrypted?.bytes.fill(0);
|
||||
}
|
||||
}
|
||||
const key =
|
||||
block.label === "PRIVATE KEY" ? inspectPkcs8(block.bytes) : undefined;
|
||||
return {
|
||||
id: `private-${index}`,
|
||||
type: block.label,
|
||||
title: `Private key ${index + 1}`,
|
||||
facts: {
|
||||
Encrypted: block.encrypted ? "Yes" : "No",
|
||||
Container:
|
||||
block.label === "PRIVATE KEY"
|
||||
? "PKCS #8 PrivateKeyInfo"
|
||||
: "Legacy or unsupported private-key container",
|
||||
...(key
|
||||
? {
|
||||
"Private-key algorithm": `${key.algorithm}${key.curve ? ` · ${key.curve}` : ""}`,
|
||||
}
|
||||
: {}),
|
||||
Size: `${block.bytes.byteLength.toLocaleString()} bytes`,
|
||||
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
|
||||
},
|
||||
@@ -274,7 +381,9 @@ async function inspectBlock(
|
||||
severity: block.encrypted ? "info" : "warning",
|
||||
message: block.encrypted
|
||||
? "Encrypted private-key material was identified but not decrypted."
|
||||
: "Unencrypted private-key material is sensitive. It is not persisted by this app.",
|
||||
: block.label === "PRIVATE KEY"
|
||||
? "Unencrypted PKCS #8 private-key material is sensitive. It is structurally inspected but not persisted."
|
||||
: "This legacy private-key container is fingerprinted but is not imported; convert it to PKCS #8 explicitly outside this tool.",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -390,41 +499,398 @@ async function inspectJson(source: string): Promise<CryptoItem[]> {
|
||||
return Promise.all(keys.map((key, index) => inspectJwk(key, index)));
|
||||
}
|
||||
|
||||
async function linkCertificates(
|
||||
function extensionIdentifiers(certificate: X509Certificate): {
|
||||
authority?: string;
|
||||
subject?: string;
|
||||
} {
|
||||
const authority = certificate.getExtension(AuthorityKeyIdentifierExtension);
|
||||
const subject = certificate.getExtension(SubjectKeyIdentifierExtension);
|
||||
return {
|
||||
...(authority?.keyId ? { authority: authority.keyId.toUpperCase() } : {}),
|
||||
...(subject?.keyId ? { subject: subject.keyId.toUpperCase() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function analyzeCertificatePaths(
|
||||
items: CryptoItem[],
|
||||
): Promise<CryptoInspection["chain"]> {
|
||||
now = new Date(),
|
||||
): Promise<CertificatePathAnalysis[]> {
|
||||
const certificates = items.filter(
|
||||
(item): item is CryptoItem & { certificate: X509Certificate } =>
|
||||
!!item.certificate,
|
||||
);
|
||||
const result: CryptoInspection["chain"] = [];
|
||||
for (const child of certificates) {
|
||||
if (child.certificate.subject === child.certificate.issuer) continue;
|
||||
const issuer = certificates.find(
|
||||
(candidate) => candidate.certificate.subject === child.certificate.issuer,
|
||||
if (certificates.length === 0) return [];
|
||||
const leaves = certificates.filter(
|
||||
(candidate) =>
|
||||
!certificates.some(
|
||||
(child) =>
|
||||
child !== candidate &&
|
||||
child.certificate.issuer === candidate.certificate.subject,
|
||||
),
|
||||
);
|
||||
if (!issuer) continue;
|
||||
result.push({
|
||||
child: child.title,
|
||||
issuer: issuer.title,
|
||||
signatureValid: await child.certificate
|
||||
const starts = leaves.length > 0 ? leaves : certificates;
|
||||
const paths: CertificatePathAnalysis[] = [];
|
||||
for (const start of starts) {
|
||||
const certificatePath = [start];
|
||||
const links: CertificatePathLink[] = [];
|
||||
const findings: CryptoFinding[] = [];
|
||||
const visited = new Set([start.id]);
|
||||
let current = start;
|
||||
let status: CertificatePathAnalysis["status"] = "incomplete";
|
||||
let pathInvalid = false;
|
||||
for (let depth = 0; depth < 32; depth += 1) {
|
||||
const certificate = current.certificate;
|
||||
if (now < certificate.notBefore || now > certificate.notAfter) {
|
||||
pathInvalid = true;
|
||||
findings.push({
|
||||
severity: "error",
|
||||
message: `${current.title} is outside its certificate validity interval at the selected inspection time.`,
|
||||
});
|
||||
}
|
||||
if (certificate.subject === certificate.issuer) {
|
||||
const selfSignature = await certificate
|
||||
.isSelfSigned()
|
||||
.catch(() => false);
|
||||
if (!selfSignature) {
|
||||
pathInvalid = true;
|
||||
findings.push({
|
||||
severity: "error",
|
||||
message: `${current.title} names itself as issuer but its self-signature did not verify.`,
|
||||
});
|
||||
}
|
||||
status = pathInvalid ? "invalid" : "self-signed-anchor-present";
|
||||
findings.push({
|
||||
severity: "info",
|
||||
message:
|
||||
"A self-signed certificate terminates this explicit-input path, but it is not treated as trusted.",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const identifiers = extensionIdentifiers(certificate);
|
||||
const nameCandidates = certificates.filter(
|
||||
(candidate) =>
|
||||
candidate !== current &&
|
||||
candidate.certificate.subject === certificate.issuer,
|
||||
);
|
||||
let candidates = nameCandidates;
|
||||
let authorityMatched: boolean | undefined;
|
||||
if (identifiers.authority && nameCandidates.length > 0) {
|
||||
const keyMatches = nameCandidates.filter(
|
||||
(candidate) =>
|
||||
extensionIdentifiers(candidate.certificate).subject ===
|
||||
identifiers.authority,
|
||||
);
|
||||
if (keyMatches.length > 0) {
|
||||
candidates = keyMatches;
|
||||
authorityMatched = true;
|
||||
} else if (
|
||||
nameCandidates.some(
|
||||
(candidate) =>
|
||||
extensionIdentifiers(candidate.certificate).subject !== undefined,
|
||||
)
|
||||
) {
|
||||
findings.push({
|
||||
severity: "error",
|
||||
message: `${current.title} authority key identifier does not match any same-name issuer candidate.`,
|
||||
});
|
||||
status = "invalid";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
findings.push({
|
||||
severity: "warning",
|
||||
message: `No supplied certificate has subject “${certificate.issuer}” for ${current.title}.`,
|
||||
});
|
||||
status = pathInvalid ? "invalid" : "incomplete";
|
||||
break;
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
findings.push({
|
||||
severity: "warning",
|
||||
message: `${current.title} has ${candidates.length} indistinguishable issuer candidates; the path is not guessed.`,
|
||||
});
|
||||
status = "ambiguous";
|
||||
break;
|
||||
}
|
||||
const issuer = candidates[0]!;
|
||||
if (visited.has(issuer.id)) {
|
||||
findings.push({
|
||||
severity: "error",
|
||||
message: `Certificate loop detected when linking ${current.title} to ${issuer.title}.`,
|
||||
});
|
||||
status = "loop";
|
||||
break;
|
||||
}
|
||||
|
||||
const signatureValid = await certificate
|
||||
.verify({
|
||||
publicKey: issuer.certificate.publicKey,
|
||||
signatureOnly: true,
|
||||
})
|
||||
.catch(() => false),
|
||||
.catch(() => false);
|
||||
const basic = issuer.certificate.getExtension(BasicConstraintsExtension);
|
||||
const usages = issuer.certificate.getExtension(KeyUsagesExtension);
|
||||
const issuerIsCa = basic?.ca === true;
|
||||
const keyCertSignAllowed =
|
||||
!usages || (usages.usages & KeyUsageFlags.keyCertSign) !== 0;
|
||||
const linkFindings: CryptoFinding[] = [];
|
||||
if (!signatureValid)
|
||||
linkFindings.push({
|
||||
severity: "error",
|
||||
message:
|
||||
"Certificate signature did not verify with this issuer candidate.",
|
||||
});
|
||||
if (!issuerIsCa)
|
||||
linkFindings.push({
|
||||
severity: "error",
|
||||
message:
|
||||
"Issuer certificate does not assert CA=true in Basic Constraints.",
|
||||
});
|
||||
if (!keyCertSignAllowed)
|
||||
linkFindings.push({
|
||||
severity: "error",
|
||||
message: "Issuer Key Usage does not permit certificate signing.",
|
||||
});
|
||||
const caCertificatesBelow = certificatePath
|
||||
.slice(1)
|
||||
.filter(
|
||||
(item) =>
|
||||
item.certificate.getExtension(BasicConstraintsExtension)?.ca ===
|
||||
true,
|
||||
).length;
|
||||
if (
|
||||
basic?.pathLength !== undefined &&
|
||||
caCertificatesBelow > basic.pathLength
|
||||
)
|
||||
linkFindings.push({
|
||||
severity: "error",
|
||||
message: `Issuer pathLength ${basic.pathLength} is exceeded by ${caCertificatesBelow} subordinate CA certificate(s).`,
|
||||
});
|
||||
if (linkFindings.some((finding) => finding.severity === "error"))
|
||||
pathInvalid = true;
|
||||
links.push({
|
||||
child: current.title,
|
||||
issuer: issuer.title,
|
||||
signatureValid,
|
||||
issuerIsCa,
|
||||
keyCertSignAllowed,
|
||||
...(authorityMatched === undefined
|
||||
? {}
|
||||
: { authorityKeyIdentifierMatched: authorityMatched }),
|
||||
findings: linkFindings,
|
||||
});
|
||||
findings.push(...linkFindings);
|
||||
certificatePath.push(issuer);
|
||||
visited.add(issuer.id);
|
||||
current = issuer;
|
||||
if (depth === 31) {
|
||||
findings.push({
|
||||
severity: "error",
|
||||
message: "Certificate path exceeds the 32-certificate bound.",
|
||||
});
|
||||
status = "invalid";
|
||||
}
|
||||
}
|
||||
paths.push({
|
||||
leaf: start.title,
|
||||
certificates: certificatePath.map((item) => item.title),
|
||||
status,
|
||||
links,
|
||||
findings,
|
||||
trusted: false,
|
||||
});
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function chainFromPaths(
|
||||
paths: readonly CertificatePathAnalysis[],
|
||||
): CryptoInspection["chain"] {
|
||||
const result: CryptoInspection["chain"] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const path of paths) {
|
||||
for (const link of path.links) {
|
||||
const key = `${link.child}\0${link.issuer}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({
|
||||
child: link.child,
|
||||
issuer: link.issuer,
|
||||
signatureValid: link.signatureValid,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pfxEncryptionSummary(
|
||||
encryption: NonNullable<Pkcs12BagInventory["encryption"]>,
|
||||
): string {
|
||||
return `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`;
|
||||
}
|
||||
|
||||
async function inspectPkcs12Items(
|
||||
bytes: Uint8Array,
|
||||
now: Date,
|
||||
password: string | undefined,
|
||||
): Promise<CryptoItem[]> {
|
||||
const pfx = await inspectPkcs12(bytes, {
|
||||
...(password === undefined ? {} : { password }),
|
||||
});
|
||||
const macStatus =
|
||||
pfx.mac.status === "verified"
|
||||
? "Verified with the explicitly supplied password"
|
||||
: pfx.mac.status === "password-required"
|
||||
? "Present; explicit password required to verify"
|
||||
: pfx.mac.status === "unsupported"
|
||||
? `Present; unsupported digest (${pfx.mac.algorithm ?? "unknown"})`
|
||||
: "Absent";
|
||||
const contentSummary = pfx.contents
|
||||
.map((content) => {
|
||||
const encryption = content.encryption
|
||||
? `; ${content.encryption.cipher}-${content.encryption.keyLength}, ${content.encryption.iterations.toLocaleString()} PBKDF2 iterations`
|
||||
: "";
|
||||
return `#${content.index + 1} ${content.type}: ${content.state}, ${content.bagCount} bag(s)${encryption}`;
|
||||
})
|
||||
.join(" | ");
|
||||
const envelope: CryptoItem = {
|
||||
id: "pfx-envelope",
|
||||
type: "PKCS #12/PFX",
|
||||
title: "PKCS #12/PFX authenticated safe",
|
||||
facts: {
|
||||
Version: String(pfx.version),
|
||||
"Authenticated-safe content type": pfx.authSafeContentType,
|
||||
MacData: macStatus,
|
||||
...(pfx.mac.algorithm ? { "MAC algorithm": pfx.mac.algorithm } : {}),
|
||||
...(pfx.mac.iterations === undefined
|
||||
? {}
|
||||
: { "MAC iterations": pfx.mac.iterations.toLocaleString() }),
|
||||
Contents: contentSummary || "Empty AuthenticatedSafe",
|
||||
"SafeBag inventory": `${pfx.bags.length.toLocaleString()} bag(s)`,
|
||||
Size: `${pfx.bytes.toLocaleString()} bytes`,
|
||||
"SHA-256 fingerprint": await sha256(buffer(bytes)),
|
||||
},
|
||||
findings: [
|
||||
...pfx.warnings.map((message) => ({
|
||||
severity: "warning" as const,
|
||||
message,
|
||||
})),
|
||||
...(pfx.mac.status === "password-required"
|
||||
? [
|
||||
{
|
||||
severity: "info" as const,
|
||||
message:
|
||||
"Enable password use explicitly to verify MacData and inspect supported encrypted SafeContents/key bags.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
severity: "warning",
|
||||
message:
|
||||
"Private-key bag values are only structurally inspected in page memory. Secret bytes and the password are never included in the report or exported implicitly.",
|
||||
},
|
||||
],
|
||||
};
|
||||
const bagItems: CryptoItem[] = [];
|
||||
for (const [index, bag] of pfx.bags.entries()) {
|
||||
const bagFacts: Record<string, string> = {
|
||||
"Bag path": bag.path,
|
||||
"Bag OID": bag.bagOid,
|
||||
"Friendly name": bag.friendlyName ?? "—",
|
||||
localKeyId: bag.localKeyId ?? "—",
|
||||
Encrypted: bag.encrypted ? "Yes" : "No",
|
||||
State:
|
||||
bag.state === "inspected"
|
||||
? "Structurally inspected"
|
||||
: bag.state === "password-required"
|
||||
? "Explicit password required"
|
||||
: "Value deliberately not decoded",
|
||||
...(bag.encryption
|
||||
? { Encryption: pfxEncryptionSummary(bag.encryption) }
|
||||
: {}),
|
||||
...(bag.key
|
||||
? {
|
||||
"Private-key algorithm": `${bag.key.algorithm}${bag.key.curve ? ` · ${bag.key.curve}` : ""}`,
|
||||
"Private-key container size": `${bag.key.bytes.toLocaleString()} bytes`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (bag.certificateBytes) {
|
||||
let certificate: CryptoItem;
|
||||
try {
|
||||
certificate = await inspectCertificate(
|
||||
bag.certificateBytes,
|
||||
index,
|
||||
now,
|
||||
);
|
||||
} catch (reason) {
|
||||
throw new Error(
|
||||
`${bag.path} contains an invalid X.509 certificate: ${reason instanceof Error ? reason.message : "certificate parsing failed"}`,
|
||||
{ cause: reason },
|
||||
);
|
||||
}
|
||||
bagItems.push({
|
||||
...certificate,
|
||||
id: `pfx-bag-${index}`,
|
||||
title: bag.friendlyName || certificate.title,
|
||||
facts: { ...bagFacts, ...certificate.facts },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
bagItems.push({
|
||||
id: `pfx-bag-${index}`,
|
||||
type:
|
||||
bag.bagType === "private-key" || bag.bagType === "shrouded-private-key"
|
||||
? "PKCS #12 private-key bag"
|
||||
: `PKCS #12 ${bag.bagType} bag`,
|
||||
title:
|
||||
bag.friendlyName ?? `${bag.bagType.replaceAll("-", " ")} ${index + 1}`,
|
||||
facts: bagFacts,
|
||||
findings: [
|
||||
...(bag.state === "password-required"
|
||||
? [
|
||||
{
|
||||
severity: "info" as const,
|
||||
message:
|
||||
"The encrypted bag was inventoried without attempting decryption.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(bag.key
|
||||
? [
|
||||
{
|
||||
severity: "warning" as const,
|
||||
message:
|
||||
"Only private-key container metadata is shown; secret material is not retained in the inspection result.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(bag.state === "unsupported"
|
||||
? [
|
||||
{
|
||||
severity: "warning" as const,
|
||||
message:
|
||||
"This bag type is identified, but its value is not decoded or exported.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
return [envelope, ...bagItems];
|
||||
}
|
||||
|
||||
export async function inspectCryptoInput(
|
||||
input: string | Uint8Array,
|
||||
now = new Date(),
|
||||
options: { password?: string } = {},
|
||||
): Promise<CryptoInspection> {
|
||||
if (typeof input !== "string" && input.byteLength > MAX_INPUT_BYTES)
|
||||
throw new Error("Input exceeds the 8 MiB inspection limit.");
|
||||
const findings: CryptoFinding[] = [];
|
||||
const pbkdf2Budget: Pbkdf2WorkBudget = { iterations: 0 };
|
||||
let items: CryptoItem[];
|
||||
if (typeof input === "string" && input.trimStart().startsWith("{")) {
|
||||
boundedUtf8Bytes(input);
|
||||
@@ -433,10 +899,27 @@ export async function inspectCryptoInput(
|
||||
const blocks = parsePemBlocks(input);
|
||||
if (!blocks.length)
|
||||
throw new Error("No supported PEM or JWK/JWKS object was found.");
|
||||
items = await Promise.all(
|
||||
blocks.map((block, index) => inspectBlock(block, index, now)),
|
||||
items = [];
|
||||
for (const [index, block] of blocks.entries())
|
||||
items.push(
|
||||
await inspectBlock(block, index, now, options.password, pbkdf2Budget),
|
||||
);
|
||||
} else {
|
||||
if (recognizesPkcs12(input)) {
|
||||
items = await inspectPkcs12Items(input, now, options.password);
|
||||
const paths = await analyzeCertificatePaths(items, now);
|
||||
const chain = chainFromPaths(paths);
|
||||
if (
|
||||
items.some((item) => item.certificate) &&
|
||||
!paths.some((path) => path.status === "self-signed-anchor-present")
|
||||
)
|
||||
findings.push({
|
||||
severity: "info",
|
||||
message:
|
||||
"No supplied path terminates at a valid self-signed certificate. No browser or operating-system trust store is consulted.",
|
||||
});
|
||||
return { items, findings, chain, paths };
|
||||
}
|
||||
const candidates: ((bytes: Uint8Array) => Promise<CryptoItem>)[] = [
|
||||
(bytes) => inspectCertificate(bytes, 0, now),
|
||||
async (bytes) =>
|
||||
@@ -449,6 +932,8 @@ export async function inspectCryptoInput(
|
||||
},
|
||||
0,
|
||||
now,
|
||||
undefined,
|
||||
pbkdf2Budget,
|
||||
),
|
||||
async (bytes) =>
|
||||
inspectBlock(
|
||||
@@ -460,31 +945,100 @@ export async function inspectCryptoInput(
|
||||
},
|
||||
0,
|
||||
now,
|
||||
undefined,
|
||||
pbkdf2Budget,
|
||||
),
|
||||
async (bytes) => {
|
||||
const encryption = inspectEncryptedPkcs8(bytes);
|
||||
if (options.password !== undefined)
|
||||
reservePbkdf2Work(
|
||||
pbkdf2Budget,
|
||||
encryption.iterations,
|
||||
"Encrypted private key 1",
|
||||
);
|
||||
const decrypted =
|
||||
options.password === undefined
|
||||
? undefined
|
||||
: await decryptEncryptedPkcs8(bytes, options.password);
|
||||
try {
|
||||
return {
|
||||
id: "private-0",
|
||||
type: "ENCRYPTED PRIVATE KEY",
|
||||
title: "Encrypted private key 1",
|
||||
facts: {
|
||||
Container: "PKCS #8 EncryptedPrivateKeyInfo",
|
||||
Encryption: `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`,
|
||||
Salt: `${encryption.saltBytes} bytes`,
|
||||
"IV / nonce": `${encryption.ivOrNonceBytes} bytes`,
|
||||
"Encrypted size": `${encryption.encryptedBytes.toLocaleString()} bytes`,
|
||||
"Private-key algorithm": decrypted
|
||||
? `${decrypted.key.algorithm}${decrypted.key.curve ? ` · ${decrypted.key.curve}` : ""}`
|
||||
: "Password required to inspect",
|
||||
"Decryption status": decrypted
|
||||
? "Decrypted and structurally validated in memory"
|
||||
: "Not attempted",
|
||||
"SHA-256 fingerprint": await sha256(buffer(bytes)),
|
||||
},
|
||||
findings: [
|
||||
...(encryption.cipher === "AES-CBC"
|
||||
? [
|
||||
{
|
||||
severity: "warning" as const,
|
||||
message:
|
||||
"AES-CBC PBES2 does not authenticate the ciphertext; a successful padding/structure check is not an integrity guarantee.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
severity: decrypted ? "warning" : "info",
|
||||
message: decrypted
|
||||
? "The PBES2 key was decrypted only in page memory. Private-key material is sensitive and was not added to the report."
|
||||
: "PBES2 parameters were inspected without decryption. Enter a password explicitly to validate/import the key in memory.",
|
||||
},
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
decrypted?.bytes.fill(0);
|
||||
}
|
||||
},
|
||||
];
|
||||
let matched: CryptoItem | undefined;
|
||||
for (const candidate of candidates) {
|
||||
for (const [candidateIndex, candidate] of candidates.entries()) {
|
||||
try {
|
||||
matched = await candidate(input);
|
||||
break;
|
||||
} catch (reason) {
|
||||
if (candidateIndex === 3 && options.password) {
|
||||
let recognized = false;
|
||||
try {
|
||||
inspectEncryptedPkcs8(input);
|
||||
recognized = true;
|
||||
} catch {
|
||||
/* this was not PBES2; continue probing */
|
||||
}
|
||||
if (recognized) throw reason;
|
||||
}
|
||||
/* try the next DER model */
|
||||
}
|
||||
}
|
||||
if (!matched)
|
||||
throw new Error(
|
||||
"The binary input is not a supported DER certificate, CSR, or CRL. PKCS #12/PFX is identified but not decrypted in v0.1.",
|
||||
"The binary input is not a supported DER certificate, CSR, CRL, PBES2 encrypted PKCS #8 key, or PKCS #12/PFX file.",
|
||||
);
|
||||
items = [matched];
|
||||
}
|
||||
const chain = await linkCertificates(items);
|
||||
if (items.some((item) => item.certificate) && chain.length === 0)
|
||||
const paths = await analyzeCertificatePaths(items, now);
|
||||
const chain = chainFromPaths(paths);
|
||||
if (
|
||||
items.some((item) => item.certificate) &&
|
||||
!paths.some((path) => path.status === "self-signed-anchor-present")
|
||||
)
|
||||
findings.push({
|
||||
severity: "info",
|
||||
message:
|
||||
"No complete issuer link was found. No browser or operating-system trust store is consulted.",
|
||||
"No supplied path terminates at a valid self-signed certificate. No browser or operating-system trust store is consulted.",
|
||||
});
|
||||
return { items, findings, chain };
|
||||
return { items, findings, chain, paths };
|
||||
}
|
||||
|
||||
function dnsMatch(pattern: string, hostname: string): boolean {
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
import "reflect-metadata";
|
||||
import { PemConverter, X509Certificate } from "@peculiar/x509";
|
||||
import {
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
secureRandomBytes,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
import { decryptEncryptedPkcs8 } from "./pbes2";
|
||||
|
||||
const MAX_KEY_SOURCE = 8 * 1024 * 1024;
|
||||
const MAX_MESSAGE = 8 * 1024 * 1024;
|
||||
const MAX_CIPHERTEXT = 16 * 1024 * 1024;
|
||||
|
||||
export type OperationAlgorithm =
|
||||
| "rsa-pss-sha256"
|
||||
| "rsa-pkcs1-sha256"
|
||||
| "ecdsa-p256-sha256"
|
||||
| "ed25519"
|
||||
| "rsa-oaep-sha256"
|
||||
| "aes-gcm-256";
|
||||
|
||||
export const OPERATION_ALGORITHMS: ReadonlyArray<{
|
||||
id: OperationAlgorithm;
|
||||
label: string;
|
||||
operations: readonly ("sign" | "verify" | "encrypt" | "decrypt")[];
|
||||
limit: string;
|
||||
}> = [
|
||||
{
|
||||
id: "rsa-pss-sha256",
|
||||
label: "RSA-PSS · SHA-256 · 32-byte salt",
|
||||
operations: ["sign", "verify"],
|
||||
limit: "RSA modulus 2,048–16,384 bits; fixed 32-byte PSS salt.",
|
||||
},
|
||||
{
|
||||
id: "rsa-pkcs1-sha256",
|
||||
label: "RSASSA-PKCS1-v1_5 · SHA-256",
|
||||
operations: ["sign", "verify"],
|
||||
limit: "RSA modulus 2,048–16,384 bits.",
|
||||
},
|
||||
{
|
||||
id: "ecdsa-p256-sha256",
|
||||
label: "ECDSA P-256 · SHA-256",
|
||||
operations: ["sign", "verify"],
|
||||
limit: "P-256 keys only; WebCrypto IEEE P1363 signature representation.",
|
||||
},
|
||||
{
|
||||
id: "ed25519",
|
||||
label: "Ed25519",
|
||||
operations: ["sign", "verify"],
|
||||
limit:
|
||||
"Available only when the current browser WebCrypto implements Ed25519.",
|
||||
},
|
||||
{
|
||||
id: "rsa-oaep-sha256",
|
||||
label: "RSA-OAEP · SHA-256",
|
||||
operations: ["encrypt", "decrypt"],
|
||||
limit: "RSA modulus 2,048–16,384 bits; no OAEP label; short messages only.",
|
||||
},
|
||||
{
|
||||
id: "aes-gcm-256",
|
||||
label: "AES-256-GCM · random 96-bit IV · 128-bit tag",
|
||||
operations: ["encrypt", "decrypt"],
|
||||
limit:
|
||||
"Exactly 256-bit JWK/raw key; fresh WebCrypto IV generated for every encryption.",
|
||||
},
|
||||
];
|
||||
|
||||
export interface CryptoOperationResult {
|
||||
operation: "sign" | "verify" | "encrypt" | "decrypt";
|
||||
algorithm: OperationAlgorithm;
|
||||
output: string;
|
||||
valid?: boolean;
|
||||
facts: Record<string, string>;
|
||||
}
|
||||
|
||||
interface AlgorithmDefinition {
|
||||
importAlgorithm:
|
||||
| AlgorithmIdentifier
|
||||
| RsaHashedImportParams
|
||||
| EcKeyImportParams
|
||||
| AesKeyAlgorithm;
|
||||
operationAlgorithm:
|
||||
AlgorithmIdentifier | RsaPssParams | EcdsaParams | RsaOaepParams;
|
||||
expected: "RSA" | "EC" | "Ed25519" | "AES";
|
||||
}
|
||||
|
||||
function definition(algorithm: OperationAlgorithm): AlgorithmDefinition {
|
||||
switch (algorithm) {
|
||||
case "rsa-pss-sha256":
|
||||
return {
|
||||
importAlgorithm: { name: "RSA-PSS", hash: "SHA-256" },
|
||||
operationAlgorithm: { name: "RSA-PSS", saltLength: 32 },
|
||||
expected: "RSA",
|
||||
};
|
||||
case "rsa-pkcs1-sha256":
|
||||
return {
|
||||
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
operationAlgorithm: { name: "RSASSA-PKCS1-v1_5" },
|
||||
expected: "RSA",
|
||||
};
|
||||
case "ecdsa-p256-sha256":
|
||||
return {
|
||||
importAlgorithm: { name: "ECDSA", namedCurve: "P-256" },
|
||||
operationAlgorithm: { name: "ECDSA", hash: "SHA-256" },
|
||||
expected: "EC",
|
||||
};
|
||||
case "ed25519":
|
||||
return {
|
||||
importAlgorithm: { name: "Ed25519" },
|
||||
operationAlgorithm: { name: "Ed25519" },
|
||||
expected: "Ed25519",
|
||||
};
|
||||
case "rsa-oaep-sha256":
|
||||
return {
|
||||
importAlgorithm: { name: "RSA-OAEP", hash: "SHA-256" },
|
||||
operationAlgorithm: { name: "RSA-OAEP" },
|
||||
expected: "RSA",
|
||||
};
|
||||
case "aes-gcm-256":
|
||||
return {
|
||||
importAlgorithm: { name: "AES-GCM", length: 256 },
|
||||
operationAlgorithm: { name: "AES-GCM" },
|
||||
expected: "AES",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(
|
||||
value: string,
|
||||
name: string,
|
||||
maximum = MAX_MESSAGE,
|
||||
): Uint8Array {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
if (bytes.length > maximum)
|
||||
throw new Error(
|
||||
`${name} exceeds ${(maximum / 1024 / 1024).toLocaleString()} MiB.`,
|
||||
);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function arrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function onePem(source: string): { label: string; bytes: Uint8Array } {
|
||||
const matches = [
|
||||
...source.matchAll(
|
||||
/-----BEGIN ([A-Z0-9][A-Z0-9 -]{0,80})-----[\s\S]*?-----END \1-----/gu,
|
||||
),
|
||||
];
|
||||
if (matches.length !== 1)
|
||||
throw new Error("Operation key input must contain exactly one PEM block.");
|
||||
let decoded: ArrayBuffer;
|
||||
try {
|
||||
decoded = PemConverter.decodeFirst(matches[0]![0]);
|
||||
} catch {
|
||||
throw new Error("Operation key PEM has invalid framing or Base64.");
|
||||
}
|
||||
return { label: matches[0]![1]!, bytes: new Uint8Array(decoded) };
|
||||
}
|
||||
|
||||
function publicJwk(value: JsonWebKey): JsonWebKey {
|
||||
if (value.kty === "RSA") return { kty: "RSA", n: value.n, e: value.e };
|
||||
if (value.kty === "EC")
|
||||
return { kty: "EC", crv: value.crv, x: value.x, y: value.y };
|
||||
if (value.kty === "OKP") return { kty: "OKP", crv: value.crv, x: value.x };
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseJwk(source: string): JsonWebKey {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(source);
|
||||
} catch {
|
||||
throw new Error("Operation JWK is not valid JSON.");
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
||||
throw new Error("Operation JWK must be one JSON object.");
|
||||
const jwk = parsed as JsonWebKey;
|
||||
if (typeof jwk.kty !== "string") throw new Error("Operation JWK has no kty.");
|
||||
return jwk;
|
||||
}
|
||||
|
||||
async function sourceForImport(
|
||||
source: string,
|
||||
password: string,
|
||||
role: "private" | "public",
|
||||
): Promise<{
|
||||
format: "pkcs8" | "spki" | "jwk";
|
||||
keyData: ArrayBuffer | JsonWebKey;
|
||||
cleanup?: () => void;
|
||||
}> {
|
||||
if (boundedText(source, "Key input", MAX_KEY_SOURCE).length === 0)
|
||||
throw new Error("Key input is required.");
|
||||
if (source.trimStart().startsWith("{")) {
|
||||
const jwk = parseJwk(source);
|
||||
return {
|
||||
format: "jwk",
|
||||
keyData: role === "public" ? publicJwk(jwk) : jwk,
|
||||
};
|
||||
}
|
||||
const pem = onePem(source);
|
||||
if (role === "private") {
|
||||
if (pem.label === "PRIVATE KEY") {
|
||||
const keyData = arrayBuffer(pem.bytes);
|
||||
return {
|
||||
format: "pkcs8",
|
||||
keyData,
|
||||
cleanup: () => {
|
||||
pem.bytes.fill(0);
|
||||
new Uint8Array(keyData).fill(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (pem.label === "ENCRYPTED PRIVATE KEY") {
|
||||
const decrypted = await decryptEncryptedPkcs8(pem.bytes, password);
|
||||
try {
|
||||
const keyData = arrayBuffer(decrypted.bytes);
|
||||
return {
|
||||
format: "pkcs8",
|
||||
keyData,
|
||||
cleanup: () => {
|
||||
decrypted.bytes.fill(0);
|
||||
new Uint8Array(keyData).fill(0);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
decrypted.bytes.fill(0);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"Private operations accept PKCS #8 PRIVATE KEY, PBES2 ENCRYPTED PRIVATE KEY, or private JWK. PKCS #1/SEC1 conversion is intentionally not guessed.",
|
||||
);
|
||||
}
|
||||
if (pem.label === "PUBLIC KEY")
|
||||
return { format: "spki", keyData: arrayBuffer(pem.bytes) };
|
||||
if (pem.label === "CERTIFICATE" || pem.label === "X509 CERTIFICATE") {
|
||||
const certificate = new X509Certificate(arrayBuffer(pem.bytes));
|
||||
return { format: "spki", keyData: certificate.publicKey.rawData };
|
||||
}
|
||||
throw new Error(
|
||||
"Public operations accept SubjectPublicKeyInfo PUBLIC KEY, CERTIFICATE, or public JWK.",
|
||||
);
|
||||
}
|
||||
|
||||
function assertKeyLimits(
|
||||
key: CryptoKey,
|
||||
expected: AlgorithmDefinition["expected"],
|
||||
) {
|
||||
if (expected === "RSA") {
|
||||
const algorithm = key.algorithm as RsaKeyAlgorithm;
|
||||
if (
|
||||
algorithm.name.startsWith("RSA") ||
|
||||
algorithm.name.startsWith("RSASSA")
|
||||
) {
|
||||
if (algorithm.modulusLength < 2_048 || algorithm.modulusLength > 16_384)
|
||||
throw new Error("RSA modulus must contain 2,048–16,384 bits.");
|
||||
return;
|
||||
}
|
||||
} else if (expected === "EC") {
|
||||
const algorithm = key.algorithm as EcKeyAlgorithm;
|
||||
if (algorithm.name === "ECDSA" && algorithm.namedCurve === "P-256") return;
|
||||
} else if (expected === "Ed25519") {
|
||||
if (key.algorithm.name === "Ed25519") return;
|
||||
} else if (expected === "AES") {
|
||||
const algorithm = key.algorithm as AesKeyAlgorithm;
|
||||
if (algorithm.name === "AES-GCM" && algorithm.length === 256) return;
|
||||
}
|
||||
throw new Error(
|
||||
`Imported key does not match the selected ${expected} algorithm limit.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function importAsymmetric(
|
||||
source: string,
|
||||
password: string,
|
||||
algorithm: OperationAlgorithm,
|
||||
role: "private" | "public",
|
||||
usage: KeyUsage,
|
||||
): Promise<CryptoKey> {
|
||||
const selected = definition(algorithm);
|
||||
if (selected.expected === "AES")
|
||||
throw new Error("Selected operation requires an AES key.");
|
||||
const input = await sourceForImport(source, password, role);
|
||||
try {
|
||||
let key: CryptoKey;
|
||||
try {
|
||||
key =
|
||||
input.format === "jwk"
|
||||
? await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
input.keyData as JsonWebKey,
|
||||
selected.importAlgorithm,
|
||||
false,
|
||||
[usage],
|
||||
)
|
||||
: await crypto.subtle.importKey(
|
||||
input.format,
|
||||
input.keyData as ArrayBuffer,
|
||||
selected.importAlgorithm,
|
||||
false,
|
||||
[usage],
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`WebCrypto could not import this ${role} key for the selected algorithm. Check the key container, curve/hash family, password, and key_ops/usages.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
assertKeyLimits(key, selected.expected);
|
||||
return key;
|
||||
} finally {
|
||||
input.cleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function importAes(
|
||||
source: string,
|
||||
usage: "encrypt" | "decrypt",
|
||||
): Promise<CryptoKey> {
|
||||
const trimmed = source.trim();
|
||||
let format: "jwk" | "raw";
|
||||
let data: JsonWebKey | ArrayBuffer;
|
||||
if (trimmed.startsWith("{")) {
|
||||
format = "jwk";
|
||||
data = parseJwk(trimmed);
|
||||
} else {
|
||||
const encoded = trimmed.startsWith("base64url:")
|
||||
? trimmed.slice("base64url:".length)
|
||||
: trimmed;
|
||||
const bytes = base64UrlToBytes(encoded, { maxOutputBytes: 32 });
|
||||
if (bytes.length !== 32 || bytesToBase64Url(bytes) !== encoded)
|
||||
throw new Error(
|
||||
"Raw AES key must be canonical unpadded Base64url for exactly 32 bytes.",
|
||||
);
|
||||
format = "raw";
|
||||
data = arrayBuffer(bytes);
|
||||
}
|
||||
try {
|
||||
const key =
|
||||
format === "jwk"
|
||||
? await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
data as JsonWebKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
[usage],
|
||||
)
|
||||
: await crypto.subtle.importKey(
|
||||
"raw",
|
||||
data as ArrayBuffer,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
[usage],
|
||||
);
|
||||
assertKeyLimits(key, "AES");
|
||||
return key;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Raw AES key"))
|
||||
throw error;
|
||||
throw new Error(
|
||||
"WebCrypto could not import an exact 256-bit AES-GCM key for this operation.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function algorithmLimit(id: OperationAlgorithm): string {
|
||||
return OPERATION_ALGORITHMS.find((item) => item.id === id)?.limit ?? "";
|
||||
}
|
||||
|
||||
export function generateAesKeySource(): string {
|
||||
return bytesToBase64Url(secureRandomBytes(32, crypto, 32));
|
||||
}
|
||||
|
||||
export async function signText(
|
||||
algorithm: OperationAlgorithm,
|
||||
privateKey: string,
|
||||
password: string,
|
||||
message: string,
|
||||
): Promise<CryptoOperationResult> {
|
||||
const selected = definition(algorithm);
|
||||
if (
|
||||
!["RSA", "EC", "Ed25519"].includes(selected.expected) ||
|
||||
algorithm === "rsa-oaep-sha256"
|
||||
)
|
||||
throw new Error("Selected algorithm does not support signing.");
|
||||
const data = boundedText(message, "Message");
|
||||
const key = await importAsymmetric(
|
||||
privateKey,
|
||||
password,
|
||||
algorithm,
|
||||
"private",
|
||||
"sign",
|
||||
);
|
||||
const signature = await crypto.subtle.sign(
|
||||
selected.operationAlgorithm,
|
||||
key,
|
||||
arrayBuffer(data),
|
||||
);
|
||||
return {
|
||||
operation: "sign",
|
||||
algorithm,
|
||||
output: bytesToBase64Url(new Uint8Array(signature)),
|
||||
facts: {
|
||||
"Message bytes": String(data.length),
|
||||
"Signature bytes": String(signature.byteLength),
|
||||
Encoding: "Unpadded Base64url",
|
||||
Limit: algorithmLimit(algorithm),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyText(
|
||||
algorithm: OperationAlgorithm,
|
||||
publicKey: string,
|
||||
message: string,
|
||||
signature: string,
|
||||
): Promise<CryptoOperationResult> {
|
||||
const selected = definition(algorithm);
|
||||
if (
|
||||
!["RSA", "EC", "Ed25519"].includes(selected.expected) ||
|
||||
algorithm === "rsa-oaep-sha256"
|
||||
)
|
||||
throw new Error("Selected algorithm does not support verification.");
|
||||
const data = boundedText(message, "Message");
|
||||
const signatureBytes = base64UrlToBytes(signature.trim(), {
|
||||
maxOutputBytes: MAX_CIPHERTEXT,
|
||||
});
|
||||
const key = await importAsymmetric(
|
||||
publicKey,
|
||||
"",
|
||||
algorithm,
|
||||
"public",
|
||||
"verify",
|
||||
);
|
||||
const valid = await crypto.subtle.verify(
|
||||
selected.operationAlgorithm,
|
||||
key,
|
||||
arrayBuffer(signatureBytes),
|
||||
arrayBuffer(data),
|
||||
);
|
||||
return {
|
||||
operation: "verify",
|
||||
algorithm,
|
||||
output: valid ? "Signature is valid." : "Signature is not valid.",
|
||||
valid,
|
||||
facts: {
|
||||
"Message bytes": String(data.length),
|
||||
"Signature bytes": String(signatureBytes.length),
|
||||
Limit: algorithmLimit(algorithm),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function encryptText(
|
||||
algorithm: OperationAlgorithm,
|
||||
keySource: string,
|
||||
message: string,
|
||||
): Promise<CryptoOperationResult> {
|
||||
const data = boundedText(message, "Plaintext");
|
||||
if (algorithm === "aes-gcm-256") {
|
||||
const key = await importAes(keySource, "encrypt");
|
||||
const iv = secureRandomBytes(12, crypto, 12);
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: arrayBuffer(iv), tagLength: 128 },
|
||||
key,
|
||||
arrayBuffer(data),
|
||||
),
|
||||
);
|
||||
const output = JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
algorithm: "AES-256-GCM",
|
||||
iv: bytesToBase64Url(iv),
|
||||
ciphertext: bytesToBase64Url(ciphertext),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return {
|
||||
operation: "encrypt",
|
||||
algorithm,
|
||||
output,
|
||||
facts: {
|
||||
"Plaintext bytes": String(data.length),
|
||||
"Ciphertext and tag bytes": String(ciphertext.length),
|
||||
Limit: algorithmLimit(algorithm),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (algorithm !== "rsa-oaep-sha256")
|
||||
throw new Error("Selected algorithm does not support encryption.");
|
||||
const key = await importAsymmetric(
|
||||
keySource,
|
||||
"",
|
||||
algorithm,
|
||||
"public",
|
||||
"encrypt",
|
||||
);
|
||||
const modulusLength = (key.algorithm as RsaKeyAlgorithm).modulusLength;
|
||||
const maximum = modulusLength / 8 - 2 * 32 - 2;
|
||||
if (data.length > maximum)
|
||||
throw new Error(
|
||||
`RSA-OAEP SHA-256 plaintext is limited to ${maximum} bytes for this key.`,
|
||||
);
|
||||
const encrypted = new Uint8Array(
|
||||
await crypto.subtle.encrypt({ name: "RSA-OAEP" }, key, arrayBuffer(data)),
|
||||
);
|
||||
return {
|
||||
operation: "encrypt",
|
||||
algorithm,
|
||||
output: bytesToBase64Url(encrypted),
|
||||
facts: {
|
||||
"Plaintext bytes": String(data.length),
|
||||
"Ciphertext bytes": String(encrypted.length),
|
||||
"Maximum plaintext bytes": String(maximum),
|
||||
Encoding: "Unpadded Base64url",
|
||||
Limit: algorithmLimit(algorithm),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface AesEnvelope {
|
||||
schemaVersion: 1;
|
||||
algorithm: "AES-256-GCM";
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
function parseAesEnvelope(source: string): AesEnvelope {
|
||||
if (source.length > MAX_CIPHERTEXT * 2)
|
||||
throw new Error("AES-GCM envelope exceeds the input limit.");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(source);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"AES-GCM ciphertext must be the JSON envelope produced by this tool.",
|
||||
);
|
||||
}
|
||||
const value = parsed as Partial<AesEnvelope>;
|
||||
if (
|
||||
!value ||
|
||||
value.schemaVersion !== 1 ||
|
||||
value.algorithm !== "AES-256-GCM" ||
|
||||
typeof value.iv !== "string" ||
|
||||
typeof value.ciphertext !== "string"
|
||||
)
|
||||
throw new Error("AES-GCM envelope fields are incomplete or unsupported.");
|
||||
return value as AesEnvelope;
|
||||
}
|
||||
|
||||
export async function decryptText(
|
||||
algorithm: OperationAlgorithm,
|
||||
keySource: string,
|
||||
password: string,
|
||||
ciphertext: string,
|
||||
): Promise<CryptoOperationResult> {
|
||||
let decrypted: ArrayBuffer;
|
||||
let ciphertextBytes: Uint8Array;
|
||||
if (algorithm === "aes-gcm-256") {
|
||||
const envelope = parseAesEnvelope(ciphertext);
|
||||
const iv = base64UrlToBytes(envelope.iv, { maxOutputBytes: 12 });
|
||||
if (iv.length !== 12 || bytesToBase64Url(iv) !== envelope.iv)
|
||||
throw new Error(
|
||||
"AES-GCM envelope IV must be canonical 12-byte Base64url.",
|
||||
);
|
||||
ciphertextBytes = base64UrlToBytes(envelope.ciphertext, {
|
||||
maxOutputBytes: MAX_CIPHERTEXT,
|
||||
});
|
||||
const key = await importAes(keySource, "decrypt");
|
||||
try {
|
||||
decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: arrayBuffer(iv), tagLength: 128 },
|
||||
key,
|
||||
arrayBuffer(ciphertextBytes),
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"AES-GCM authentication failed; no plaintext was released.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (algorithm !== "rsa-oaep-sha256")
|
||||
throw new Error("Selected algorithm does not support decryption.");
|
||||
ciphertextBytes = base64UrlToBytes(ciphertext.trim(), {
|
||||
maxOutputBytes: MAX_CIPHERTEXT,
|
||||
});
|
||||
const key = await importAsymmetric(
|
||||
keySource,
|
||||
password,
|
||||
algorithm,
|
||||
"private",
|
||||
"decrypt",
|
||||
);
|
||||
if (
|
||||
ciphertextBytes.length !==
|
||||
(key.algorithm as RsaKeyAlgorithm).modulusLength / 8
|
||||
)
|
||||
throw new Error(
|
||||
"RSA-OAEP ciphertext length does not match the selected key modulus.",
|
||||
);
|
||||
try {
|
||||
decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
key,
|
||||
arrayBuffer(ciphertextBytes),
|
||||
);
|
||||
} catch {
|
||||
throw new Error("RSA-OAEP decryption failed; no plaintext was released.");
|
||||
}
|
||||
}
|
||||
let output: string;
|
||||
try {
|
||||
output = new TextDecoder("utf-8", { fatal: true }).decode(decrypted);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Decrypted bytes are not valid UTF-8 text; binary plaintext is not rendered.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
operation: "decrypt",
|
||||
algorithm,
|
||||
output,
|
||||
facts: {
|
||||
"Ciphertext bytes": String(ciphertextBytes.length),
|
||||
"Plaintext bytes": String(decrypted.byteLength),
|
||||
Limit: algorithmLimit(algorithm),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import {
|
||||
BaseBlock,
|
||||
Integer,
|
||||
Null,
|
||||
ObjectIdentifier,
|
||||
OctetString,
|
||||
Sequence,
|
||||
fromBER,
|
||||
} from "asn1js";
|
||||
|
||||
const PBES2 = "1.2.840.113549.1.5.13";
|
||||
const PBKDF2 = "1.2.840.113549.1.5.12";
|
||||
const MAX_ENCRYPTED_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_ITERATIONS = 10_000_000;
|
||||
export const MAX_TOTAL_PBKDF2_ITERATIONS = 20_000_000;
|
||||
|
||||
const PRFS: Record<string, string> = {
|
||||
"1.2.840.113549.2.7": "SHA-1",
|
||||
"1.2.840.113549.2.9": "SHA-256",
|
||||
"1.2.840.113549.2.10": "SHA-384",
|
||||
"1.2.840.113549.2.11": "SHA-512",
|
||||
};
|
||||
|
||||
const CIPHERS: Record<
|
||||
string,
|
||||
{ name: "AES-CBC" | "AES-GCM"; length: 128 | 192 | 256 }
|
||||
> = {
|
||||
"2.16.840.1.101.3.4.1.2": { name: "AES-CBC", length: 128 },
|
||||
"2.16.840.1.101.3.4.1.22": { name: "AES-CBC", length: 192 },
|
||||
"2.16.840.1.101.3.4.1.42": { name: "AES-CBC", length: 256 },
|
||||
"2.16.840.1.101.3.4.1.6": { name: "AES-GCM", length: 128 },
|
||||
"2.16.840.1.101.3.4.1.26": { name: "AES-GCM", length: 192 },
|
||||
"2.16.840.1.101.3.4.1.46": { name: "AES-GCM", length: 256 },
|
||||
};
|
||||
|
||||
const PRIVATE_KEY_ALGORITHMS: Record<string, string> = {
|
||||
"1.2.840.113549.1.1.1": "RSA",
|
||||
"1.2.840.113549.1.1.10": "RSA-PSS",
|
||||
"1.2.840.10045.2.1": "EC",
|
||||
"1.3.101.112": "Ed25519",
|
||||
"1.3.101.113": "Ed448",
|
||||
"1.3.101.110": "X25519",
|
||||
"1.3.101.111": "X448",
|
||||
};
|
||||
|
||||
const CURVES: Record<string, string> = {
|
||||
"1.2.840.10045.3.1.7": "P-256",
|
||||
"1.3.132.0.34": "P-384",
|
||||
"1.3.132.0.35": "P-521",
|
||||
};
|
||||
|
||||
export interface Pbkdf2WorkBudget {
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
export function reservePbkdf2Work(
|
||||
budget: Pbkdf2WorkBudget,
|
||||
iterations: number,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!Number.isSafeInteger(budget.iterations) ||
|
||||
budget.iterations < 0 ||
|
||||
!Number.isSafeInteger(iterations) ||
|
||||
iterations < 1 ||
|
||||
budget.iterations > MAX_TOTAL_PBKDF2_ITERATIONS - iterations
|
||||
) {
|
||||
throw new Error(
|
||||
`${label} exceeds the ${MAX_TOTAL_PBKDF2_ITERATIONS.toLocaleString()}-iteration aggregate PBKDF2 safety budget. Inspect fewer encrypted objects at once.`,
|
||||
);
|
||||
}
|
||||
budget.iterations += iterations;
|
||||
}
|
||||
|
||||
type Block = BaseBlock;
|
||||
|
||||
function asBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function parseDer(bytes: Uint8Array, name: string): Block {
|
||||
if (bytes.byteLength === 0 || bytes.byteLength > MAX_ENCRYPTED_BYTES)
|
||||
throw new Error(`${name} must contain 1 byte–8 MiB.`);
|
||||
const parsed = fromBER(asBuffer(bytes));
|
||||
if (parsed.offset === -1 || parsed.offset !== bytes.byteLength)
|
||||
throw new Error(`${name} is malformed DER or contains trailing bytes.`);
|
||||
const canonical = new Uint8Array(parsed.result.toBER(false));
|
||||
if (
|
||||
canonical.length !== bytes.length ||
|
||||
canonical.some((value, index) => value !== bytes[index])
|
||||
)
|
||||
throw new Error(`${name} must use canonical definite-length DER.`);
|
||||
return parsed.result;
|
||||
}
|
||||
|
||||
function sequence(block: Block | undefined, name: string): Block[] {
|
||||
if (!(block instanceof Sequence))
|
||||
throw new Error(`${name} must be an ASN.1 SEQUENCE.`);
|
||||
return block.valueBlock.value;
|
||||
}
|
||||
|
||||
function oid(block: Block | undefined, name: string): string {
|
||||
if (!(block instanceof ObjectIdentifier))
|
||||
throw new Error(`${name} must be an ASN.1 object identifier.`);
|
||||
return block.getValue();
|
||||
}
|
||||
|
||||
function octets(block: Block | undefined, name: string): Uint8Array {
|
||||
if (!(block instanceof OctetString))
|
||||
throw new Error(`${name} must be an ASN.1 OCTET STRING.`);
|
||||
return new Uint8Array(block.getValue());
|
||||
}
|
||||
|
||||
function integer(block: Block | undefined, name: string): bigint {
|
||||
if (!(block instanceof Integer))
|
||||
throw new Error(`${name} must be an ASN.1 INTEGER.`);
|
||||
return block.toBigInt();
|
||||
}
|
||||
|
||||
export interface Pbes2Inspection {
|
||||
scheme: "PBES2";
|
||||
kdf: "PBKDF2";
|
||||
prf: string;
|
||||
iterations: number;
|
||||
saltBytes: number;
|
||||
cipher: "AES-CBC" | "AES-GCM";
|
||||
keyLength: 128 | 192 | 256;
|
||||
ivOrNonceBytes: number;
|
||||
tagLength?: number;
|
||||
encryptedBytes: number;
|
||||
}
|
||||
|
||||
interface ParsedPbes2 extends Pbes2Inspection {
|
||||
salt: Uint8Array;
|
||||
ivOrNonce: Uint8Array;
|
||||
encryptedData: Uint8Array;
|
||||
}
|
||||
|
||||
function parsePbes2(bytes: Uint8Array): ParsedPbes2 {
|
||||
const root = sequence(
|
||||
parseDer(bytes, "Encrypted PKCS #8"),
|
||||
"EncryptedPrivateKeyInfo",
|
||||
);
|
||||
if (root.length !== 2)
|
||||
throw new Error(
|
||||
"EncryptedPrivateKeyInfo must contain an algorithm and encrypted data.",
|
||||
);
|
||||
return parsePbes2Algorithm(
|
||||
root[0],
|
||||
octets(root[1], "Encrypted PKCS #8 data"),
|
||||
);
|
||||
}
|
||||
|
||||
function parsePbes2Algorithm(
|
||||
algorithmBlock: Block | undefined,
|
||||
encryptedData: Uint8Array,
|
||||
): ParsedPbes2 {
|
||||
const algorithm = sequence(algorithmBlock, "Encryption algorithm");
|
||||
if (
|
||||
algorithm.length !== 2 ||
|
||||
oid(algorithm[0], "Encryption algorithm") !== PBES2
|
||||
)
|
||||
throw new Error("Only PKCS #5 PBES2 encrypted PKCS #8 is supported.");
|
||||
const params = sequence(algorithm[1], "PBES2 parameters");
|
||||
if (params.length !== 2) throw new Error("PBES2 parameters are incomplete.");
|
||||
|
||||
const kdf = sequence(params[0], "PBES2 key derivation function");
|
||||
if (kdf.length !== 2 || oid(kdf[0], "KDF algorithm") !== PBKDF2)
|
||||
throw new Error("Only PBKDF2 key derivation is supported for PBES2.");
|
||||
const pbkdf = sequence(kdf[1], "PBKDF2 parameters");
|
||||
if (pbkdf.length < 2 || pbkdf.length > 4)
|
||||
throw new Error("PBKDF2 parameters have an unsupported shape.");
|
||||
const salt = octets(pbkdf[0], "PBKDF2 salt");
|
||||
if (salt.length < 8 || salt.length > 1_024)
|
||||
throw new Error("PBKDF2 salt must contain 8–1,024 bytes.");
|
||||
const iterationBig = integer(pbkdf[1], "PBKDF2 iteration count");
|
||||
if (iterationBig < 1n || iterationBig > BigInt(MAX_ITERATIONS))
|
||||
throw new Error(
|
||||
`PBKDF2 iteration count must be 1–${MAX_ITERATIONS.toLocaleString()}.`,
|
||||
);
|
||||
let cursor = 2;
|
||||
let declaredKeyLength: number | undefined;
|
||||
if (pbkdf[cursor] instanceof Integer) {
|
||||
const bytesLong = integer(pbkdf[cursor], "PBKDF2 key length");
|
||||
if (bytesLong < 1n || bytesLong > 64n)
|
||||
throw new Error("PBKDF2 key length is outside the supported bound.");
|
||||
declaredKeyLength = Number(bytesLong) * 8;
|
||||
cursor += 1;
|
||||
}
|
||||
let prf = "SHA-1";
|
||||
if (pbkdf[cursor]) {
|
||||
const prfAlgorithm = sequence(pbkdf[cursor], "PBKDF2 PRF");
|
||||
if (
|
||||
prfAlgorithm.length < 1 ||
|
||||
prfAlgorithm.length > 2 ||
|
||||
(prfAlgorithm[1] !== undefined && !(prfAlgorithm[1] instanceof Null))
|
||||
)
|
||||
throw new Error("PBKDF2 PRF parameters must be absent or NULL.");
|
||||
prf = PRFS[oid(prfAlgorithm[0], "PBKDF2 PRF algorithm")] ?? "";
|
||||
if (!prf)
|
||||
throw new Error(
|
||||
"PBKDF2 PRF is not supported by this WebCrypto workflow.",
|
||||
);
|
||||
cursor += 1;
|
||||
}
|
||||
if (cursor !== pbkdf.length)
|
||||
throw new Error("PBKDF2 parameters contain unsupported trailing fields.");
|
||||
|
||||
const encryption = sequence(params[1], "PBES2 encryption scheme");
|
||||
if (encryption.length !== 2)
|
||||
throw new Error("PBES2 encryption scheme parameters are incomplete.");
|
||||
const cipher = CIPHERS[oid(encryption[0], "PBES2 cipher")];
|
||||
if (!cipher)
|
||||
throw new Error("PBES2 cipher is not a supported AES-CBC/AES-GCM scheme.");
|
||||
if (declaredKeyLength !== undefined && declaredKeyLength !== cipher.length)
|
||||
throw new Error(
|
||||
"PBKDF2 declared key length does not match the AES scheme.",
|
||||
);
|
||||
|
||||
let ivOrNonce: Uint8Array;
|
||||
let tagLength: number | undefined;
|
||||
if (cipher.name === "AES-CBC") {
|
||||
ivOrNonce = octets(encryption[1], "AES-CBC IV");
|
||||
if (ivOrNonce.length !== 16)
|
||||
throw new Error("AES-CBC IV must contain 16 bytes.");
|
||||
} else {
|
||||
const gcm = sequence(encryption[1], "AES-GCM parameters");
|
||||
if (gcm.length < 1 || gcm.length > 2)
|
||||
throw new Error("AES-GCM parameters have an unsupported shape.");
|
||||
ivOrNonce = octets(gcm[0], "AES-GCM nonce");
|
||||
if (ivOrNonce.length < 12 || ivOrNonce.length > 16)
|
||||
throw new Error("AES-GCM nonce must contain 12–16 bytes.");
|
||||
const tagBytes = gcm[1] ? integer(gcm[1], "AES-GCM tag length") : 12n;
|
||||
if (![12n, 13n, 14n, 15n, 16n].includes(tagBytes))
|
||||
throw new Error("AES-GCM tag length must contain 12–16 octets.");
|
||||
tagLength = Number(tagBytes) * 8;
|
||||
}
|
||||
if (encryptedData.length === 0 || encryptedData.length > MAX_ENCRYPTED_BYTES)
|
||||
throw new Error("PBES2 encrypted payload must contain 1 byte–8 MiB.");
|
||||
return {
|
||||
scheme: "PBES2",
|
||||
kdf: "PBKDF2",
|
||||
prf,
|
||||
iterations: Number(iterationBig),
|
||||
saltBytes: salt.length,
|
||||
cipher: cipher.name,
|
||||
keyLength: cipher.length,
|
||||
ivOrNonceBytes: ivOrNonce.length,
|
||||
...(tagLength ? { tagLength } : {}),
|
||||
encryptedBytes: encryptedData.length,
|
||||
salt,
|
||||
ivOrNonce,
|
||||
encryptedData,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectEncryptedPkcs8(bytes: Uint8Array): Pbes2Inspection {
|
||||
const parsed = parsePbes2(bytes);
|
||||
return {
|
||||
scheme: parsed.scheme,
|
||||
kdf: parsed.kdf,
|
||||
prf: parsed.prf,
|
||||
iterations: parsed.iterations,
|
||||
saltBytes: parsed.saltBytes,
|
||||
cipher: parsed.cipher,
|
||||
keyLength: parsed.keyLength,
|
||||
ivOrNonceBytes: parsed.ivOrNonceBytes,
|
||||
...(parsed.tagLength === undefined ? {} : { tagLength: parsed.tagLength }),
|
||||
encryptedBytes: parsed.encryptedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectPbes2Payload(
|
||||
algorithmIdentifier: Uint8Array,
|
||||
encryptedData: Uint8Array,
|
||||
): Pbes2Inspection {
|
||||
const parsed = parsePbes2Algorithm(
|
||||
parseDer(algorithmIdentifier, "PBES2 AlgorithmIdentifier"),
|
||||
encryptedData,
|
||||
);
|
||||
return publicPbes2Inspection(parsed);
|
||||
}
|
||||
|
||||
export interface Pkcs8Inspection {
|
||||
algorithmOid: string;
|
||||
algorithm: string;
|
||||
curve?: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export function inspectPkcs8(bytes: Uint8Array): Pkcs8Inspection {
|
||||
const root = sequence(
|
||||
parseDer(bytes, "PKCS #8 private key"),
|
||||
"PrivateKeyInfo",
|
||||
);
|
||||
if (root.length < 3) throw new Error("PKCS #8 PrivateKeyInfo is incomplete.");
|
||||
const version = integer(root[0], "PKCS #8 version");
|
||||
if (version < 0n || version > 1n)
|
||||
throw new Error("PKCS #8 version is unsupported.");
|
||||
const algorithmIdentifier = sequence(root[1], "Private-key algorithm");
|
||||
const algorithmOid = oid(algorithmIdentifier[0], "Private-key algorithm");
|
||||
const algorithm = PRIVATE_KEY_ALGORITHMS[algorithmOid] ?? "Unknown";
|
||||
const curveOid =
|
||||
algorithm === "EC" && algorithmIdentifier[1] instanceof ObjectIdentifier
|
||||
? algorithmIdentifier[1].getValue()
|
||||
: undefined;
|
||||
// Validates that the privateKey field is present and encoded as octets.
|
||||
octets(root[2], "PKCS #8 privateKey");
|
||||
return {
|
||||
algorithmOid,
|
||||
algorithm,
|
||||
...(curveOid ? { curve: CURVES[curveOid] ?? `OID ${curveOid}` } : {}),
|
||||
bytes: bytes.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptEncryptedPkcs8(
|
||||
bytes: Uint8Array,
|
||||
password: string,
|
||||
): Promise<{
|
||||
bytes: Uint8Array;
|
||||
inspection: Pbes2Inspection;
|
||||
key: Pkcs8Inspection;
|
||||
}> {
|
||||
if (password.length > 100_000)
|
||||
throw new Error("Password must contain at most 100,000 UTF-16 units.");
|
||||
const parsed = parsePbes2(bytes);
|
||||
const output = await decryptParsedPbes2(parsed, password);
|
||||
try {
|
||||
return {
|
||||
bytes: output,
|
||||
inspection: publicPbes2Inspection(parsed),
|
||||
key: inspectPkcs8(output),
|
||||
};
|
||||
} catch (error) {
|
||||
output.fill(0);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptPbes2Payload(
|
||||
algorithmIdentifier: Uint8Array,
|
||||
encryptedData: Uint8Array,
|
||||
password: string,
|
||||
): Promise<{ bytes: Uint8Array; inspection: Pbes2Inspection }> {
|
||||
if (password.length > 100_000)
|
||||
throw new Error("Password must contain at most 100,000 UTF-16 units.");
|
||||
const parsed = parsePbes2Algorithm(
|
||||
parseDer(algorithmIdentifier, "PBES2 AlgorithmIdentifier"),
|
||||
encryptedData,
|
||||
);
|
||||
return {
|
||||
bytes: await decryptParsedPbes2(parsed, password),
|
||||
inspection: publicPbes2Inspection(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
function publicPbes2Inspection(parsed: ParsedPbes2): Pbes2Inspection {
|
||||
return {
|
||||
scheme: parsed.scheme,
|
||||
kdf: parsed.kdf,
|
||||
prf: parsed.prf,
|
||||
iterations: parsed.iterations,
|
||||
saltBytes: parsed.saltBytes,
|
||||
cipher: parsed.cipher,
|
||||
keyLength: parsed.keyLength,
|
||||
ivOrNonceBytes: parsed.ivOrNonceBytes,
|
||||
...(parsed.tagLength === undefined ? {} : { tagLength: parsed.tagLength }),
|
||||
encryptedBytes: parsed.encryptedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptParsedPbes2(
|
||||
parsed: ParsedPbes2,
|
||||
password: string,
|
||||
): Promise<Uint8Array> {
|
||||
const material = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: asBuffer(parsed.salt),
|
||||
iterations: parsed.iterations,
|
||||
hash: parsed.prf,
|
||||
},
|
||||
material,
|
||||
{ name: parsed.cipher, length: parsed.keyLength },
|
||||
false,
|
||||
["decrypt"],
|
||||
);
|
||||
let decrypted: ArrayBuffer;
|
||||
try {
|
||||
decrypted = await crypto.subtle.decrypt(
|
||||
parsed.cipher === "AES-CBC"
|
||||
? { name: "AES-CBC", iv: asBuffer(parsed.ivOrNonce) }
|
||||
: {
|
||||
name: "AES-GCM",
|
||||
iv: asBuffer(parsed.ivOrNonce),
|
||||
tagLength: parsed.tagLength ?? 96,
|
||||
},
|
||||
key,
|
||||
asBuffer(parsed.encryptedData),
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"PBES2 decryption failed. The password may be wrong, the data may be damaged, or this WebCrypto runtime may not support the parameters.",
|
||||
);
|
||||
}
|
||||
return new Uint8Array(decrypted);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import {
|
||||
BaseBlock,
|
||||
BmpString,
|
||||
Integer,
|
||||
ObjectIdentifier,
|
||||
OctetString,
|
||||
Sequence,
|
||||
Set as AsnSet,
|
||||
Utf8String,
|
||||
fromBER,
|
||||
} from "asn1js";
|
||||
import {
|
||||
decryptEncryptedPkcs8,
|
||||
decryptPbes2Payload,
|
||||
inspectEncryptedPkcs8,
|
||||
inspectPbes2Payload,
|
||||
inspectPkcs8,
|
||||
reservePbkdf2Work,
|
||||
type Pbkdf2WorkBudget,
|
||||
type Pbes2Inspection,
|
||||
type Pkcs8Inspection,
|
||||
} from "./pbes2";
|
||||
|
||||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_CONTENT_INFOS = 64;
|
||||
const MAX_BAGS = 512;
|
||||
const MAX_NESTING_DEPTH = 4;
|
||||
const MAX_ATTRIBUTES = 32;
|
||||
const MAX_MAC_ITERATIONS = 50_000;
|
||||
const MAX_PASSWORD_UNITS = 4_096;
|
||||
|
||||
const CMS_DATA = "1.2.840.113549.1.7.1";
|
||||
const CMS_ENCRYPTED_DATA = "1.2.840.113549.1.7.6";
|
||||
const PBES2 = "1.2.840.113549.1.5.13";
|
||||
const LEGACY_PKCS12_PBE_PREFIX = "1.2.840.113549.1.12.1.";
|
||||
|
||||
const KEY_BAG = "1.2.840.113549.1.12.10.1.1";
|
||||
const SHROUDED_KEY_BAG = "1.2.840.113549.1.12.10.1.2";
|
||||
const CERT_BAG = "1.2.840.113549.1.12.10.1.3";
|
||||
const CRL_BAG = "1.2.840.113549.1.12.10.1.4";
|
||||
const SECRET_BAG = "1.2.840.113549.1.12.10.1.5";
|
||||
const SAFE_CONTENTS_BAG = "1.2.840.113549.1.12.10.1.6";
|
||||
const X509_CERTIFICATE = "1.2.840.113549.1.9.22.1";
|
||||
const FRIENDLY_NAME = "1.2.840.113549.1.9.20";
|
||||
const LOCAL_KEY_ID = "1.2.840.113549.1.9.21";
|
||||
|
||||
type Block = BaseBlock;
|
||||
|
||||
const DIGESTS: Record<
|
||||
string,
|
||||
{ name: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; u: number; v: number }
|
||||
> = {
|
||||
"1.3.14.3.2.26": { name: "SHA-1", u: 20, v: 64 },
|
||||
"2.16.840.1.101.3.4.2.1": { name: "SHA-256", u: 32, v: 64 },
|
||||
"2.16.840.1.101.3.4.2.2": { name: "SHA-384", u: 48, v: 128 },
|
||||
"2.16.840.1.101.3.4.2.3": { name: "SHA-512", u: 64, v: 128 },
|
||||
};
|
||||
|
||||
export interface Pkcs12MacInspection {
|
||||
present: boolean;
|
||||
status: "absent" | "password-required" | "verified" | "unsupported";
|
||||
algorithm?: string;
|
||||
algorithmOid?: string;
|
||||
iterations?: number;
|
||||
saltBytes?: number;
|
||||
}
|
||||
|
||||
export interface Pkcs12ContentInventory {
|
||||
index: number;
|
||||
type: "data" | "encryptedData";
|
||||
state: "parsed" | "password-required";
|
||||
encryption?: Pbes2Inspection;
|
||||
bagCount: number;
|
||||
}
|
||||
|
||||
export interface Pkcs12BagInventory {
|
||||
path: string;
|
||||
bagType:
|
||||
| "private-key"
|
||||
| "shrouded-private-key"
|
||||
| "certificate"
|
||||
| "safe-contents"
|
||||
| "crl"
|
||||
| "secret"
|
||||
| "unknown";
|
||||
bagOid: string;
|
||||
friendlyName?: string;
|
||||
localKeyId?: string;
|
||||
encrypted: boolean;
|
||||
state: "inspected" | "password-required" | "unsupported";
|
||||
key?: Pkcs8Inspection;
|
||||
encryption?: Pbes2Inspection;
|
||||
certificateBytes?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface Pkcs12Inspection {
|
||||
recognized: true;
|
||||
version: 3;
|
||||
bytes: number;
|
||||
authSafeContentType: typeof CMS_DATA;
|
||||
mac: Pkcs12MacInspection;
|
||||
contents: Pkcs12ContentInventory[];
|
||||
bags: Pkcs12BagInventory[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface ParsedMacData {
|
||||
digest: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
algorithmOid: string;
|
||||
algorithm?: (typeof DIGESTS)[string];
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
interface InventoryContext {
|
||||
password?: string;
|
||||
bags: Pkcs12BagInventory[];
|
||||
warnings: string[];
|
||||
pbkdf2Budget: Pbkdf2WorkBudget;
|
||||
}
|
||||
|
||||
function asBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function derBytes(block: Block): Uint8Array {
|
||||
return new Uint8Array(block.toBER(false));
|
||||
}
|
||||
|
||||
function parseDer(bytes: Uint8Array, name: string): Block {
|
||||
if (bytes.length === 0 || bytes.length > MAX_INPUT_BYTES)
|
||||
throw new Error(`${name} must contain 1 byte–8 MiB.`);
|
||||
const parsed = fromBER(asBuffer(bytes));
|
||||
if (parsed.offset === -1 || parsed.offset !== bytes.length)
|
||||
throw new Error(`${name} is malformed DER or contains trailing bytes.`);
|
||||
const canonical = new Uint8Array(parsed.result.toBER(false));
|
||||
if (
|
||||
canonical.length !== bytes.length ||
|
||||
canonical.some((value, index) => value !== bytes[index])
|
||||
)
|
||||
throw new Error(`${name} must use canonical definite-length DER.`);
|
||||
return parsed.result;
|
||||
}
|
||||
|
||||
function sequence(block: Block | undefined, name: string): Block[] {
|
||||
if (!(block instanceof Sequence))
|
||||
throw new Error(`${name} must be an ASN.1 SEQUENCE.`);
|
||||
return block.valueBlock.value;
|
||||
}
|
||||
|
||||
function set(block: Block | undefined, name: string): Block[] {
|
||||
if (!(block instanceof AsnSet))
|
||||
throw new Error(`${name} must be an ASN.1 SET.`);
|
||||
return block.valueBlock.value;
|
||||
}
|
||||
|
||||
function oid(block: Block | undefined, name: string): string {
|
||||
if (!(block instanceof ObjectIdentifier))
|
||||
throw new Error(`${name} must be an ASN.1 object identifier.`);
|
||||
return block.getValue();
|
||||
}
|
||||
|
||||
function integer(block: Block | undefined, name: string): bigint {
|
||||
if (!(block instanceof Integer))
|
||||
throw new Error(`${name} must be an ASN.1 INTEGER.`);
|
||||
return block.toBigInt();
|
||||
}
|
||||
|
||||
function octets(block: Block | undefined, name: string): Uint8Array {
|
||||
if (!(block instanceof OctetString))
|
||||
throw new Error(`${name} must be an ASN.1 OCTET STRING.`);
|
||||
return new Uint8Array(block.getValue());
|
||||
}
|
||||
|
||||
function explicit(block: Block | undefined, tag: number, name: string): Block {
|
||||
if (
|
||||
!block ||
|
||||
block.idBlock.tagClass !== 3 ||
|
||||
block.idBlock.tagNumber !== tag ||
|
||||
!block.idBlock.isConstructed
|
||||
)
|
||||
throw new Error(`${name} must be an explicit [${tag}] value.`);
|
||||
const constructed = block.valueBlock as Block["valueBlock"] & {
|
||||
value?: Block[];
|
||||
};
|
||||
if (!Array.isArray(constructed.value) || constructed.value.length !== 1)
|
||||
throw new Error(`${name} must contain exactly one value.`);
|
||||
return constructed.value[0]!;
|
||||
}
|
||||
|
||||
function implicitOctets(
|
||||
block: Block | undefined,
|
||||
tag: number,
|
||||
name: string,
|
||||
): Uint8Array {
|
||||
if (
|
||||
!block ||
|
||||
block.idBlock.tagClass !== 3 ||
|
||||
block.idBlock.tagNumber !== tag ||
|
||||
block.idBlock.isConstructed
|
||||
)
|
||||
throw new Error(`${name} must be a primitive implicit [${tag}] value.`);
|
||||
const primitive = block.valueBlock as Block["valueBlock"] & {
|
||||
valueHexView?: Uint8Array;
|
||||
};
|
||||
return new Uint8Array(primitive.valueHexView ?? new Uint8Array());
|
||||
}
|
||||
|
||||
function boundedPassword(password: string): void {
|
||||
if (password.length > MAX_PASSWORD_UNITS)
|
||||
throw new Error(
|
||||
`PKCS #12 password must contain at most ${MAX_PASSWORD_UNITS.toLocaleString()} UTF-16 units.`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseContentInfo(block: Block, name: string): [string, Block] {
|
||||
const fields = sequence(block, name);
|
||||
if (fields.length !== 2)
|
||||
throw new Error(`${name} must contain a content type and [0] content.`);
|
||||
return [oid(fields[0], `${name} content type`), explicit(fields[1], 0, name)];
|
||||
}
|
||||
|
||||
function parseMacData(block: Block | undefined): ParsedMacData {
|
||||
const fields = sequence(block, "PFX MacData");
|
||||
if (fields.length < 2 || fields.length > 3)
|
||||
throw new Error("PFX MacData has an unsupported shape.");
|
||||
const digestInfo = sequence(fields[0], "PFX MacData DigestInfo");
|
||||
if (digestInfo.length !== 2)
|
||||
throw new Error("PFX MacData DigestInfo is incomplete.");
|
||||
const algorithmIdentifier = sequence(
|
||||
digestInfo[0],
|
||||
"PFX MacData digest algorithm",
|
||||
);
|
||||
if (algorithmIdentifier.length < 1 || algorithmIdentifier.length > 2)
|
||||
throw new Error("PFX MacData digest AlgorithmIdentifier is malformed.");
|
||||
const algorithmOid = oid(
|
||||
algorithmIdentifier[0],
|
||||
"PFX MacData digest algorithm",
|
||||
);
|
||||
const iterationsBig = fields[2]
|
||||
? integer(fields[2], "PFX MacData iterations")
|
||||
: 1n;
|
||||
if (iterationsBig < 1n || iterationsBig > BigInt(MAX_MAC_ITERATIONS))
|
||||
throw new Error(
|
||||
`PFX MacData iterations must be 1–${MAX_MAC_ITERATIONS.toLocaleString()}.`,
|
||||
);
|
||||
const salt = octets(fields[1], "PFX MacData salt");
|
||||
if (salt.length < 1 || salt.length > 1_024)
|
||||
throw new Error("PFX MacData salt must contain 1–1,024 bytes.");
|
||||
const digest = octets(digestInfo[1], "PFX MacData digest");
|
||||
const algorithm = DIGESTS[algorithmOid];
|
||||
if (algorithm && digest.length !== algorithm.u)
|
||||
throw new Error(
|
||||
`PFX MacData ${algorithm.name} digest must contain ${algorithm.u} bytes.`,
|
||||
);
|
||||
return {
|
||||
digest,
|
||||
salt,
|
||||
algorithmOid,
|
||||
...(algorithm ? { algorithm } : {}),
|
||||
iterations: Number(iterationsBig),
|
||||
};
|
||||
}
|
||||
|
||||
function pkcs12PasswordBytes(password: string): Uint8Array {
|
||||
const output = new Uint8Array((password.length + 1) * 2);
|
||||
for (let index = 0; index < password.length; index += 1) {
|
||||
const unit = password.charCodeAt(index);
|
||||
output[index * 2] = unit >>> 8;
|
||||
output[index * 2 + 1] = unit & 0xff;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function repeatToMultiple(source: Uint8Array, multiple: number): Uint8Array {
|
||||
if (source.length === 0) return source;
|
||||
const length = multiple * Math.ceil(source.length / multiple);
|
||||
return Uint8Array.from(
|
||||
{ length },
|
||||
(_, index) => source[index % source.length]!,
|
||||
);
|
||||
}
|
||||
|
||||
async function pkcs12MacKey(
|
||||
password: string,
|
||||
salt: Uint8Array,
|
||||
iterations: number,
|
||||
digest: NonNullable<ParsedMacData["algorithm"]>,
|
||||
): Promise<Uint8Array> {
|
||||
// RFC 7292 Appendix B, diversifier ID 3 (MAC material).
|
||||
const diversifier = new Uint8Array(digest.v).fill(3);
|
||||
const passwordBytes = pkcs12PasswordBytes(password);
|
||||
const saltBlock = repeatToMultiple(salt, digest.v);
|
||||
const passwordBlock = repeatToMultiple(passwordBytes, digest.v);
|
||||
const state = new Uint8Array(saltBlock.length + passwordBlock.length);
|
||||
state.set(saltBlock);
|
||||
state.set(passwordBlock, saltBlock.length);
|
||||
const source = new Uint8Array(diversifier.length + state.length);
|
||||
source.set(diversifier);
|
||||
source.set(state, diversifier.length);
|
||||
let derived = new Uint8Array();
|
||||
const blocks = Math.ceil(digest.u / digest.u);
|
||||
for (let blockIndex = 0; blockIndex < blocks; blockIndex += 1) {
|
||||
let a = new Uint8Array(await crypto.subtle.digest(digest.name, source));
|
||||
for (let round = 1; round < iterations; round += 1)
|
||||
a = new Uint8Array(await crypto.subtle.digest(digest.name, a));
|
||||
const next = new Uint8Array(derived.length + a.length);
|
||||
next.set(derived);
|
||||
next.set(a, derived.length);
|
||||
derived = next;
|
||||
if (state.length > 0) {
|
||||
const b = Uint8Array.from(
|
||||
{ length: digest.v },
|
||||
(_, index) => a[index % a.length]!,
|
||||
);
|
||||
for (let offset = 0; offset < state.length; offset += digest.v) {
|
||||
let carry = 1;
|
||||
for (let index = digest.v - 1; index >= 0; index -= 1) {
|
||||
const position = offset + index;
|
||||
const sum = state[position]! + b[index]! + carry;
|
||||
state[position] = sum & 0xff;
|
||||
carry = sum >>> 8;
|
||||
}
|
||||
}
|
||||
source.set(state, diversifier.length);
|
||||
}
|
||||
}
|
||||
passwordBytes.fill(0);
|
||||
state.fill(0);
|
||||
source.fill(0);
|
||||
return derived.slice(0, digest.u);
|
||||
}
|
||||
|
||||
function equalConstantTime(left: Uint8Array, right: Uint8Array): boolean {
|
||||
let difference = left.length ^ right.length;
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1)
|
||||
difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
async function verifyMacData(
|
||||
mac: ParsedMacData,
|
||||
authSafeBytes: Uint8Array,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
if (!mac.algorithm)
|
||||
throw new Error(
|
||||
`PFX MacData digest ${mac.algorithmOid} is not supported by this WebCrypto workflow.`,
|
||||
);
|
||||
const keyBytes = await pkcs12MacKey(
|
||||
password,
|
||||
mac.salt,
|
||||
mac.iterations,
|
||||
mac.algorithm,
|
||||
);
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
asBuffer(keyBytes),
|
||||
{ name: "HMAC", hash: mac.algorithm.name },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const actual = new Uint8Array(
|
||||
await crypto.subtle.sign("HMAC", key, asBuffer(authSafeBytes)),
|
||||
);
|
||||
if (!equalConstantTime(actual, mac.digest))
|
||||
throw new Error(
|
||||
"PKCS #12 password is incorrect, or MacData/authSafe bytes are damaged.",
|
||||
);
|
||||
} finally {
|
||||
keyBytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAttributes(
|
||||
block: Block | undefined,
|
||||
path: string,
|
||||
): Pick<Pkcs12BagInventory, "friendlyName" | "localKeyId"> {
|
||||
if (!block) return {};
|
||||
const attributes = set(block, `${path} attributes`);
|
||||
if (attributes.length > MAX_ATTRIBUTES)
|
||||
throw new Error(`${path} has more than ${MAX_ATTRIBUTES} attributes.`);
|
||||
let friendlyName: string | undefined;
|
||||
let localKeyId: string | undefined;
|
||||
for (const attribute of attributes) {
|
||||
const fields = sequence(attribute, `${path} attribute`);
|
||||
if (fields.length !== 2)
|
||||
throw new Error(`${path} contains a malformed bag attribute.`);
|
||||
const attributeOid = oid(fields[0], `${path} attribute type`);
|
||||
const values = set(fields[1], `${path} attribute values`);
|
||||
if (values.length !== 1)
|
||||
throw new Error(`${path} bag attributes must contain one value.`);
|
||||
const value = values[0]!;
|
||||
if (attributeOid === FRIENDLY_NAME) {
|
||||
if (!(value instanceof BmpString) && !(value instanceof Utf8String))
|
||||
throw new Error(
|
||||
`${path} friendlyName must be BMPString or UTF8String.`,
|
||||
);
|
||||
const candidate = value.getValue();
|
||||
if (candidate.length > 256)
|
||||
throw new Error(`${path} friendlyName exceeds 256 UTF-16 units.`);
|
||||
friendlyName = candidate;
|
||||
} else if (attributeOid === LOCAL_KEY_ID) {
|
||||
const candidate = octets(value, `${path} localKeyId`);
|
||||
if (candidate.length > 64)
|
||||
throw new Error(`${path} localKeyId exceeds 64 bytes.`);
|
||||
localKeyId = Array.from(candidate, (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(friendlyName === undefined ? {} : { friendlyName }),
|
||||
...(localKeyId === undefined ? {} : { localKeyId }),
|
||||
};
|
||||
}
|
||||
|
||||
function addBag(context: InventoryContext, bag: Pkcs12BagInventory): void {
|
||||
if (context.bags.length >= MAX_BAGS)
|
||||
throw new Error(`PFX contains more than ${MAX_BAGS} SafeBags.`);
|
||||
context.bags.push(bag);
|
||||
}
|
||||
|
||||
async function inspectSafeContents(
|
||||
bytes: Uint8Array,
|
||||
context: InventoryContext,
|
||||
prefix: string,
|
||||
depth: number,
|
||||
): Promise<number> {
|
||||
if (depth > MAX_NESTING_DEPTH)
|
||||
throw new Error(
|
||||
`PFX SafeContents nesting exceeds ${MAX_NESTING_DEPTH} levels.`,
|
||||
);
|
||||
const bags = sequence(
|
||||
parseDer(bytes, `${prefix} SafeContents`),
|
||||
"SafeContents",
|
||||
);
|
||||
const before = context.bags.length;
|
||||
for (const [index, block] of bags.entries()) {
|
||||
const path = `${prefix}/bag[${index}]`;
|
||||
const fields = sequence(block, path);
|
||||
if (fields.length < 2 || fields.length > 3)
|
||||
throw new Error(`${path} has an unsupported SafeBag shape.`);
|
||||
const bagOid = oid(fields[0], `${path} bagId`);
|
||||
const bagValue = explicit(fields[1], 0, `${path} bagValue`);
|
||||
const attributes = parseAttributes(fields[2], path);
|
||||
if (bagOid === KEY_BAG) {
|
||||
const keyBytes = derBytes(bagValue);
|
||||
try {
|
||||
addBag(context, {
|
||||
path,
|
||||
bagType: "private-key",
|
||||
bagOid,
|
||||
...attributes,
|
||||
encrypted: false,
|
||||
state: "inspected",
|
||||
key: inspectPkcs8(keyBytes),
|
||||
});
|
||||
} finally {
|
||||
keyBytes.fill(0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (bagOid === SHROUDED_KEY_BAG) {
|
||||
const encryptedBytes = derBytes(bagValue);
|
||||
let encryption: Pbes2Inspection;
|
||||
try {
|
||||
encryption = inspectEncryptedPkcs8(encryptedBytes);
|
||||
} catch (reason) {
|
||||
throw new Error(
|
||||
`${path} uses a legacy or unsupported shrouded-key PBE: ${reason instanceof Error ? reason.message : "unsupported algorithm"}`,
|
||||
{ cause: reason },
|
||||
);
|
||||
}
|
||||
if (context.password === undefined) {
|
||||
addBag(context, {
|
||||
path,
|
||||
bagType: "shrouded-private-key",
|
||||
bagOid,
|
||||
...attributes,
|
||||
encrypted: true,
|
||||
state: "password-required",
|
||||
encryption,
|
||||
});
|
||||
} else {
|
||||
reservePbkdf2Work(context.pbkdf2Budget, encryption.iterations, path);
|
||||
let decrypted: Awaited<ReturnType<typeof decryptEncryptedPkcs8>>;
|
||||
try {
|
||||
decrypted = await decryptEncryptedPkcs8(
|
||||
encryptedBytes,
|
||||
context.password,
|
||||
);
|
||||
} catch (reason) {
|
||||
throw new Error(
|
||||
`${path} could not decrypt its shrouded PKCS #8 key: ${reason instanceof Error ? reason.message : "decryption failed"}`,
|
||||
{ cause: reason },
|
||||
);
|
||||
}
|
||||
try {
|
||||
addBag(context, {
|
||||
path,
|
||||
bagType: "shrouded-private-key",
|
||||
bagOid,
|
||||
...attributes,
|
||||
encrypted: true,
|
||||
state: "inspected",
|
||||
encryption,
|
||||
key: decrypted.key,
|
||||
});
|
||||
} finally {
|
||||
decrypted.bytes.fill(0);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (bagOid === CERT_BAG) {
|
||||
const certFields = sequence(bagValue, `${path} CertBag`);
|
||||
if (certFields.length !== 2)
|
||||
throw new Error(`${path} CertBag has an unsupported shape.`);
|
||||
const certId = oid(certFields[0], `${path} certificate type`);
|
||||
if (certId !== X509_CERTIFICATE)
|
||||
throw new Error(`${path} certificate type ${certId} is unsupported.`);
|
||||
addBag(context, {
|
||||
path,
|
||||
bagType: "certificate",
|
||||
bagOid,
|
||||
...attributes,
|
||||
encrypted: false,
|
||||
state: "inspected",
|
||||
certificateBytes: octets(
|
||||
explicit(certFields[1], 0, `${path} certificate value`),
|
||||
`${path} X.509 certificate`,
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (bagOid === SAFE_CONTENTS_BAG) {
|
||||
addBag(context, {
|
||||
path,
|
||||
bagType: "safe-contents",
|
||||
bagOid,
|
||||
...attributes,
|
||||
encrypted: false,
|
||||
state: "inspected",
|
||||
});
|
||||
const nestedBytes = derBytes(bagValue);
|
||||
try {
|
||||
await inspectSafeContents(nestedBytes, context, path, depth + 1);
|
||||
} finally {
|
||||
nestedBytes.fill(0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const bagType =
|
||||
bagOid === CRL_BAG ? "crl" : bagOid === SECRET_BAG ? "secret" : "unknown";
|
||||
addBag(context, {
|
||||
path,
|
||||
bagType,
|
||||
bagOid,
|
||||
...attributes,
|
||||
encrypted: false,
|
||||
state: "unsupported",
|
||||
});
|
||||
context.warnings.push(
|
||||
`${path} (${bagType}, OID ${bagOid}) is inventoried but its value is deliberately not decoded or exported.`,
|
||||
);
|
||||
}
|
||||
return context.bags.length - before;
|
||||
}
|
||||
|
||||
async function inspectAuthenticatedSafe(
|
||||
bytes: Uint8Array,
|
||||
context: InventoryContext,
|
||||
): Promise<Pkcs12ContentInventory[]> {
|
||||
const blocks = sequence(
|
||||
parseDer(bytes, "PFX AuthenticatedSafe"),
|
||||
"AuthenticatedSafe",
|
||||
);
|
||||
if (blocks.length > MAX_CONTENT_INFOS)
|
||||
throw new Error(
|
||||
`PFX AuthenticatedSafe contains more than ${MAX_CONTENT_INFOS} ContentInfo values.`,
|
||||
);
|
||||
const contents: Pkcs12ContentInventory[] = [];
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const [contentType, content] = parseContentInfo(
|
||||
block,
|
||||
`AuthenticatedSafe[${index}]`,
|
||||
);
|
||||
const prefix = `content[${index}]`;
|
||||
if (contentType === CMS_DATA) {
|
||||
const bagCount = await inspectSafeContents(
|
||||
octets(content, `${prefix} data`),
|
||||
context,
|
||||
prefix,
|
||||
0,
|
||||
);
|
||||
contents.push({ index, type: "data", state: "parsed", bagCount });
|
||||
continue;
|
||||
}
|
||||
if (contentType !== CMS_ENCRYPTED_DATA)
|
||||
throw new Error(
|
||||
`AuthenticatedSafe[${index}] content type ${contentType} is unsupported; only data and encryptedData are accepted.`,
|
||||
);
|
||||
const encryptedData = sequence(content, `${prefix} EncryptedData`);
|
||||
if (encryptedData.length < 2 || encryptedData.length > 3)
|
||||
throw new Error(`${prefix} EncryptedData has an unsupported shape.`);
|
||||
if (integer(encryptedData[0], `${prefix} EncryptedData version`) !== 0n)
|
||||
throw new Error(`${prefix} EncryptedData version must be 0.`);
|
||||
const encryptedContentInfo = sequence(
|
||||
encryptedData[1],
|
||||
`${prefix} EncryptedContentInfo`,
|
||||
);
|
||||
if (encryptedContentInfo.length !== 3)
|
||||
throw new Error(`${prefix} EncryptedContentInfo is incomplete.`);
|
||||
if (
|
||||
oid(encryptedContentInfo[0], `${prefix} encrypted content type`) !==
|
||||
CMS_DATA
|
||||
)
|
||||
throw new Error(`${prefix} encrypted content must contain CMS data.`);
|
||||
const algorithmIdentifier = derBytes(encryptedContentInfo[1]!);
|
||||
const algorithmFields = sequence(
|
||||
encryptedContentInfo[1],
|
||||
`${prefix} encryption algorithm`,
|
||||
);
|
||||
const algorithmOid = oid(
|
||||
algorithmFields[0],
|
||||
`${prefix} encryption algorithm`,
|
||||
);
|
||||
if (algorithmOid !== PBES2) {
|
||||
const family = algorithmOid.startsWith(LEGACY_PKCS12_PBE_PREFIX)
|
||||
? "legacy PKCS #12 PBE"
|
||||
: "unsupported encryption";
|
||||
throw new Error(
|
||||
`${prefix} uses ${family} OID ${algorithmOid}; only PBES2/PBKDF2 with AES-CBC or AES-GCM is supported.`,
|
||||
);
|
||||
}
|
||||
const encryptedBytes = implicitOctets(
|
||||
encryptedContentInfo[2],
|
||||
0,
|
||||
`${prefix} encryptedContent`,
|
||||
);
|
||||
const encryption = inspectPbes2Payload(algorithmIdentifier, encryptedBytes);
|
||||
if (context.password === undefined) {
|
||||
contents.push({
|
||||
index,
|
||||
type: "encryptedData",
|
||||
state: "password-required",
|
||||
encryption,
|
||||
bagCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let plaintext: Uint8Array | undefined;
|
||||
try {
|
||||
reservePbkdf2Work(context.pbkdf2Budget, encryption.iterations, prefix);
|
||||
plaintext = (
|
||||
await decryptPbes2Payload(
|
||||
algorithmIdentifier,
|
||||
encryptedBytes,
|
||||
context.password,
|
||||
)
|
||||
).bytes;
|
||||
const bagCount = await inspectSafeContents(plaintext, context, prefix, 0);
|
||||
contents.push({
|
||||
index,
|
||||
type: "encryptedData",
|
||||
state: "parsed",
|
||||
encryption,
|
||||
bagCount,
|
||||
});
|
||||
} catch (reason) {
|
||||
throw new Error(
|
||||
`${prefix} PBES2 content could not be decrypted and parsed: ${reason instanceof Error ? reason.message : "decryption failed"}`,
|
||||
{ cause: reason },
|
||||
);
|
||||
} finally {
|
||||
plaintext?.fill(0);
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
export function recognizesPkcs12(bytes: Uint8Array): boolean {
|
||||
try {
|
||||
const root = sequence(parseDer(bytes, "PKCS #12/PFX"), "PFX");
|
||||
return (
|
||||
root.length >= 2 &&
|
||||
root.length <= 3 &&
|
||||
integer(root[0], "PFX version") === 3n &&
|
||||
sequence(root[1], "PFX authSafe ContentInfo").length >= 1
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectPkcs12(
|
||||
bytes: Uint8Array,
|
||||
options: { password?: string } = {},
|
||||
): Promise<Pkcs12Inspection> {
|
||||
if (options.password !== undefined) boundedPassword(options.password);
|
||||
const root = sequence(parseDer(bytes, "PKCS #12/PFX"), "PFX");
|
||||
if (root.length < 2 || root.length > 3)
|
||||
throw new Error("PFX must contain version, authSafe and optional MacData.");
|
||||
if (integer(root[0], "PFX version") !== 3n)
|
||||
throw new Error("PFX version must be 3.");
|
||||
const [authSafeContentType, authSafeContent] = parseContentInfo(
|
||||
root[1]!,
|
||||
"PFX authSafe ContentInfo",
|
||||
);
|
||||
if (authSafeContentType !== CMS_DATA)
|
||||
throw new Error(
|
||||
`PFX authSafe content type ${authSafeContentType} is unsupported; RFC 7292 requires CMS data here.`,
|
||||
);
|
||||
const authSafeBytes = octets(authSafeContent, "PFX authSafe data");
|
||||
let mac: Pkcs12MacInspection;
|
||||
if (!root[2]) {
|
||||
mac = { present: false, status: "absent" };
|
||||
} else {
|
||||
const parsedMac = parseMacData(root[2]);
|
||||
if (options.password === undefined) {
|
||||
mac = {
|
||||
present: true,
|
||||
status: parsedMac.algorithm ? "password-required" : "unsupported",
|
||||
algorithm: parsedMac.algorithm?.name ?? `OID ${parsedMac.algorithmOid}`,
|
||||
algorithmOid: parsedMac.algorithmOid,
|
||||
iterations: parsedMac.iterations,
|
||||
saltBytes: parsedMac.salt.length,
|
||||
};
|
||||
} else {
|
||||
await verifyMacData(parsedMac, authSafeBytes, options.password);
|
||||
mac = {
|
||||
present: true,
|
||||
status: "verified",
|
||||
algorithm: parsedMac.algorithm!.name,
|
||||
algorithmOid: parsedMac.algorithmOid,
|
||||
iterations: parsedMac.iterations,
|
||||
saltBytes: parsedMac.salt.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
const context: InventoryContext = {
|
||||
...(options.password === undefined ? {} : { password: options.password }),
|
||||
bags: [],
|
||||
warnings: [],
|
||||
pbkdf2Budget: { iterations: 0 },
|
||||
};
|
||||
if (!root[2])
|
||||
context.warnings.push(
|
||||
"MacData is absent, so the AuthenticatedSafe has no verified password/integrity check.",
|
||||
);
|
||||
const contents = await inspectAuthenticatedSafe(authSafeBytes, context);
|
||||
return {
|
||||
recognized: true,
|
||||
version: 3,
|
||||
bytes: bytes.length,
|
||||
authSafeContentType: CMS_DATA,
|
||||
mac,
|
||||
contents,
|
||||
bags: context.bags,
|
||||
warnings: context.warnings,
|
||||
};
|
||||
}
|
||||
@@ -179,6 +179,20 @@ textarea {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.password-consent {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.55rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.password-consent input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.08rem;
|
||||
accent-color: var(--toolbox-accent);
|
||||
}
|
||||
.muted {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
@@ -319,6 +333,46 @@ textarea {
|
||||
padding-left: 1.25rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.operation-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.operation-tabs button[aria-pressed="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.operation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.limit-card p {
|
||||
min-height: 2.55rem;
|
||||
margin: 0;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.operation-result {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.72rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.operation-result pre,
|
||||
.path-line {
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "reflect-metadata";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.crypto-tools",
|
||||
"name": "Crypto Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect keys, certificates and signatures locally in the browser.",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect keys, certificates, PKCS #12 and signatures locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["security", "cryptography", "developer"],
|
||||
"tags": ["x509", "certificate", "jwk", "signature", "crypto"],
|
||||
"tags": [
|
||||
"x509",
|
||||
"certificate",
|
||||
"pkcs12",
|
||||
"pfx",
|
||||
"jwk",
|
||||
"signature",
|
||||
"crypto"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +29,40 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "application/pem-certificate-chain",
|
||||
"extensions": [".pem", ".crt", ".cer", ".csr", ".key"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/pkix-cert",
|
||||
"extensions": [".der", ".cer"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/pkcs12",
|
||||
"extensions": [".p12", ".pfx"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json", ".jwk", ".jwks"]
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
},
|
||||
{
|
||||
"mediaType": "text/plain",
|
||||
"extensions": [".pem", ".txt"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": ["web-crypto"]
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { MODERN_PFX_BASE64, MODERN_PFX_PASSWORD } from "../fixtures/pkcs12";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4173";
|
||||
function nodeBase64Buffer(value: string): never {
|
||||
return (
|
||||
globalThis as unknown as {
|
||||
Buffer: { from(source: string, encoding: "base64"): never };
|
||||
}
|
||||
).Buffer.from(value, "base64");
|
||||
}
|
||||
|
||||
async function localOnly(page: Page) {
|
||||
const external: string[] = [];
|
||||
await page.route("**/*", async (route) => {
|
||||
@@ -39,6 +48,63 @@ test("inspects a JWK and computes its local thumbprint", async ({ page }) => {
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("round-trips authenticated text with an explicit generated AES key", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/crypto/");
|
||||
await page.getByRole("button", { name: "Generate 256-bit AES key" }).click();
|
||||
await expect(page.getByLabel("Operation key")).toHaveValue(
|
||||
/^[A-Za-z0-9_-]{43}$/u,
|
||||
);
|
||||
await page.getByRole("button", { name: "Encrypt locally" }).click();
|
||||
await expect(page.locator(".operation-result > pre")).toContainText(
|
||||
'"algorithm": "AES-256-GCM"',
|
||||
);
|
||||
await page.getByRole("button", { name: "Decrypt" }).click();
|
||||
await page.getByRole("button", { name: "Decrypt locally" }).click();
|
||||
await expect(page.locator(".operation-result > pre")).toHaveText(
|
||||
"Local-only example",
|
||||
);
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("inspects a modern PFX only after explicit password consent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/crypto/");
|
||||
await page.getByLabel("Open file").setInputFiles({
|
||||
name: "modern-test.p12",
|
||||
mimeType: "application/x-pkcs12",
|
||||
buffer: nodeBase64Buffer(MODERN_PFX_BASE64),
|
||||
});
|
||||
await expect(
|
||||
page.getByText("Present; explicit password required to verify"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Local test identity" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByLabel(/Use this password in memory for this inspection/u)
|
||||
.check();
|
||||
await page
|
||||
.getByLabel(/PBES2 \/ PKCS #12 password/u)
|
||||
.fill(MODERN_PFX_PASSWORD);
|
||||
await page.getByRole("button", { name: "Inspect locally" }).click();
|
||||
await expect(
|
||||
page.getByText("Verified with the explicitly supplied password"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Local PFX Test", { exact: false }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator("dt", { hasText: "Private-key algorithm" }),
|
||||
).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("serves the release identity and hardened headers", async ({
|
||||
request,
|
||||
}) => {
|
||||
@@ -51,7 +117,7 @@ test("serves the release identity and hardened headers", async ({
|
||||
const manifest = await request.get("/deep/nested/crypto/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.crypto-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
entry: "./",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/crypto/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AuthorityKeyIdentifierExtension,
|
||||
BasicConstraintsExtension,
|
||||
KeyUsageFlags,
|
||||
KeyUsagesExtension,
|
||||
SubjectKeyIdentifierExtension,
|
||||
X509CertificateGenerator,
|
||||
} from "@peculiar/x509";
|
||||
import {
|
||||
checkCertificateHostname,
|
||||
inspectCryptoInput,
|
||||
@@ -70,4 +78,74 @@ describe("crypto input inspection", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds an explicitly untrusted path with signature and CA checks", async () => {
|
||||
const algorithm = {
|
||||
name: "ECDSA",
|
||||
namedCurve: "P-256",
|
||||
hash: "SHA-256",
|
||||
} as const;
|
||||
const rootKeys = await crypto.subtle.generateKey(
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
const root = await X509CertificateGenerator.createSelfSigned(
|
||||
{
|
||||
serialNumber: "01",
|
||||
name: "CN=Local Test Root",
|
||||
notBefore: new Date("2029-01-01T00:00:00Z"),
|
||||
notAfter: new Date("2035-01-01T00:00:00Z"),
|
||||
signingAlgorithm: algorithm,
|
||||
keys: rootKeys,
|
||||
extensions: [
|
||||
new BasicConstraintsExtension(true, 1, true),
|
||||
new KeyUsagesExtension(
|
||||
KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign,
|
||||
true,
|
||||
),
|
||||
await SubjectKeyIdentifierExtension.create(rootKeys.publicKey),
|
||||
],
|
||||
},
|
||||
crypto,
|
||||
);
|
||||
const leafKeys = await crypto.subtle.generateKey(
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
const leaf = await X509CertificateGenerator.create(
|
||||
{
|
||||
serialNumber: "02",
|
||||
subject: "CN=Leaf",
|
||||
issuer: root.subject,
|
||||
notBefore: new Date("2029-01-01T00:00:00Z"),
|
||||
notAfter: new Date("2031-01-01T00:00:00Z"),
|
||||
signingAlgorithm: algorithm,
|
||||
publicKey: leafKeys.publicKey,
|
||||
signingKey: rootKeys.privateKey,
|
||||
extensions: [
|
||||
new BasicConstraintsExtension(false, undefined, true),
|
||||
await AuthorityKeyIdentifierExtension.create(rootKeys.publicKey),
|
||||
],
|
||||
},
|
||||
crypto,
|
||||
);
|
||||
const inspection = await inspectCryptoInput(
|
||||
`${leaf.toString("pem")}\n${root.toString("pem")}`,
|
||||
new Date("2030-01-01T00:00:00Z"),
|
||||
);
|
||||
expect(inspection.paths).toHaveLength(1);
|
||||
expect(inspection.paths[0]).toMatchObject({
|
||||
status: "self-signed-anchor-present",
|
||||
trusted: false,
|
||||
certificates: ["CN=Leaf", "CN=Local Test Root"],
|
||||
});
|
||||
expect(inspection.paths[0]?.links[0]).toMatchObject({
|
||||
signatureValid: true,
|
||||
issuerIsCa: true,
|
||||
keyCertSignAllowed: true,
|
||||
authorityKeyIdentifierMatched: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { base64UrlToBytes, bytesToBase64Url } from "@add-ideas/toolbox-helpers";
|
||||
import {
|
||||
decryptText,
|
||||
encryptText,
|
||||
generateAesKeySource,
|
||||
signText,
|
||||
verifyText,
|
||||
} from "../../src/crypto/operations";
|
||||
import { encryptedPkcs8, encryptedPkcs8Pem } from "../fixtures/pbes2";
|
||||
|
||||
describe("concrete WebCrypto operations", () => {
|
||||
it("round-trips authenticated AES-256-GCM and rejects tampering", async () => {
|
||||
const key = generateAesKeySource();
|
||||
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/u);
|
||||
const encrypted = await encryptText("aes-gcm-256", key, "private text");
|
||||
const decrypted = await decryptText(
|
||||
"aes-gcm-256",
|
||||
key,
|
||||
"",
|
||||
encrypted.output,
|
||||
);
|
||||
expect(decrypted.output).toBe("private text");
|
||||
|
||||
const envelope = JSON.parse(encrypted.output) as { ciphertext: string };
|
||||
const tampered = base64UrlToBytes(envelope.ciphertext);
|
||||
tampered[0] = tampered[0]! ^ 1;
|
||||
envelope.ciphertext = bytesToBase64Url(tampered);
|
||||
await expect(
|
||||
decryptText("aes-gcm-256", key, "", JSON.stringify(envelope)),
|
||||
).rejects.toThrow(/authentication failed/u);
|
||||
});
|
||||
|
||||
it("signs and verifies ECDSA P-256 messages", async () => {
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
const privateJwk = JSON.stringify(
|
||||
await crypto.subtle.exportKey("jwk", pair.privateKey),
|
||||
);
|
||||
const publicJwk = JSON.stringify(
|
||||
await crypto.subtle.exportKey("jwk", pair.publicKey),
|
||||
);
|
||||
const signed = await signText(
|
||||
"ecdsa-p256-sha256",
|
||||
privateJwk,
|
||||
"",
|
||||
"bounded message",
|
||||
);
|
||||
await expect(
|
||||
verifyText(
|
||||
"ecdsa-p256-sha256",
|
||||
publicJwk,
|
||||
"bounded message",
|
||||
signed.output,
|
||||
),
|
||||
).resolves.toMatchObject({ valid: true });
|
||||
await expect(
|
||||
verifyText(
|
||||
"ecdsa-p256-sha256",
|
||||
publicJwk,
|
||||
"changed message",
|
||||
signed.output,
|
||||
),
|
||||
).resolves.toMatchObject({ valid: false });
|
||||
});
|
||||
|
||||
it("wipes both plaintext PKCS #8 buffers after encrypted key import", async () => {
|
||||
const encrypted = await encryptedPkcs8("operation secret");
|
||||
const fill = vi.spyOn(Uint8Array.prototype, "fill");
|
||||
try {
|
||||
await signText(
|
||||
"ecdsa-p256-sha256",
|
||||
encryptedPkcs8Pem(encrypted),
|
||||
"operation secret",
|
||||
"bounded message",
|
||||
);
|
||||
expect(fill.mock.calls.filter(([value]) => value === 0)).toHaveLength(2);
|
||||
} finally {
|
||||
fill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces RSA-OAEP message size and round-trips short UTF-8", async () => {
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
hash: "SHA-256",
|
||||
modulusLength: 2048,
|
||||
publicExponent: Uint8Array.of(1, 0, 1),
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
const privateJwk = JSON.stringify(
|
||||
await crypto.subtle.exportKey("jwk", pair.privateKey),
|
||||
);
|
||||
const publicJwk = JSON.stringify(
|
||||
await crypto.subtle.exportKey("jwk", pair.publicKey),
|
||||
);
|
||||
const encrypted = await encryptText(
|
||||
"rsa-oaep-sha256",
|
||||
publicJwk,
|
||||
"short secret",
|
||||
);
|
||||
await expect(
|
||||
decryptText("rsa-oaep-sha256", privateJwk, "", encrypted.output),
|
||||
).resolves.toMatchObject({ output: "short secret" });
|
||||
await expect(
|
||||
encryptText("rsa-oaep-sha256", publicJwk, "x".repeat(191)),
|
||||
).rejects.toThrow(/limited to 190 bytes/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MAX_TOTAL_PBKDF2_ITERATIONS,
|
||||
decryptEncryptedPkcs8,
|
||||
inspectEncryptedPkcs8,
|
||||
reservePbkdf2Work,
|
||||
} from "../../src/crypto/pbes2";
|
||||
import { inspectCryptoInput } from "../../src/crypto/inspection";
|
||||
import {
|
||||
encryptedPbes2Payload,
|
||||
encryptedPkcs8,
|
||||
encryptedPkcs8Pem as pem,
|
||||
} from "../fixtures/pbes2";
|
||||
|
||||
describe("PBES2 encrypted PKCS #8", () => {
|
||||
it("enforces one aggregate PBKDF2 work budget", () => {
|
||||
const budget = { iterations: 0 };
|
||||
reservePbkdf2Work(budget, 10_000_000, "first object");
|
||||
reservePbkdf2Work(budget, 10_000_000, "second object");
|
||||
expect(budget.iterations).toBe(MAX_TOTAL_PBKDF2_ITERATIONS);
|
||||
expect(() => reservePbkdf2Work(budget, 1, "third object")).toThrow(
|
||||
/aggregate PBKDF2 safety budget/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("inspects supported parameters and decrypts only with the password", async () => {
|
||||
const bytes = await encryptedPkcs8("correct horse");
|
||||
expect(inspectEncryptedPkcs8(bytes)).toMatchObject({
|
||||
scheme: "PBES2",
|
||||
kdf: "PBKDF2",
|
||||
prf: "SHA-256",
|
||||
iterations: 12_000,
|
||||
cipher: "AES-CBC",
|
||||
keyLength: 256,
|
||||
});
|
||||
await expect(decryptEncryptedPkcs8(bytes, "wrong battery")).rejects.toThrow(
|
||||
/decryption failed/u,
|
||||
);
|
||||
const decrypted = await decryptEncryptedPkcs8(bytes, "correct horse");
|
||||
expect(decrypted.key).toMatchObject({ algorithm: "EC", curve: "P-256" });
|
||||
decrypted.bytes.fill(0);
|
||||
});
|
||||
|
||||
it("integrates password-gated decryption into PEM inspection", async () => {
|
||||
const bytes = await encryptedPkcs8("local secret");
|
||||
const withoutPassword = await inspectCryptoInput(pem(bytes));
|
||||
expect(withoutPassword.items[0]?.facts["Decryption status"]).toBe(
|
||||
"Not attempted",
|
||||
);
|
||||
const withPassword = await inspectCryptoInput(pem(bytes), new Date(), {
|
||||
password: "local secret",
|
||||
});
|
||||
expect(withPassword.items[0]?.facts["Private-key algorithm"]).toContain(
|
||||
"EC",
|
||||
);
|
||||
});
|
||||
|
||||
it("wipes decrypted private-key bytes after PEM and DER inspection", async () => {
|
||||
const bytes = await encryptedPkcs8("ephemeral secret");
|
||||
const fill = vi.spyOn(Uint8Array.prototype, "fill");
|
||||
try {
|
||||
await inspectCryptoInput(pem(bytes), new Date(), {
|
||||
password: "ephemeral secret",
|
||||
});
|
||||
await inspectCryptoInput(bytes, new Date(), {
|
||||
password: "ephemeral secret",
|
||||
});
|
||||
expect(fill.mock.calls.filter(([value]) => value === 0)).toHaveLength(2);
|
||||
} finally {
|
||||
fill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("wipes decrypted bytes when the plaintext is not valid PKCS #8", async () => {
|
||||
const bytes = await encryptedPbes2Payload(
|
||||
"wrong structure",
|
||||
new TextEncoder().encode("valid AES-CBC padding; invalid PKCS #8"),
|
||||
);
|
||||
const fill = vi.spyOn(Uint8Array.prototype, "fill");
|
||||
try {
|
||||
await expect(
|
||||
decryptEncryptedPkcs8(bytes, "wrong structure"),
|
||||
).rejects.toThrow(/PKCS #8|DER|SEQUENCE/u);
|
||||
expect(fill.mock.calls.filter(([value]) => value === 0)).toHaveLength(1);
|
||||
} finally {
|
||||
fill.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inspectCryptoInput } from "../../src/crypto/inspection";
|
||||
import { inspectPkcs12 } from "../../src/crypto/pkcs12";
|
||||
import {
|
||||
EMPTY_PASSWORD_PFX_BASE64,
|
||||
LEGACY_PFX_BASE64,
|
||||
MODERN_PFX_PASSWORD,
|
||||
pfxFixture,
|
||||
} from "../fixtures/pkcs12";
|
||||
|
||||
describe("bounded PKCS #12 inspection", () => {
|
||||
it("inventories supported content without silently using a password", async () => {
|
||||
const result = await inspectPkcs12(pfxFixture());
|
||||
expect(result.mac).toMatchObject({
|
||||
present: true,
|
||||
status: "password-required",
|
||||
algorithm: "SHA-256",
|
||||
iterations: 2_048,
|
||||
});
|
||||
expect(result.contents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "encryptedData",
|
||||
state: "password-required",
|
||||
}),
|
||||
expect.objectContaining({ type: "data", state: "parsed" }),
|
||||
]),
|
||||
);
|
||||
expect(result.bags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
bagType: "shrouded-private-key",
|
||||
friendlyName: "Local test identity",
|
||||
state: "password-required",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("verifies MacData and decrypts PBES2 SafeContents/key bags", async () => {
|
||||
const result = await inspectPkcs12(pfxFixture(), {
|
||||
password: MODERN_PFX_PASSWORD,
|
||||
});
|
||||
expect(result.mac.status).toBe("verified");
|
||||
expect(result.contents.every((content) => content.state === "parsed")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.bags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
bagType: "certificate",
|
||||
friendlyName: "Local test identity",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
bagType: "shrouded-private-key",
|
||||
state: "inspected",
|
||||
key: expect.objectContaining({ algorithm: "RSA" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes explicit empty passwords from password omission", async () => {
|
||||
const bytes = pfxFixture(EMPTY_PASSWORD_PFX_BASE64);
|
||||
expect((await inspectPkcs12(bytes)).mac.status).toBe("password-required");
|
||||
const inspected = await inspectPkcs12(bytes, { password: "" });
|
||||
expect(inspected.mac.status).toBe("verified");
|
||||
expect(
|
||||
inspected.bags.some(
|
||||
(bag) =>
|
||||
bag.bagType === "shrouded-private-key" &&
|
||||
bag.state === "inspected" &&
|
||||
bag.key?.algorithm === "RSA",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed for wrong passwords, damaged nesting and legacy PBE", async () => {
|
||||
await expect(
|
||||
inspectPkcs12(pfxFixture(), { password: "wrong-password" }),
|
||||
).rejects.toThrow(/password is incorrect|MacData\/authSafe/u);
|
||||
|
||||
const truncated = pfxFixture().slice(0, -1);
|
||||
await expect(inspectPkcs12(truncated)).rejects.toThrow(
|
||||
/malformed DER|trailing bytes/u,
|
||||
);
|
||||
|
||||
await expect(
|
||||
inspectPkcs12(pfxFixture(LEGACY_PFX_BASE64), {
|
||||
password: MODERN_PFX_PASSWORD,
|
||||
}),
|
||||
).rejects.toThrow(/legacy PKCS #12 PBE/u);
|
||||
|
||||
await expect(
|
||||
inspectPkcs12(pfxFixture(), { password: "x".repeat(4_097) }),
|
||||
).rejects.toThrow(/at most 4,096 UTF-16 units/u);
|
||||
});
|
||||
|
||||
it("integrates bag and certificate inventory without reporting secrets", async () => {
|
||||
const result = await inspectCryptoInput(pfxFixture(), new Date(), {
|
||||
password: MODERN_PFX_PASSWORD,
|
||||
});
|
||||
expect(result.items[0]).toMatchObject({
|
||||
type: "PKCS #12/PFX",
|
||||
facts: expect.objectContaining({
|
||||
MacData: expect.stringMatching(/Verified/u),
|
||||
}),
|
||||
});
|
||||
expect(result.items.some((item) => item.certificate)).toBe(true);
|
||||
expect(
|
||||
result.items.some((item) =>
|
||||
item.facts["Private-key algorithm"]?.includes("RSA"),
|
||||
),
|
||||
).toBe(true);
|
||||
const reportSurface = JSON.stringify(
|
||||
result.items.map(({ id, type, title, facts, findings }) => ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
facts,
|
||||
findings,
|
||||
})),
|
||||
);
|
||||
expect(reportSurface).not.toContain(MODERN_PFX_PASSWORD);
|
||||
expect(reportSurface).not.toContain("PRIVATE KEY-----");
|
||||
});
|
||||
});
|
||||
Vendored
+107
@@ -0,0 +1,107 @@
|
||||
import { Integer, Null, ObjectIdentifier, OctetString, Sequence } from "asn1js";
|
||||
|
||||
function buffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
export function encryptedPkcs8Pem(bytes: Uint8Array): string {
|
||||
const binary = Array.from(bytes, (value) => String.fromCharCode(value)).join(
|
||||
"",
|
||||
);
|
||||
const body =
|
||||
btoa(binary)
|
||||
.match(/.{1,64}/gu)
|
||||
?.join("\n") ?? "";
|
||||
return `-----BEGIN ENCRYPTED PRIVATE KEY-----\n${body}\n-----END ENCRYPTED PRIVATE KEY-----`;
|
||||
}
|
||||
|
||||
export async function encryptedPkcs8(password: string): Promise<Uint8Array> {
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
const privateKey = new Uint8Array(
|
||||
await crypto.subtle.exportKey("pkcs8", pair.privateKey),
|
||||
);
|
||||
try {
|
||||
return await encryptedPbes2Payload(password, privateKey);
|
||||
} finally {
|
||||
privateKey.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptedPbes2Payload(
|
||||
password: string,
|
||||
plaintext: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const salt = Uint8Array.from({ length: 16 }, (_, index) => index + 1);
|
||||
const iv = Uint8Array.from({ length: 16 }, (_, index) => 32 - index);
|
||||
const material = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt: buffer(salt),
|
||||
iterations: 12_000,
|
||||
},
|
||||
material,
|
||||
{ name: "AES-CBC", length: 256 },
|
||||
false,
|
||||
["encrypt"],
|
||||
);
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: "AES-CBC", iv: buffer(iv) },
|
||||
key,
|
||||
buffer(plaintext),
|
||||
),
|
||||
);
|
||||
const encoded = new Sequence({
|
||||
value: [
|
||||
new Sequence({
|
||||
value: [
|
||||
new ObjectIdentifier({ value: "1.2.840.113549.1.5.13" }),
|
||||
new Sequence({
|
||||
value: [
|
||||
new Sequence({
|
||||
value: [
|
||||
new ObjectIdentifier({ value: "1.2.840.113549.1.5.12" }),
|
||||
new Sequence({
|
||||
value: [
|
||||
new OctetString({ valueHex: buffer(salt) }),
|
||||
new Integer({ value: 12_000 }),
|
||||
new Sequence({
|
||||
value: [
|
||||
new ObjectIdentifier({ value: "1.2.840.113549.2.9" }),
|
||||
new Null(),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Sequence({
|
||||
value: [
|
||||
new ObjectIdentifier({ value: "2.16.840.1.101.3.4.1.42" }),
|
||||
new OctetString({ valueHex: buffer(iv) }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new OctetString({ valueHex: buffer(ciphertext) }),
|
||||
],
|
||||
});
|
||||
return new Uint8Array(encoded.toBER(false));
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
// Synthetic test-only identity generated with OpenSSL 3.5.4. The private key is
|
||||
// protected by the published fixture password and must never be used elsewhere.
|
||||
export const MODERN_PFX_PASSWORD = "local-secret";
|
||||
export const MODERN_PFX_BASE64 =
|
||||
"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";
|
||||
|
||||
export const LEGACY_PFX_BASE64 =
|
||||
"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";
|
||||
|
||||
export const EMPTY_PASSWORD_PFX_BASE64 =
|
||||
"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";
|
||||
|
||||
export function pfxFixture(base64 = MODERN_PFX_BASE64): Uint8Array {
|
||||
return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
||||
}
|
||||
Reference in New Issue
Block a user