Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# __benchmarks__/blinded_vs_unblinded_2048
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Mitata-driven benchmark measuring the wall-clock cost of RSA blinding
|
||||
relative to the bare CRT private operation on a 2048-bit key. Validates
|
||||
the < 5% blinding-overhead claim from the change proposal so any future
|
||||
regression in the blinding path is visible at PR-review time.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates a fresh 2048-bit key per run (no committed key bytes)
|
||||
- Measures both `rsaPrivateOperationCrt(m, key)` and the blinded path
|
||||
(`blindMessage` → `rsaPrivateOperationCrt` → `unblindResult`)
|
||||
- Single-process wall-clock comparison; not statistical to mitata's
|
||||
full rigor — see the bench output for confidence intervals
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
bun src/__benchmarks__/blinded_vs_unblinded_2048.ts
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- NOT wired into CI — purely a developer benchmark for tracking
|
||||
regressions in the blinding path. The 5% overhead headroom in the
|
||||
spec is the gate; if a refactor pushes overhead past that, the
|
||||
benchmark surfaces the regression here before anything ships.
|
||||
- Uses `defaultRng()` for the blinding factor so the timing reflects
|
||||
production behaviour (CSPRNG-backed sampling).
|
||||
@@ -0,0 +1,42 @@
|
||||
# __fixtures__/rsa-2048-key
|
||||
|
||||
## Purpose
|
||||
|
||||
Real 2048-bit RSA keypair generated once at module-load time and exposed
|
||||
as canonical `RsaPublicKey` / `RsaPrivateKey` shapes for Stage 2+ tests
|
||||
(sign / verify / encrypt / decrypt). The 16-bit synthetic
|
||||
`TINY_PRIVATE_KEY` fixture in `test-keys.ts` cannot host a SHA-256 EM
|
||||
(32 bytes is wider than its 2-byte modulus), so signature tests need a
|
||||
real-sized key.
|
||||
|
||||
## Features
|
||||
|
||||
- Real 2048-bit keypair via `node:crypto.generateKeyPairSync`
|
||||
- Decoded into canonical bigint form via `finalisePrivateKey` /
|
||||
`finalisePublicKey`
|
||||
- Stable within a single test run (deterministic across all tests in the
|
||||
same process); not deterministic across processes
|
||||
- Re-run cost amortised across the entire Stage 2+ suite (≈ 50 ms once)
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import {
|
||||
RSA_2048_PRIVATE_KEY,
|
||||
RSA_2048_PUBLIC_KEY,
|
||||
} from "../__fixtures__/rsa-2048-key.js";
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `RSA_2048_PUBLIC_KEY: RsaPublicKey`
|
||||
|
||||
### `RSA_2048_PRIVATE_KEY: RsaPrivateKey`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Tests that need byte-exact reference vectors must use one of the RFC
|
||||
8017 Appendix C / Wycheproof / CAVP fixture files (added incrementally
|
||||
).
|
||||
- The fixture is not committed bytes-on-disk; regeneration on every
|
||||
test process amortises down to noise once the rest of the suite runs.
|
||||
@@ -0,0 +1,40 @@
|
||||
# __fixtures__/test-keys
|
||||
|
||||
## Purpose
|
||||
|
||||
Tiny synthetic RSA keypair for unit tests that need an `RsaPrivateKey` /
|
||||
`RsaPublicKey` shape but don't need to run a real cryptographic
|
||||
operation. Modulus is 16 bits (`n = 251 * 241 = 60 491`) so the
|
||||
modulus-length policy gate's sub-1024-bit reject path can fire without
|
||||
colliding with real signing operations.
|
||||
|
||||
## Features
|
||||
|
||||
- Hand-computed CRT parameters (`d`, `p`, `q`, `dp`, `dq`, `qInv`)
|
||||
- Round-trip-safe through every parser/serialiser
|
||||
- 16-bit modulus exercises the `MODULUS_TOO_SHORT` rejection path
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import {
|
||||
TINY_PRIVATE_KEY,
|
||||
TINY_PUBLIC_KEY,
|
||||
} from "../__fixtures__/test-keys.js";
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TINY_PUBLIC_KEY: RsaPublicKey`
|
||||
|
||||
### `TINY_PRIVATE_KEY: RsaPrivateKey`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- NOT a real cryptographic key. Do not use for any signature /
|
||||
encryption operation that needs to interoperate with real RSA keys —
|
||||
the modulus is far below the FIPS / industry minimum.
|
||||
- The 16-bit modulus is chosen specifically so it cannot host a SHA-256
|
||||
encoded message (the EM is 32 bytes, the modulus is 2 bytes); this
|
||||
triggers the canonical `MODULUS_TOO_SHORT` failure path without
|
||||
needing to construct adversarial inputs.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `asn1/reader`
|
||||
|
||||
## Purpose
|
||||
|
||||
Strict-DER reader scoped to the RSA key shapes defined in RFC 8017 Appendix A
|
||||
(PKCS#1), RFC 5280 §4.1 + Appendix A (`SubjectPublicKeyInfo`), and RFC 5208 / RFC 5958
|
||||
(PKCS#8 `PrivateKeyInfo` / `OneAsymmetricKey`). The reader rejects every BER
|
||||
construction that DER forbids — indefinite length, non-minimal length, non-minimal
|
||||
integer encoding, non-zero unused-bits in `BIT STRING`, trailing data — by raising
|
||||
`CryptoRsaError(CRYPTO_RSA.INVALID_KEY)`.
|
||||
|
||||
The implementation is intentionally minimal. It implements exactly the subset needed
|
||||
to decode RSA keys; it is not a general-purpose ASN.1 library.
|
||||
|
||||
## Features
|
||||
|
||||
- Stateful cursor with `position`, `remaining`, `hasMore`, `peekTag`, `assertEnd`.
|
||||
- `readInteger`, `readSequence`, `readObjectIdentifier`, `readBitString`,
|
||||
`readOctetString`, `readNull`, `readContextSpecific(tagNumber)`.
|
||||
- Strict DER length parsing — non-minimal long-form lengths are rejected.
|
||||
- Strict DER integer parsing — leading-`0x00` only when MSB requires it; negative
|
||||
encodings rejected (RSA INTEGERs are non-negative).
|
||||
- `BIT STRING` reader copies the body so callers cannot mutate the input buffer.
|
||||
- Sub-readers returned by `readSequence` / `readContextSpecific` are scoped to the
|
||||
child body for ergonomic recursive descent.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DerReader } from "@suaveplan/crypto-rsa/asn1";
|
||||
|
||||
const reader = new DerReader(pkcs1PublicKeyDer);
|
||||
const seq = reader.readSequence();
|
||||
const modulus = seq.readInteger();
|
||||
const exponent = seq.readInteger();
|
||||
seq.assertEnd();
|
||||
reader.assertEnd();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DerReader, TAG_INTEGER } from "@suaveplan/crypto-rsa/asn1";
|
||||
|
||||
const reader = new DerReader(pkcs8Bytes);
|
||||
const outer = reader.readSequence();
|
||||
// version is OPTIONAL DEFAULT 0 in some PKCS#8 dialects
|
||||
const version =
|
||||
outer.peekTag() === TAG_INTEGER ? outer.readInteger() : 0n;
|
||||
const algSeq = outer.readSequence();
|
||||
const oid = algSeq.readObjectIdentifier();
|
||||
algSeq.readNull();
|
||||
const privateKeyOctet = outer.readOctetString();
|
||||
const inner = new DerReader(privateKeyOctet).readSequence();
|
||||
// … recurse into RSAPrivateKey body.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DerReader { constructor(bytes: Uint8Array, offset?: number) }`
|
||||
- `position: number` — current cursor offset (read-only).
|
||||
- `remaining: number` — bytes remaining in the buffer.
|
||||
- `hasMore(): boolean` — true if the cursor has not reached the end.
|
||||
- `peekTag(): number | undefined` — tag byte at the cursor, or `undefined` at EOF.
|
||||
- `readInteger(): bigint` — consumes a DER `INTEGER`; throws `INVALID_KEY` on bad encoding.
|
||||
- `readSequence(): DerReader` — body sub-reader for the next `SEQUENCE`.
|
||||
- `readObjectIdentifier(): string` — dotted-decimal OID (e.g. `"1.2.840.113549.1.1.1"`).
|
||||
- `readBitString(): Uint8Array` — payload bytes; throws when unused-bits ≠ 0.
|
||||
- `readOctetString(): Uint8Array` — payload bytes (defensive copy).
|
||||
- `readNull(): void` — consumes a DER `NULL`; throws on non-empty body.
|
||||
- `readContextSpecific(tagNumber: 0..30): DerReader` — body sub-reader for `[n]`.
|
||||
- `assertEnd(): void` — throws `INVALID_KEY` if any unread bytes remain.
|
||||
|
||||
All failure modes throw `CryptoRsaError` with `code === CRYPTO_RSA.INVALID_KEY` and
|
||||
a structured `context` payload (e.g. `{ expected, actual }`, `{ unusedBits }`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The reader rejects BER-only constructs even when they are syntactically unambiguous
|
||||
because RSA wire formats are defined in DER and any tooling that emits BER is
|
||||
non-conformant. This matters for security: silently accepting non-canonical encodings
|
||||
is a known fingerprinting and signature-malleability vector.
|
||||
|
||||
Sub-readers returned by `readSequence` are constructed over `Uint8Array.subarray`
|
||||
(a view), not a copy, so the parser remains zero-allocation on the hot path. The
|
||||
single defensive copy is in `readBitString` because callers receive the payload
|
||||
directly and the SPKI bit-string carries the inner PKCS#1 body which is then
|
||||
re-parsed.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `asn1/writer`
|
||||
|
||||
## Purpose
|
||||
|
||||
Strict-DER encoder mirroring `asn1/reader`. Encodes the same minimal subset of
|
||||
ASN.1 needed to serialise RSA keys in PKCS#1, SPKI, and PKCS#8 wire formats with
|
||||
byte-identical canonical form, so `parse(serialise(key))` round-trips bit-for-bit.
|
||||
|
||||
The writer never emits BER constructions even when the underlying X.690 grammar
|
||||
permits them; minimal definite-length encoding, minimal integer encoding, and
|
||||
zero-unused-bit `BIT STRING` are the only forms produced.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeLength` — minimal definite-length form per X.690 §8.1.3.
|
||||
- `encodeInteger(bigint)` — non-negative `bigint` with leading-`0x00` insertion
|
||||
when the high bit is set (so positive interpretation is unambiguous).
|
||||
- `encodeSequence(...children)` — variadic concatenation of pre-encoded child TLVs.
|
||||
- `encodeObjectIdentifier(oid)` — dotted-decimal OID with base-128 sub-identifier
|
||||
encoding and validation of the first two arcs.
|
||||
- `encodeBitString` — primitive `BIT STRING` carrying a whole-byte payload.
|
||||
- `encodeOctetString`, `encodeNull`, `encodeContextSpecific(tagNumber, body)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
encodeInteger,
|
||||
encodeSequence,
|
||||
} from "@suaveplan/crypto-rsa/asn1";
|
||||
|
||||
const pkcs1Public = encodeSequence(
|
||||
encodeInteger(modulus),
|
||||
encodeInteger(exponent)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
encodeBitString,
|
||||
encodeNull,
|
||||
encodeObjectIdentifier,
|
||||
encodeSequence,
|
||||
} from "@suaveplan/crypto-rsa/asn1";
|
||||
|
||||
const RSA_ENCRYPTION_OID = "1.2.840.113549.1.1.1";
|
||||
|
||||
const algorithmIdentifier = encodeSequence(
|
||||
encodeObjectIdentifier(RSA_ENCRYPTION_OID),
|
||||
encodeNull()
|
||||
);
|
||||
|
||||
const subjectPublicKeyInfo = encodeSequence(
|
||||
algorithmIdentifier,
|
||||
encodeBitString(pkcs1PublicKeyDer)
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeLength(length: number): Uint8Array` — DER length octet(s).
|
||||
- `encodeInteger(value: bigint): Uint8Array` — full INTEGER TLV.
|
||||
- `encodeSequence(...children: Uint8Array[]): Uint8Array` — full SEQUENCE TLV.
|
||||
- `encodeObjectIdentifier(oid: string): Uint8Array` — full OID TLV.
|
||||
- `encodeBitString(payload: Uint8Array): Uint8Array` — full BIT STRING TLV.
|
||||
- `encodeOctetString(payload: Uint8Array): Uint8Array` — full OCTET STRING TLV.
|
||||
- `encodeNull(): Uint8Array` — `0x05 0x00`.
|
||||
- `encodeContextSpecific(tagNumber: 0..30, body: Uint8Array): Uint8Array` — `[n]` wrapping.
|
||||
|
||||
All inputs are validated. Negative integers, malformed OID strings, lengths that
|
||||
overflow, and out-of-range context tag numbers throw
|
||||
`CryptoRsaError(CRYPTO_RSA.INVALID_KEY)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The writer is fully synchronous and allocates one `Uint8Array` per TLV node plus
|
||||
one final concatenation through `concatBytes` from `@suaveplan/crypto-utils`.
|
||||
For the small key-shape graph (≤ 8 INTEGERs for an RSA private key) this is well
|
||||
below any allocation budget.
|
||||
|
||||
OID encoding implements the standard base-128 sub-identifier rule: the high bit of
|
||||
every byte except the last is set, so each sub-identifier reconstructs as a
|
||||
big-endian variable-length integer. The first two arcs are folded into a single
|
||||
byte (`arc1 * 40 + arc2`) per X.690 §8.19; arc1 is constrained to `{0, 1, 2}`,
|
||||
and arc2 to `< 40` when arc1 is 0 or 1. Both constraints are enforced.
|
||||
@@ -0,0 +1,133 @@
|
||||
# `blinding`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
RSA blinding (RFC 8017 §5.1.2 informational note) is the standard
|
||||
defence against remote-timing attacks on RSA private operations
|
||||
(decrypt and sign). Without it, the wall-clock time of a
|
||||
`c^d mod n` computation correlates with the bits of the secret
|
||||
exponent `d` and with the value of `c` — well-published attacks
|
||||
(Brumley/Boneh 2003, Aciicmez et al. 2007) recovered RSA private
|
||||
keys from a single network-attached server in minutes.
|
||||
|
||||
This module exposes the two halves of the defence — `blindMessage`
|
||||
that randomises the input and `unblindResult` that recovers the
|
||||
true output — as standalone helpers so they can be wired into
|
||||
every private-operation surface in the package (`rsaDecryptPkcs1v15`
|
||||
and `rsaDecryptOaep` here , `rsaSignPkcs1v15` and
|
||||
`rsaSignPss` once that wave lands).
|
||||
|
||||
## Features
|
||||
|
||||
- Uniform rejection sampling of the blinding factor `r` over
|
||||
`Z*_n` (drop any candidate `≥ n`, drop any candidate sharing a
|
||||
prime factor with `n` so `r^-1 mod n` always exists).
|
||||
- Exhaustion guard — `MAX_REJECTIONS = 1024` candidates before
|
||||
surfacing `KEYGEN_FAILED`. For a well-formed RSA modulus this
|
||||
never fires; the cap exists only to keep a malformed key (e.g.
|
||||
`n` with very small factors) from looping forever.
|
||||
- Pluggable `Rng` source — works with both `crypto.getRandomValues`
|
||||
CSPRNGs (use the optional `nextBytes` fast path) and seeded
|
||||
deterministic test RNGs (fall back to `nextUint32` extraction).
|
||||
- Helper `nextBytesViaRng` exported for reuse by keygen and other
|
||||
modules that need RNG byte extraction.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { blindMessage, unblindResult } from "@suaveplan/crypto-rsa/blinding";
|
||||
import {
|
||||
rsaPrivateOperationCrt,
|
||||
type RsaPrivateKey,
|
||||
type RsaPublicKey,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
import { defaultByteSource } from "@suaveplan/crypto-utils";
|
||||
|
||||
declare const m: bigint;
|
||||
declare const publicKey: RsaPublicKey;
|
||||
declare const privateKey: RsaPrivateKey;
|
||||
|
||||
const csprng = {
|
||||
nextUint32: () =>
|
||||
new DataView(defaultByteSource(4).buffer).getUint32(0),
|
||||
nextFloat: () => Math.random(),
|
||||
nextBytes: (n: number) => defaultByteSource(n),
|
||||
};
|
||||
|
||||
const { mPrime, rInverse } = blindMessage(m, publicKey, csprng);
|
||||
const sBlinded = rsaPrivateOperationCrt(mPrime, privateKey);
|
||||
const s = unblindResult(sBlinded, rInverse, publicKey.n);
|
||||
// s === m^d mod n, but the modPow ran on the random m' instead of m
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A deterministic seeded `Rng` for tests — `blindMessage` will use
|
||||
`nextUint32` extraction when `nextBytes` is omitted, so the same
|
||||
seed produces the same `(mPrime, rInverse)` every run:
|
||||
|
||||
```ts
|
||||
import { blindMessage, unblindResult } from "@suaveplan/crypto-rsa/blinding";
|
||||
|
||||
let state = 0x12345678 >>> 0;
|
||||
const seededRng = {
|
||||
nextUint32: () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state;
|
||||
},
|
||||
nextFloat: () => state / 0xffffffff,
|
||||
};
|
||||
|
||||
const { mPrime, rInverse } = blindMessage(0x42n, publicKey, seededRng);
|
||||
// ... feed into rsaPrivateOperationCrt, then unblindResult(...)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `blindMessage(m, key, rng): BlindResult`
|
||||
|
||||
- `m: bigint` — message representative in `[0, n)`. Throws
|
||||
`CRYPTO_RSA.INVALID_KEY` if out of range.
|
||||
- `key: RsaPublicKey` — public key carrying `n` and `e`.
|
||||
- `rng: Rng` — randomness source (canonical
|
||||
`@suaveplan/types` interface).
|
||||
- Returns `{ mPrime, rInverse }` — the blinded message and the
|
||||
modular inverse of `r` to be passed back into `unblindResult`.
|
||||
- Throws `CRYPTO_RSA.KEYGEN_FAILED` after `MAX_REJECTIONS` failed
|
||||
rejection-sampling attempts. Indicates a pathological modulus,
|
||||
not a normal failure mode.
|
||||
|
||||
### `unblindResult(s, rInverse, n): bigint`
|
||||
|
||||
Pure modular multiplication — `(s * rInverse) % n`. Constant-time
|
||||
to the extent JavaScript's bigint arithmetic allows.
|
||||
|
||||
### `nextBytesViaRng(rng, n): Uint8Array`
|
||||
|
||||
Helper that extracts `n` bytes from an arbitrary `Rng`,
|
||||
preferring the optional `nextBytes` fast path when present.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- We use noble's `invert` for `r^-1 mod n` rather than rolling our
|
||||
own extended-Euclidean — noble already implements the Bezout
|
||||
algorithm with bigint-correct sign handling.
|
||||
- `gcd(r, n)` rejection guards against the (vanishingly rare)
|
||||
case where the random `r` shares a prime factor with `n`.
|
||||
Without this, `invert` would throw and we'd lose the candidate
|
||||
anyway, but the gcd check is cheaper than catching the throw
|
||||
in a tight loop.
|
||||
- The blinding factor `r` is sampled byte-wise with a top-bit
|
||||
mask matching `n`'s bit length so the rejection rate stays
|
||||
below 50% per draw.
|
||||
- This module is constant-time only to the extent JavaScript's
|
||||
bigint arithmetic allows. Bigint multiplication and modular
|
||||
reduction over 2048+ bit values are NOT constant-time in V8 or
|
||||
JavaScriptCore. Blinding still meaningfully degrades the
|
||||
signal-to-noise ratio of timing attacks because the secret no
|
||||
longer correlates with the input — but for FIPS-grade
|
||||
side-channel resistance, defer to a hardware engine
|
||||
(`crypto-browser` WebCrypto, `crypto-sodium` libsodium).
|
||||
@@ -0,0 +1,86 @@
|
||||
# `decrypt/oaep`
|
||||
|
||||
## Purpose
|
||||
|
||||
RSAES-OAEP decryption per RFC 8017 §7.1.2 — counterpart to
|
||||
`encrypt/oaep`. Modern default for RSA public-key encryption.
|
||||
|
||||
The defining concern of this module is the **Manger defence**:
|
||||
every padding-format failure surfaces the SAME
|
||||
`CRYPTO_RSA.DECRYPT_FAILED` error so a remote attacker cannot
|
||||
mount the chosen-ciphertext attack Manger described against
|
||||
naive OAEP implementations in 2001.
|
||||
|
||||
## Features
|
||||
|
||||
- Combined error path — leading-byte mismatch, `lHash` mismatch
|
||||
(wrong label), missing `0x01` separator, non-zero byte in
|
||||
`PS` before separator, integer ≥ `n`, ciphertext length ≠ `k`,
|
||||
modulus too small for the chosen hash: every failure raises
|
||||
the single `DECRYPT_FAILED` code.
|
||||
- Constant-time `lHash` comparison via `constantTimeEqual` from
|
||||
`@suaveplan/crypto-utils` (the comparison we DO have a
|
||||
guarantee about — the rest of the unwrap is best-effort).
|
||||
- RSA blinding wired in by default; `{ blinding: false }` opt-out
|
||||
emits the `crypto_rsa.blinding_disabled_total{operation="decrypt"}`
|
||||
counter.
|
||||
- CRT-accelerated private operation.
|
||||
- `crypto_rsa.decrypt_duration_ms{scheme="oaep",hash}` histogram.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaDecryptOaep } from "@suaveplan/crypto-rsa/decrypt/oaep";
|
||||
|
||||
const plaintext = rsaDecryptOaep(privateKey, ciphertext, {
|
||||
hash: "SHA-256",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
OAEP-SHA-384 with explicit label — the matching encrypt MUST
|
||||
have used the same hash, MGF hash, AND label or this throws
|
||||
`DECRYPT_FAILED`:
|
||||
|
||||
```ts
|
||||
import { rsaDecryptOaep } from "@suaveplan/crypto-rsa/decrypt/oaep";
|
||||
|
||||
const plaintext = rsaDecryptOaep(privateKey, ciphertext, {
|
||||
hash: "SHA-384",
|
||||
mgfHash: "SHA-384",
|
||||
label: new TextEncoder().encode("session-2026-05-11"),
|
||||
allowLegacy: false,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaDecryptOaep(key, ciphertext, options?): Uint8Array`
|
||||
|
||||
- `key: RsaPrivateKey`
|
||||
- `ciphertext: Uint8Array` — must equal `k = ceil(bitLength / 8)`.
|
||||
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`.
|
||||
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `hash`.
|
||||
- `options.label?: Uint8Array` — defaults to empty.
|
||||
- `options.rng?: Rng` — randomness source for blinding.
|
||||
- `options.blinding?: boolean` — defaults to `true`.
|
||||
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
|
||||
- Returns the recovered plaintext.
|
||||
- Throws `CRYPTO_RSA.DECRYPT_FAILED` on every padding failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The combined error path is enforced by routing every failure
|
||||
through the private `decryptFailed()` helper — a single
|
||||
callsite makes future drift detectable in code review.
|
||||
- `constantTimeEqual` from `@suaveplan/crypto-utils` covers the
|
||||
`lHash` byte comparison; the surrounding unwrap loop is NOT
|
||||
constant-time because JavaScript bigint and `Uint8Array.slice`
|
||||
are not. This is the same practical limitation as PKCS1-v1_5
|
||||
decryption — for FIPS-grade side-channel resistance, defer to
|
||||
`crypto-browser` (WebCrypto) or `crypto-sodium`.
|
||||
- The OAEP separator scan walks `db` from index `hLen` onward
|
||||
and rejects on either a non-zero, non-0x01 byte before the
|
||||
separator or no separator at all. This implements RFC 8017
|
||||
§7.1.2 step 3.g exactly.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `decrypt/pkcs1v15`
|
||||
|
||||
## Purpose
|
||||
|
||||
RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2. Counterpart to
|
||||
`encrypt/pkcs1v15`. Provides legacy interop for protocols that
|
||||
predate OAEP (TLS 1.0–1.2 RSA key transport, JWE `RSA1_5` legacy
|
||||
mode). Modern callers MUST prefer `decrypt/oaep`.
|
||||
|
||||
The defining concern of this module is the **Bleichenbacher
|
||||
defence**: every padding-format failure surfaces the SAME
|
||||
`CRYPTO_RSA.DECRYPT_FAILED` error so a remote attacker observing
|
||||
the typed-error code (or the OTel error counter) cannot mount a
|
||||
million-message chosen-ciphertext attack.
|
||||
|
||||
## Features
|
||||
|
||||
- Combined error path — wrong leading byte, wrong block-type
|
||||
marker, missing `0x00` separator, `PS` shorter than 8 bytes,
|
||||
ciphertext length ≠ `k`, post-decrypt integer ≥ `n`: every
|
||||
failure throws the single `DECRYPT_FAILED` code.
|
||||
- RSA blinding wired in by default — `{ blinding: false }` opt-out
|
||||
emits the `crypto_rsa.blinding_disabled_total{operation="decrypt"}`
|
||||
counter so production dashboards can flag any caller that
|
||||
disables the timing-side-channel defence.
|
||||
- CRT-accelerated private operation (`rsaPrivateOperationCrt`).
|
||||
- Modulus-length policy gate runs first.
|
||||
- `crypto_rsa.decrypt_duration_ms{scheme="pkcs1v15"}` histogram.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
|
||||
import type { RsaPrivateKey } from "@suaveplan/crypto-rsa";
|
||||
|
||||
declare const privateKey: RsaPrivateKey;
|
||||
declare const ciphertext: Uint8Array;
|
||||
const plaintext = rsaDecryptPkcs1v15(privateKey, ciphertext, {
|
||||
allowLegacy: false,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Disable blinding (NOT recommended outside benchmarks) — the
|
||||
counter increment surfaces the choice in production telemetry:
|
||||
|
||||
```ts
|
||||
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
|
||||
|
||||
const plaintext = rsaDecryptPkcs1v15(privateKey, ciphertext, {
|
||||
blinding: false, // emits crypto_rsa.blinding_disabled_total{operation="decrypt"}
|
||||
allowLegacy: true,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaDecryptPkcs1v15(key, ciphertext, options?): Uint8Array`
|
||||
|
||||
- `key: RsaPrivateKey` — modulus must satisfy the policy gate.
|
||||
- `ciphertext: Uint8Array` — must be exactly `k` bytes; any
|
||||
other length throws `DECRYPT_FAILED`.
|
||||
- `options.rng?: Rng` — randomness source for blinding.
|
||||
- `options.blinding?: boolean` — defaults to `true`. Set to
|
||||
`false` only for benchmark comparisons.
|
||||
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
|
||||
- Returns the recovered plaintext (`em.length - separator - 1`
|
||||
bytes).
|
||||
- Throws `CRYPTO_RSA.DECRYPT_FAILED` on every padding failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The combined error path is enforced by routing every failure
|
||||
through the private `decryptFailed()` helper — a single
|
||||
callsite for `new CryptoRsaError(DECRYPT_FAILED, ...)` makes
|
||||
it impossible for a future edit to accidentally introduce a
|
||||
differentiated error.
|
||||
- True constant-time decryption is not achievable in JavaScript
|
||||
without WebCrypto (V8/JSC bigint ops are not constant-time).
|
||||
We mitigate with blinding and with eager-throw bucketing all
|
||||
padding failures under one error class. For FIPS-grade
|
||||
side-channel resistance, route through `crypto-browser`
|
||||
(WebCrypto) or `crypto-sodium`.
|
||||
- The `c >= key.n` check is RFC 8017 §5.1.2's "ciphertext
|
||||
representative out of range" condition — also bucketed under
|
||||
`DECRYPT_FAILED` so callers cannot distinguish "padding bad"
|
||||
from "ciphertext outside `Z*_n`".
|
||||
@@ -0,0 +1,84 @@
|
||||
# emsa-pkcs1-v1\_5
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements EMSA-PKCS1-v1\_5-ENCODE from RFC 8017 §9.2 — the encoding
|
||||
method underlying RSASSA-PKCS1-v1\_5 signatures. This module also pins
|
||||
the four DigestInfo prefix byte sequences from §9.2 note 1 as named
|
||||
constants so consumers and tests can audit the bytes against the RFC.
|
||||
|
||||
## Features
|
||||
|
||||
- Hard-coded `DIGEST_INFO_PREFIX_*` constants for SHA-1, SHA-256, SHA-384, SHA-512
|
||||
- `digestInfoPrefix(hash)` lookup — throws `CRYPTO_RSA.UNSUPPORTED_HASH` on unknown algorithm
|
||||
- `emsaPkcs1v15Encode(message, emLen, hash)` — RFC 8017 §9.2 conformant encoder
|
||||
- Detects modulus-too-short conditions (`emLen < tLen + 11`) and throws `CRYPTO_RSA.INVALID_KEY`
|
||||
- Returns a fresh `Uint8Array` of length exactly `emLen`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { emsaPkcs1v15Encode } from "@suaveplan/crypto-rsa/encoding/emsa-pkcs1-v1_5";
|
||||
|
||||
const message = new TextEncoder().encode("hello");
|
||||
const em = emsaPkcs1v15Encode(message, 256, "SHA-256");
|
||||
console.log(em.length); // 256
|
||||
console.log(em[0], em[1]); // 0x00, 0x01
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The encoded message is the input to the RSA private-key operation in
|
||||
`rsaSignPkcs1v15`. To verify a signature, the same encoder is run on the
|
||||
candidate message and the result is constant-time compared against the
|
||||
public-key recovery of the signature.
|
||||
|
||||
```ts
|
||||
import { emsaPkcs1v15Encode, digestInfoPrefix } from "@suaveplan/crypto-rsa/encoding/emsa-pkcs1-v1_5";
|
||||
import { toHex } from "@suaveplan/crypto-utils";
|
||||
|
||||
const prefix = digestInfoPrefix("SHA-256");
|
||||
console.log(toHex(prefix as Uint8Array));
|
||||
// 3031300d060960864801650304020105000420
|
||||
|
||||
const em1 = emsaPkcs1v15Encode(new Uint8Array([1, 2, 3]), 256, "SHA-256");
|
||||
const em2 = emsaPkcs1v15Encode(new Uint8Array([1, 2, 3]), 256, "SHA-256");
|
||||
// em1 and em2 are byte-identical (encoding is deterministic)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `emsaPkcs1v15Encode(message, emLen, hash): Uint8Array`
|
||||
|
||||
- `message: Uint8Array` — the bytes that will be digested
|
||||
- `emLen: number` — the target encoded-message length, equal to `byteLength(n)`
|
||||
- `hash: RsaHashAlgorithm` — `"SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"`
|
||||
- **returns** `Uint8Array` of length exactly `emLen`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when `emLen < tLen + 11` (modulus too short for the chosen hash)
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown algorithm
|
||||
|
||||
### `digestInfoPrefix(hash): Readonly<Uint8Array>`
|
||||
|
||||
Returns the immutable RFC 8017 §9.2 note 1 DigestInfo prefix for `hash`.
|
||||
|
||||
### `DIGEST_INFO_PREFIX_SHA1` / `_SHA256` / `_SHA384` / `_SHA512`
|
||||
|
||||
Read-only `Uint8Array` constants holding the byte-exact DigestInfo
|
||||
prefix sequences from RFC 8017 §9.2 note 1.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The encoded message structure is `0x00 || 0x01 || PS || 0x00 || T`,
|
||||
where `PS` is at least 8 bytes of `0xff` (per RFC 8017 §9.2 step 3).
|
||||
The `tLen + 11` floor encodes the 2-byte header (`0x00 || 0x01`), the
|
||||
1-byte separator (`0x00`), and the 8-byte minimum for `PS`.
|
||||
- The DigestInfo prefix bytes are pinned as literal byte arrays — not
|
||||
ASN.1 encoder calls — so a single misbyte in the RFC table would be
|
||||
caught by the bytewise prefix tests against the RFC reference.
|
||||
- The `emLen` parameter is supplied by the caller (= `byteLength(n)` for
|
||||
RSASSA-PKCS1-v1\_5). This module does not compute `emLen` from a key;
|
||||
that responsibility belongs to the higher-level signature scheme.
|
||||
- Encoding is deterministic — the same `(message, emLen, hash)` triple
|
||||
always produces the same `EM` bytes.
|
||||
- Hash dispatch goes through `digestWith` from `mgf1.ts`, so SHA-2
|
||||
flows through `@suaveplan/crypto-hash` and SHA-1 through Noble.
|
||||
@@ -0,0 +1,78 @@
|
||||
# emsa-pss-encode
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the EMSA-PSS encoding operation defined in RFC 8017 §9.1.1.
|
||||
Given a message digest, an encoded-message bit length, a salt, and the
|
||||
two hash choices (message hash and MGF1 hash), this function produces
|
||||
the byte-string `EM` that is then converted to an integer and signed
|
||||
with the RSA private-key operation in `rsaSignPss`.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 8017 §9.1.1 conformant — produces `EM = maskedDB || H || 0xbc`
|
||||
- Caller-supplied salt — randomness is the responsibility of the signature scheme
|
||||
- Configurable MGF1 hash distinct from the message hash
|
||||
- Detects modulus-too-short conditions (`emLen < hLen + sLen + 2`) and throws `CRYPTO_RSA.INVALID_KEY`
|
||||
- Validates the input digest length against the hash's documented `hLen`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
|
||||
const message = new TextEncoder().encode("hello");
|
||||
const mHash = sha256(message);
|
||||
const salt = new Uint8Array(32); // would be random in production
|
||||
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
|
||||
console.log(em.length); // 256
|
||||
console.log(em[em.length - 1]); // 0xbc
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
PSS allows the message hash and the MGF1 hash to differ. This is rare
|
||||
but legal per RFC 8017 §9.1.1, and `rsaSignPss` exposes the choice
|
||||
through its `mgfHash` option.
|
||||
|
||||
```ts
|
||||
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
|
||||
const mHash = sha256(new TextEncoder().encode("audit-log-entry"));
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||||
// Mismatched hashes: SHA-256 for the message, SHA-1 for MGF1.
|
||||
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `emsaPssEncode(mHash, emBits, salt, hash, mgfHash): Uint8Array`
|
||||
|
||||
- `mHash: Uint8Array` — pre-computed `Hash(message)`; length must equal `hashOutputLength(hash)`
|
||||
- `emBits: number` — encoded-message bit length, typically `modulusBits - 1`
|
||||
- `salt: Uint8Array` — caller-supplied salt, any non-negative length up to `emLen - hLen - 2`
|
||||
- `hash: RsaHashAlgorithm` — message hash; also used to digest `M'`
|
||||
- `mgfHash: RsaHashAlgorithm` — MGF1 hash for `dbMask`
|
||||
- **returns** `Uint8Array` of length exactly `ceil(emBits / 8)`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_SIGNATURE)` when `mHash.length !== hashOutputLength(hash)`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when `emLen < hLen + sLen + 2` (modulus too short)
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown hash via the `mgf1` dispatcher
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `M'` construction prefixes 8 zero bytes before `mHash` and the
|
||||
salt — this is the RFC-mandated domain separator that prevents
|
||||
cross-protocol collisions with constructions that hash the message
|
||||
alone.
|
||||
- The most-significant `8 * emLen - emBits` bits of `maskedDB`'s
|
||||
leftmost byte are cleared per RFC 8017 §9.1.1 step 11. For a 2048-bit
|
||||
modulus, `emBits = 2047` and `8 * 256 - 2047 = 1`, so the top bit of
|
||||
byte 0 is cleared. The verifier MUST accept only encodings where this
|
||||
bit is zero, otherwise the encoding is rejected.
|
||||
- The trailing byte is always `0xbc` — the PSS trailer field. The
|
||||
verifier rejects any other value.
|
||||
- This function does NOT generate randomness — the salt is supplied by
|
||||
the caller. `rsaSignPss` is responsible for drawing the salt from a
|
||||
CSPRNG (or using a caller-provided `Rng` from `@suaveplan/types`).
|
||||
@@ -0,0 +1,89 @@
|
||||
# emsa-pss-verify
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the EMSA-PSS verification operation defined in RFC 8017
|
||||
§9.1.2. Given the message digest, the candidate encoded message `EM`,
|
||||
the encoded-message bit length, the expected salt length, and the
|
||||
hash + MGF1 hash pair, this function returns whether `EM` is a valid
|
||||
PSS encoding of the digest.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 8017 §9.1.2 conformant — full structure, padding, and trailer checks
|
||||
- Returns `false` on every malformed input rather than throwing — the
|
||||
signature scheme decides how to escalate
|
||||
- Constant-time final compare via `constantTimeEqual` from `@suaveplan/crypto-utils`
|
||||
- Validates leftmost-bit clearance (RFC 8017 §9.1.2 step 6)
|
||||
- Validates the `0xbc` trailer
|
||||
- Validates the all-zero PS region and the `0x01` separator
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { emsaPssVerify } from "@suaveplan/crypto-rsa/encoding/emsa-pss-verify";
|
||||
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
|
||||
const mHash = sha256(new TextEncoder().encode("hello"));
|
||||
const salt = new Uint8Array(32);
|
||||
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
|
||||
console.log(emsaPssVerify(mHash, em, 2047, 32, "SHA-256", "SHA-256")); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A bit-flipped EM is rejected at one of several gates: the trailer check,
|
||||
the leftmost-bit check, the PS region check, the separator check, or
|
||||
the final salt+hash compare. All of these gates fail closed (return
|
||||
`false`) without revealing which gate caught the divergence.
|
||||
|
||||
```ts
|
||||
import { emsaPssVerify } from "@suaveplan/crypto-rsa/encoding/emsa-pss-verify";
|
||||
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
|
||||
const mHash = sha256(new TextEncoder().encode("hello"));
|
||||
const em = emsaPssEncode(
|
||||
mHash,
|
||||
2047,
|
||||
new Uint8Array(32),
|
||||
"SHA-256",
|
||||
"SHA-256"
|
||||
);
|
||||
const tampered = new Uint8Array(em);
|
||||
tampered[0] = tampered[0] ^ 0x01;
|
||||
console.log(emsaPssVerify(mHash, tampered, 2047, 32, "SHA-256", "SHA-256"));
|
||||
// false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `emsaPssVerify(mHash, em, emBits, saltLength, hash, mgfHash): boolean`
|
||||
|
||||
- `mHash: Uint8Array` — `Hash(message)`; length must equal `hashOutputLength(hash)` (otherwise returns `false`)
|
||||
- `em: Uint8Array` — candidate encoded message; length must equal `ceil(emBits / 8)` (otherwise returns `false`)
|
||||
- `emBits: number` — encoded-message bit length, typically `modulusBits - 1`
|
||||
- `saltLength: number` — expected salt length, must match the value used by `emsaPssEncode`
|
||||
- `hash: RsaHashAlgorithm` — message hash; also used to digest `M'`
|
||||
- `mgfHash: RsaHashAlgorithm` — MGF1 hash for `dbMask`
|
||||
- **returns** `true` when `EM` is a valid PSS encoding of `mHash` under the triple, `false` otherwise
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The function never throws on encoding-level failures — every
|
||||
malformed input returns `false`. This matches the RFC 8017 §9.1.2
|
||||
contract: the caller is responsible for distinguishing between
|
||||
expected verify failures and protocol errors.
|
||||
- The final `H == H'` comparison is constant-time. The earlier
|
||||
structural checks (trailer byte, PS region, separator byte) short-
|
||||
circuit on first failure — that is acceptable because the public
|
||||
inputs determining those bytes are known to the verifier; only the
|
||||
hash output carries the secret-derived signal that timing attacks
|
||||
would target.
|
||||
- The `leftmostBitsToClear` check (RFC 8017 §9.1.2 step 6) prevents a
|
||||
trivial signature-malleability attack where an attacker flips bits
|
||||
in the cleared region without being detected.
|
||||
- Returning `false` on a length mismatch (`em.length !== emLen` or
|
||||
`mHash.length !== hLen`) preserves the same constant-effort failure
|
||||
shape as a structurally-valid mismatch.
|
||||
@@ -0,0 +1,84 @@
|
||||
# mgf1
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the MGF1 mask-generation function defined in RFC 8017 §B.2.1.
|
||||
MGF1 is the building block used by EMSA-PSS and RSAES-OAEP to expand a
|
||||
short seed into an arbitrary-length pseudorandom mask. The same module
|
||||
also exports the hash-dispatch helper (`digestWith`) and the per-hash
|
||||
output-length lookup (`hashOutputLength`) shared by every encoding and
|
||||
signature function in `@suaveplan/crypto-rsa`.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 8017 §B.2.1 conformant — counter-based hash chain over `seed || I2OSP(counter, 4)`
|
||||
- Supports SHA-1, SHA-256, SHA-384, SHA-512 (the four `RsaHashAlgorithm` values)
|
||||
- Routes SHA-2 through `@suaveplan/crypto-hash` (engine-pluggable)
|
||||
- Routes SHA-1 through `@noble/hashes/sha1` directly (crypto-hash omits SHA-1)
|
||||
- Enforces the `maskLen <= 2^32 * hLen` upper bound from RFC 8017 §B.2.1 step 1
|
||||
- Throws `CRYPTO_RSA.MGF_FAILURE` on negative / non-integer `maskLen` or oversize `maskLen`
|
||||
- Throws `CRYPTO_RSA.UNSUPPORTED_HASH` on unknown hash names via `digestWith` / `hashOutputLength`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { mgf1 } from "@suaveplan/crypto-rsa/encoding/mgf1";
|
||||
|
||||
const seed = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
|
||||
const mask = mgf1(seed, 32, "SHA-256");
|
||||
console.log(mask.length); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The encoding modules call `mgf1` internally with a hash that is
|
||||
typically — but not always — the same as the one used to digest the
|
||||
message. EMSA-PSS allows distinct hash and MGF hash, OAEP does the same.
|
||||
The MGF hash is independently configurable in both schemes.
|
||||
|
||||
```ts
|
||||
import { mgf1 } from "@suaveplan/crypto-rsa/encoding/mgf1";
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
|
||||
// Reproduce the dbMask used by EMSA-PSS-Encode for a 256-byte salted DB
|
||||
const mHashSeed = sha256(new TextEncoder().encode("hello"));
|
||||
const dbMask = mgf1(mHashSeed, 222, "SHA-1");
|
||||
console.log(dbMask.length); // 222
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `mgf1(seed, maskLen, hash): Uint8Array`
|
||||
|
||||
- `seed: Uint8Array` — input seed, any non-zero length
|
||||
- `maskLen: number` — desired output length in bytes; must be a non-negative integer ≤ `2^32 * hLen`
|
||||
- `hash: RsaHashAlgorithm` — `"SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"`
|
||||
- **returns** `Uint8Array` of length exactly `maskLen`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.MGF_FAILURE)` on out-of-range `maskLen`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown hash
|
||||
|
||||
### `digestWith(hash, data): Uint8Array`
|
||||
|
||||
Dispatches `data` to the appropriate hash implementation. Used internally
|
||||
by `mgf1` and by every encoding / signature function in this package.
|
||||
|
||||
### `hashOutputLength(hash): number`
|
||||
|
||||
Returns the byte length of a digest produced by `hash`: 20, 32, 48, or 64.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The internal counter buffer is allocated once (`seed.length + 4` bytes)
|
||||
and reused across the loop, avoiding per-iteration allocation.
|
||||
- The block count is `ceil(maskLen / hLen)` and the final block is
|
||||
truncated to the requested length so the result is always exactly
|
||||
`maskLen` bytes.
|
||||
- The `2^32 * hLen` bound is the conservative upper limit specified by
|
||||
RFC 8017 (the actual MGF1 limit is `2^32`, but `2^32 * hLen` covers the
|
||||
combined output bound and matches the implementation note in
|
||||
RFC 8017 §B.2.1).
|
||||
- SHA-1 routes through `@noble/hashes/sha1` because `@suaveplan/crypto-hash`
|
||||
deliberately ships only SHA-2 in its public surface; SHA-1 is needed
|
||||
here for legacy WebAuthn-TPM signature compatibility (RSASSA-PKCS1-v1\_5
|
||||
with SHA-1) and for the rare PSS variant that uses SHA-1 as the MGF
|
||||
hash with SHA-256 as the message hash.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `default-rng`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single canonical adapter that lifts the `crypto.getRandomValues`
|
||||
CSPRNG into the `@suaveplan/types` `Rng` interface every Stage 3
|
||||
surface accepts. Centralising the conversion here keeps the
|
||||
package's CSPRNG entry point auditable — exactly one callsite,
|
||||
exactly one set of tests, and any future change (e.g. routing
|
||||
through a `crypto-utils.defaultByteSource` override for hardware
|
||||
RNGs) flows from here outward.
|
||||
|
||||
## Features
|
||||
|
||||
- Routes byte production through `defaultByteSource` from
|
||||
`@suaveplan/crypto-utils` so override hooks installed at the
|
||||
utils layer also affect every RSA surface.
|
||||
- Implements `nextUint32`, `nextFloat`, and the optional
|
||||
`nextBytes` fast path so blinding / OAEP padding always take
|
||||
the cheap byte-extraction route.
|
||||
- Pure factory — never caches; returns a fresh object on every
|
||||
call. Cheap to allocate; safe to retain across operations.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
|
||||
|
||||
const rng = defaultRng();
|
||||
console.log(rng.nextBytes?.(16));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Override the byte source repo-wide via `@suaveplan/crypto-utils`
|
||||
without touching this module:
|
||||
|
||||
```ts
|
||||
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
|
||||
|
||||
// Any hardware-RNG override installed at the crypto-utils layer
|
||||
// also reaches every Stage 3 surface that calls defaultRng().
|
||||
const rng = defaultRng();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defaultRng(): Rng`
|
||||
|
||||
Returns a fresh `Rng` instance backed by
|
||||
`@suaveplan/crypto-utils.defaultByteSource`. Implements
|
||||
`nextUint32`, `nextFloat`, and `nextBytes`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `nextUint32` path allocates 4 bytes per call. Callers that
|
||||
need a tight loop should prefer `rng.nextBytes(n)` and slice
|
||||
from there to avoid the per-call allocation overhead.
|
||||
- `nextFloat` divides by `2^32` so the returned value is strictly
|
||||
less than 1 even for the all-ones `nextUint32` draw — matches
|
||||
the `Rng` interface contract from `@suaveplan/types`.
|
||||
- Returning a fresh object per call keeps state isolated; two
|
||||
callers receive independent RNG handles even though both wrap
|
||||
the same global `crypto.getRandomValues` source.
|
||||
@@ -0,0 +1,91 @@
|
||||
# `encrypt/oaep`
|
||||
|
||||
## Purpose
|
||||
|
||||
RSAES-OAEP encryption per RFC 8017 §7.1.1. The Optimal Asymmetric
|
||||
Encryption Padding (OAEP) scheme is the modern default for
|
||||
RSA public-key encryption — provably secure under the random-oracle
|
||||
model when the underlying hash is collision-resistant, and
|
||||
immune to the chosen-ciphertext attacks (Bleichenbacher, Manger)
|
||||
that plague PKCS1-v1_5.
|
||||
|
||||
This module provides the encryption side; the matching decrypt
|
||||
lives in `decrypt/oaep` and applies the Manger-safe combined
|
||||
error path.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 8017 §7.1.1 reference algorithm with caller-overridable
|
||||
hash (`SHA-256` default), MGF hash (defaults to the OAEP
|
||||
hash), and optional label (defaults to empty).
|
||||
- Routes hashing through `@suaveplan/crypto-hash` for SHA-256/384/512
|
||||
and `@noble/hashes/sha1` for SHA-1; same dispatcher Stage 2's
|
||||
`mgf1` uses, so OAEP and MGF1 stay in lock-step.
|
||||
- Modulus-length policy gate runs first; sub-1024-bit keys
|
||||
rejected outright.
|
||||
- Plaintext oversize → `CRYPTO_RSA.INVALID_KEY` with `{ mLen, max }`
|
||||
context for diagnosability.
|
||||
- `crypto_rsa.encrypt_duration_ms{scheme="oaep",hash}` histogram.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaEncryptOaep } from "@suaveplan/crypto-rsa/encrypt/oaep";
|
||||
|
||||
const ciphertext = rsaEncryptOaep(
|
||||
publicKey,
|
||||
new TextEncoder().encode("hello"),
|
||||
{ hash: "SHA-256" }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
OAEP with SHA-384 hashing and a non-empty label (mirrors a
|
||||
common JWE `RSA-OAEP-384` deployment with a context-bound
|
||||
label):
|
||||
|
||||
```ts
|
||||
import { rsaEncryptOaep } from "@suaveplan/crypto-rsa/encrypt/oaep";
|
||||
|
||||
const label = new TextEncoder().encode("session-2026-05-11");
|
||||
const ct = rsaEncryptOaep(publicKey, plaintext, {
|
||||
hash: "SHA-384",
|
||||
mgfHash: "SHA-384",
|
||||
label,
|
||||
allowLegacy: false,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaEncryptOaep(key, plaintext, options?): Uint8Array`
|
||||
|
||||
- `key: RsaPublicKey`
|
||||
- `plaintext: Uint8Array` — at most `k - 2*hLen - 2` bytes.
|
||||
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`.
|
||||
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `hash`.
|
||||
- `options.label?: Uint8Array` — defaults to empty.
|
||||
- `options.rng?: Rng` — defaults to `defaultRng()`.
|
||||
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
|
||||
- Throws `CRYPTO_RSA.INVALID_KEY` on plaintext oversize,
|
||||
`CRYPTO_RSA.UNSUPPORTED_HASH` on unknown algorithm,
|
||||
`CRYPTO_RSA.MGF_FAILURE` on absurd mask-length input,
|
||||
modulus-length policy violations on bad key.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The seed is drawn from the injected `Rng` via the shared
|
||||
`nextBytesViaRng` helper (same path as blinding) so seeded
|
||||
test RNGs work transparently.
|
||||
- We share the `mgf1` and `digestWith` implementations from
|
||||
Stage 2's `src/encoding/mgf1.ts` rather than duplicating —
|
||||
RFC 8017 §B.2.1 is short but having two copies invites drift.
|
||||
- `db` is allocated once and built in place; XOR with the mask
|
||||
produces a new buffer rather than mutating in place because
|
||||
`xor` from `@suaveplan/crypto-utils` returns a fresh allocation
|
||||
(length-mismatch guard).
|
||||
- The OAEP encoding allocates `O(k)` — for 4096-bit keys this
|
||||
is 512 bytes. Hot-loop callers (e.g. JWE bulk re-encryption)
|
||||
should prefer keeping a single `RsaPublicKey` and amortising
|
||||
the modpow cost over many ciphertexts.
|
||||
@@ -0,0 +1,90 @@
|
||||
# `encrypt/pkcs1v15`
|
||||
|
||||
## Purpose
|
||||
|
||||
RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1 — the legacy
|
||||
public-key encryption padding scheme historically used by
|
||||
TLS 1.0–1.2 ClientKeyExchange and JWE `RSA1_5` algorithm. Modern
|
||||
deployments should prefer OAEP (`@suaveplan/crypto-rsa/encrypt/oaep`)
|
||||
because the matching decrypt path is vulnerable to
|
||||
Bleichenbacher chosen-ciphertext attacks; encryption itself is
|
||||
straightforward padding.
|
||||
|
||||
## Features
|
||||
|
||||
- Block-type-2 padding: `0x00 || 0x02 || PS || 0x00 || M` where
|
||||
`PS` is `k - mLen - 3` non-zero random bytes (RFC 8017 §7.2.1
|
||||
step 2 mandates non-zero).
|
||||
- Modulus-length policy gate runs first — sub-1024-bit keys
|
||||
rejected outright, 1024-bit accepted only with
|
||||
`{ allowLegacy: true }`.
|
||||
- Plaintext oversize → `CRYPTO_RSA.INVALID_KEY` with
|
||||
`{ mLen, max }` context for diagnosability.
|
||||
- Pluggable `Rng` for tests; defaults to `crypto.getRandomValues`
|
||||
via `defaultRng()`.
|
||||
- Observability — `crypto_rsa.encrypt_duration_ms{scheme="pkcs1v15"}`
|
||||
histogram + `outcome_total{operation="encrypt"}` counter on
|
||||
success, `errors_total{code}` on every typed-error throw.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaEncryptPkcs1v15 } from "@suaveplan/crypto-rsa/encrypt/pkcs1v15";
|
||||
import type { RsaPublicKey } from "@suaveplan/crypto-rsa";
|
||||
|
||||
declare const publicKey: RsaPublicKey; // ≥ 2048-bit
|
||||
const ciphertext = rsaEncryptPkcs1v15(
|
||||
publicKey,
|
||||
new TextEncoder().encode("hello"),
|
||||
{ allowLegacy: false }
|
||||
);
|
||||
// ciphertext.length === Math.ceil(publicKey.bitLength / 8)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Deterministic round-trip in a unit test using a seeded Rng:
|
||||
|
||||
```ts
|
||||
import { rsaEncryptPkcs1v15 } from "@suaveplan/crypto-rsa/encrypt/pkcs1v15";
|
||||
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
|
||||
|
||||
let s = 1;
|
||||
const seededRng = {
|
||||
nextUint32: () => (s = (s * 1664525 + 1013904223) >>> 0),
|
||||
nextFloat: () => Math.random(),
|
||||
};
|
||||
|
||||
const ct = rsaEncryptPkcs1v15(publicKey, plaintext, {
|
||||
rng: seededRng,
|
||||
allowLegacy: true,
|
||||
});
|
||||
const pt = rsaDecryptPkcs1v15(privateKey, ct, { allowLegacy: true });
|
||||
// pt deepEquals plaintext
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaEncryptPkcs1v15(key, plaintext, options?): Uint8Array`
|
||||
|
||||
- `key: RsaPublicKey` — modulus must satisfy the policy gate.
|
||||
- `plaintext: Uint8Array` — at most `k - 11` bytes where
|
||||
`k = ceil(bitLength / 8)`.
|
||||
- `options.rng?: Rng` — randomness source for the `PS` block.
|
||||
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
|
||||
- Returns the `k`-byte ciphertext.
|
||||
- Throws `CRYPTO_RSA.INVALID_KEY` on plaintext oversize,
|
||||
`MODULUS_TOO_SHORT` / `MODULUS_TOO_LARGE` on policy violations.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The non-zero `PS` is generated by drawing `2 * remaining`
|
||||
bytes per loop iteration; the rejection rate is `1/256` per
|
||||
byte so a single draw covers almost every case.
|
||||
- The whole pad-and-modpow path is non-constant-time. PKCS1-v1_5
|
||||
encryption is a public operation so timing leaks have no
|
||||
security impact — all the danger lives on the decryption side.
|
||||
- `byteLength(key)` derives from `bitLength` so partial-byte
|
||||
moduli (e.g. 2049-bit) round up to the next whole byte.
|
||||
- We do not blind during encryption — only the private operation
|
||||
(decrypt / sign) leaks information through timing.
|
||||
@@ -0,0 +1,90 @@
|
||||
# engine/noble
|
||||
|
||||
## Purpose
|
||||
|
||||
Default `RsaCryptoEngine` backed by the pure-JS noble primitives this
|
||||
package ships natively. Provides the `name: "noble"` engine label and
|
||||
delegates `sign` / `verify` / `encrypt` / `decrypt` / `modPow` to the
|
||||
canonical `rsa*` functions, giving engine-aware consumers identical
|
||||
semantics to direct callers.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every method on `RsaCryptoEngine`
|
||||
- Routes through `rsaSignPkcs1v15`, `rsaSignPss`, `rsaVerifyPkcs1v15`,
|
||||
`rsaVerifyPss`, `rsaEncryptPkcs1v15`, `rsaEncryptOaep`,
|
||||
`rsaDecryptPkcs1v15`, `rsaDecryptOaep`
|
||||
- Discriminated `RsaSchemeOptions` union routes per-scheme parameters
|
||||
- Rejects scheme/operation mismatches (e.g. `sign({ scheme: "oaep" })`)
|
||||
with `UNSUPPORTED_FORMAT`
|
||||
- `modPow` exposed for advanced consumers (e.g. consumer-side
|
||||
signature audit, key-pair sanity checks)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { nobleEngine } from "@suaveplan/crypto-rsa/engine";
|
||||
|
||||
const sig = await nobleEngine.sign(privateKey, message, {
|
||||
scheme: "pkcs1v15",
|
||||
hash: "SHA-256",
|
||||
});
|
||||
const ok = await nobleEngine.verify(publicKey, message, sig, {
|
||||
scheme: "pkcs1v15",
|
||||
hash: "SHA-256",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the engine surface to abstract over backend selection in higher-
|
||||
level code:
|
||||
|
||||
```ts
|
||||
import {
|
||||
nobleEngine,
|
||||
type RsaCryptoEngine,
|
||||
} from "@suaveplan/crypto-rsa/engine";
|
||||
|
||||
async function signWithEngine(
|
||||
engine: RsaCryptoEngine,
|
||||
privateKey: RsaPrivateKey,
|
||||
message: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
return engine.sign(privateKey, message, {
|
||||
scheme: "pss",
|
||||
hash: "SHA-512",
|
||||
saltLength: 64,
|
||||
});
|
||||
}
|
||||
|
||||
const sig = await signWithEngine(nobleEngine, key, msg);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `nobleEngine: RsaCryptoEngine`
|
||||
|
||||
- `name: "noble"`
|
||||
- `sign(privateKey, message, options)` — `RsaSchemeOptions` with
|
||||
`scheme: "pkcs1v15" | "pss"`
|
||||
- `verify(publicKey, message, signature, options)` — same scheme set
|
||||
- `encrypt(publicKey, plaintext, options)` — `scheme: "pkcs1v15" | "oaep"`
|
||||
- `decrypt(privateKey, ciphertext, options)` — same scheme set
|
||||
- `modPow(base, exponent, modulus): bigint`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The engine is a stateless singleton — no construction overhead, no
|
||||
per-call setup.
|
||||
- Scheme/operation mismatches throw `UNSUPPORTED_FORMAT` synchronously
|
||||
inside the async wrapper. Defensive by design — the discriminated
|
||||
union forbids the mismatch at the type level, but runtime callers
|
||||
can bypass the type system (`as never`).
|
||||
- `modPow` is exposed to give advanced consumers a single uniform
|
||||
modpow surface across engines. The implementation matches
|
||||
`math/modpow` exactly (both delegate to
|
||||
`@noble/curves/abstract/modular.pow`).
|
||||
- Sign / verify route through the package's existing functions with
|
||||
blinding enabled by default; the caller cannot disable blinding via
|
||||
the engine surface.
|
||||
@@ -0,0 +1,76 @@
|
||||
# engine/select
|
||||
|
||||
## Purpose
|
||||
|
||||
Resolve the default `RsaCryptoEngine` for the host runtime. When
|
||||
`globalThis.crypto.subtle` exposes the RSA algorithms this package
|
||||
needs, `subtleEngine` is returned for hardware-accelerated performance;
|
||||
otherwise the pure-JS `nobleEngine` is used. Increments
|
||||
`crypto_rsa.engine_selected_total{engine}` on every call so production
|
||||
dashboards can track engine distribution per deployment.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-detects `subtle` availability at runtime
|
||||
- `prefer: "noble"` forces the pure-JS engine even when subtle is
|
||||
available (useful for deterministic-test scenarios)
|
||||
- `prefer: "subtle"` forces subtle and throws `ENGINE_UNAVAILABLE` if
|
||||
absent (caller commits to subtle, surfaces failure synchronously)
|
||||
- Counter increments on every call regardless of branch taken
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultEngine } from "@suaveplan/crypto-rsa/engine";
|
||||
|
||||
const engine = defaultEngine();
|
||||
const sig = await engine.sign(privateKey, message, {
|
||||
scheme: "pkcs1v15",
|
||||
hash: "SHA-256",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Force noble in test environments to make signature outputs reproducible
|
||||
(subtle's RSA-PSS internally generates the salt and is non-determinstic;
|
||||
noble's PSS path accepts a custom `Rng`):
|
||||
|
||||
```ts
|
||||
import { defaultEngine } from "@suaveplan/crypto-rsa/engine";
|
||||
|
||||
const engine = defaultEngine({ prefer: "noble" });
|
||||
// Reproducible PSS signatures.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defaultEngine(options?): RsaCryptoEngine`
|
||||
|
||||
- `options.prefer?: "noble" | "subtle"` — forces the engine choice
|
||||
- **returns** `RsaCryptoEngine`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.ENGINE_UNAVAILABLE)` when
|
||||
`prefer: "subtle"` is set and `globalThis.crypto.subtle` is missing
|
||||
|
||||
### `DefaultEngineOptions`
|
||||
|
||||
- `prefer?: RsaEngineName`
|
||||
|
||||
### `resetEngineSelectionCounterForTesting()`
|
||||
|
||||
Test-only helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The selection rule is: if `prefer` is set, use it (subtle requested
|
||||
but missing → throw); otherwise prefer subtle, fall back to noble.
|
||||
The counter records the actual choice made, not the request.
|
||||
- Detecting subtle availability is a single `globalThis.crypto.subtle`
|
||||
read. Cached lookups would be incorrect — runtimes can polyfill
|
||||
subtle mid-process (e.g. test setup adding a Web Crypto polyfill).
|
||||
- The counter cardinality is bounded to two label values
|
||||
(`noble`/`subtle`) so dashboards aggregate cleanly.
|
||||
- This selector is the recommended default entry point for engine
|
||||
consumers; calling `nobleEngine` / `subtleEngine` directly is
|
||||
appropriate only when the caller has committed to a specific engine
|
||||
for the lifetime of the operation.
|
||||
@@ -0,0 +1,96 @@
|
||||
# engine/subtle
|
||||
|
||||
## Purpose
|
||||
|
||||
`RsaCryptoEngine` adapter over `globalThis.crypto.subtle`. When the
|
||||
host runtime exposes WebCrypto, this engine routes RSA operations
|
||||
through the platform-native (often hardware-accelerated) implementation
|
||||
— faster than pure-JS modpow on every modern runtime (browsers, Node
|
||||
≥ 16, Bun, Deno, Cloudflare Workers).
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every method on `RsaCryptoEngine`
|
||||
- Maps each scheme to the matching subtle algorithm (`RSASSA-PKCS1-v1_5`,
|
||||
`RSA-PSS`, `RSA-OAEP`)
|
||||
- Imports keys via JWK so the canonical `RsaPublicKey` / `RsaPrivateKey`
|
||||
shape works without manual key marshalling
|
||||
- `extractable: false` by default — the imported `CryptoKey` cannot be
|
||||
exported back to raw bytes
|
||||
- `extractable: true` opt-in increments `crypto_rsa.subtle_extractable_total`
|
||||
so production dashboards can flag the unsafe path
|
||||
- Throws `ENGINE_UNAVAILABLE` synchronously when subtle is missing —
|
||||
caller can fall back to noble immediately
|
||||
- Rejects `encrypt({ scheme: "pkcs1v15" })` and `decrypt(...)` —
|
||||
WebCrypto deliberately removed RSA-PKCS1-v1\_5 encryption from the
|
||||
standard (Bleichenbacher defence at the spec level)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { subtleEngine } from "@suaveplan/crypto-rsa/engine";
|
||||
|
||||
const eng = subtleEngine();
|
||||
const sig = await eng.sign(privateKey, message, {
|
||||
scheme: "pkcs1v15",
|
||||
hash: "SHA-256",
|
||||
});
|
||||
const ok = await eng.verify(publicKey, message, sig, {
|
||||
scheme: "pkcs1v15",
|
||||
hash: "SHA-256",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For workflows that need `extractable: true` (e.g. exporting keys to
|
||||
non-WebCrypto runtimes mid-test):
|
||||
|
||||
```ts
|
||||
import { subtleEngine } from "@suaveplan/crypto-rsa/engine";
|
||||
|
||||
const eng = subtleEngine({ extractable: true });
|
||||
// Note: every call increments crypto_rsa.subtle_extractable_total —
|
||||
// production dashboards flag this as unsafe.
|
||||
const sig = await eng.sign(privateKey, message, {
|
||||
scheme: "pss",
|
||||
hash: "SHA-512",
|
||||
saltLength: 64,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `subtleEngine(options?): RsaCryptoEngine`
|
||||
|
||||
- `options.extractable?: boolean` — defaults to `false`; setting `true`
|
||||
imports keys with the `extractable` bit set and emits the
|
||||
`crypto_rsa.subtle_extractable_total` counter
|
||||
- **returns** `RsaCryptoEngine` with `name: "subtle"`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.ENGINE_UNAVAILABLE)` when
|
||||
`globalThis.crypto.subtle` is undefined
|
||||
|
||||
### `SubtleEngineOptions`
|
||||
|
||||
- `extractable?: boolean`
|
||||
|
||||
### `resetSubtleCountersForTesting()`
|
||||
|
||||
Test-only helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- WebCrypto's `RSA-PSS` requires `saltLength` at sign/verify time; this
|
||||
adapter defaults to `hashOutputBytes(hash)` (the RFC 8017 §8.1
|
||||
recommendation) when the caller omits it.
|
||||
- `RSA-OAEP` accepts an optional `label` (`OAEP-DSP` "P" parameter);
|
||||
when omitted, subtle uses the empty label automatically.
|
||||
- `RSASSA-PKCS1-v1_5` for encryption is deliberately rejected — the
|
||||
WebCrypto spec removed it after the Bleichenbacher 1998 / 2001
|
||||
attacks. Use OAEP for encryption with subtle; pure-JS PKCS1v15
|
||||
encrypt remains available via the noble engine for legacy interop.
|
||||
- `modPow` falls back to `math/modpow` (which routes through noble) —
|
||||
WebCrypto exposes no standalone `modPow` primitive.
|
||||
- `importKey` runs every call. For very-high-frequency callers, build
|
||||
a key cache outside the engine; this adapter prioritises correctness
|
||||
and statelessness over import-amortisation.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `errors`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the canonical error registry and concrete `CryptoRsaError` class for the entire
|
||||
`@suaveplan/crypto-rsa` package. The 17-code surface is declared up-front so consumer
|
||||
switches over `error.code` are exhaustive even before later stages of the package wire
|
||||
up the production paths that raise the codes.
|
||||
|
||||
This module is a pure registry — no runtime behaviour beyond constructing errors. Every
|
||||
public-API entry-point in `@suaveplan/crypto-rsa` ultimately throws a `CryptoRsaError`,
|
||||
never a bare `Error`, so consumers get stable codes and i18n message keys.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `CRYPTO_RSA_CODES` literal-typed map produced via `defineCodes("CRYPTO_RSA", …)`.
|
||||
- Exhaustive 17-member `CryptoRsaCode` discriminated union covering every code Stages
|
||||
1-4 of the proposal will raise.
|
||||
- `CryptoRsaError` subclass extending `SuaveplanError<CryptoRsaCode>` so `instanceof`
|
||||
narrows the code union for error handlers.
|
||||
- Stable `code` strings (`CRYPTO_RSA.<REASON>`) safe to log, count, and switch on.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CRYPTO_RSA_CODES, CryptoRsaError } from "@suaveplan/crypto-rsa/errors";
|
||||
|
||||
throw new CryptoRsaError(
|
||||
CRYPTO_RSA_CODES.INVALID_KEY,
|
||||
"error.crypto-rsa.invalid-key"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CRYPTO_RSA_CODES, CryptoRsaError } from "@suaveplan/crypto-rsa/errors";
|
||||
|
||||
try {
|
||||
parseRsaPublicKey(bytes, { format: "pkcs1" });
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoRsaError) {
|
||||
switch (error.code) {
|
||||
case CRYPTO_RSA_CODES.INVALID_KEY:
|
||||
metrics.invalidKey.add(1);
|
||||
break;
|
||||
case CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT:
|
||||
metrics.unsupportedFormat.add(1);
|
||||
break;
|
||||
default:
|
||||
metrics.otherError.add(1, { code: error.code });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CRYPTO_RSA_CODES`
|
||||
|
||||
Frozen object mapping each reason string (e.g. `"INVALID_KEY"`) to its fully-qualified
|
||||
code (`"CRYPTO_RSA.INVALID_KEY"`). Indexable by reason key for type-safe access.
|
||||
|
||||
### `type CryptoRsaCode`
|
||||
|
||||
Union of every value in `CRYPTO_RSA_CODES`. Use as the discriminant on switch statements.
|
||||
|
||||
### `class CryptoRsaError extends SuaveplanError<CryptoRsaCode>`
|
||||
|
||||
Constructor accepts `(code, messageKey, options?)`. `options` follows
|
||||
`SuaveplanErrorOptions` (`context`, `cause`, `traceId`, `clock`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The full registry is declared even though only five codes
|
||||
(`MODULUS_TOO_SHORT`, `MODULUS_TOO_LARGE`, `INVALID_KEY`, `UNSUPPORTED_FORMAT`,
|
||||
`PEM_MALFORMED`) are reachable from production code yet. The remaining 12 codes
|
||||
(`INVALID_SIGNATURE`, `SIGNATURE_INVALID`, `UNSUPPORTED_HASH`, `DECRYPT_FAILED`,
|
||||
`MGF_FAILURE`, `KEYGEN_FAILED`, `PRIME_INVALID`, `KEY_OP_NOT_PERMITTED`,
|
||||
`PROFILE_VIOLATION`, `ENGINE_UNAVAILABLE`, `FINGERPRINT_FAILED`, `ABORTED`)
|
||||
become reachable as Stages 2-4 land sign/verify/encrypt/decrypt/keygen/engine/profile
|
||||
modules. Declaring them now keeps the public surface stable and prevents downstream
|
||||
consumers from having to recompile their switch statements.
|
||||
@@ -0,0 +1,80 @@
|
||||
# fingerprint/format
|
||||
|
||||
## Purpose
|
||||
|
||||
Render a fingerprint byte sequence as a human- and machine-readable
|
||||
string. Provides four output formats covering every common operator
|
||||
display: `hex` (lowercase, no separators), `base64` (standard alphabet
|
||||
with `=` padding), `base64url` (URL-safe, no padding), and `colon-hex`
|
||||
(lowercase hex with `:` between bytes — matches `ssh-keygen -lf` legacy
|
||||
MD5 output and `gpg --fingerprint`).
|
||||
|
||||
## Features
|
||||
|
||||
- Four output formats covering every common ops convention
|
||||
- Increments `crypto_rsa.fingerprint_total{format}` counter on every
|
||||
call so dashboards can aggregate per-format usage
|
||||
- Pairs with `computeSpkiFingerprint` for end-to-end fingerprinting
|
||||
- Throws `CRYPTO_RSA.FINGERPRINT_FAILED` for unknown formats (defensive
|
||||
guard for callers bypassing the typed union)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { formatFingerprint } from "@suaveplan/crypto-rsa/fingerprint";
|
||||
|
||||
const display = formatFingerprint(digest, "colon-hex");
|
||||
// "ab:cd:12:34:..."
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Switch format dynamically based on a runtime configuration option:
|
||||
|
||||
```ts
|
||||
import {
|
||||
computeSpkiFingerprint,
|
||||
formatFingerprint,
|
||||
type FingerprintFormat,
|
||||
} from "@suaveplan/crypto-rsa/fingerprint";
|
||||
|
||||
function displayKey(
|
||||
key: RsaPublicKey,
|
||||
format: FingerprintFormat
|
||||
): string {
|
||||
const digest = computeSpkiFingerprint(key);
|
||||
return formatFingerprint(digest, format);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `formatFingerprint(bytes, format)`
|
||||
|
||||
- `bytes: Uint8Array`
|
||||
- `format: "hex" | "base64" | "base64url" | "colon-hex"`
|
||||
- **returns** `string`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.FINGERPRINT_FAILED)` on unknown
|
||||
format (defensive — TypeScript narrows the union; only reachable via
|
||||
type-system bypass)
|
||||
|
||||
### `FingerprintFormat`
|
||||
|
||||
Union type of the four supported format names.
|
||||
|
||||
### `resetFingerprintCounterForTesting()`
|
||||
|
||||
Test-only helper. Production code never calls this.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The counter (`crypto_rsa.fingerprint_total{format}`) is incremented
|
||||
unconditionally on every call. The format attribute is bounded to
|
||||
the four-element union so cardinality stays low.
|
||||
- `colon-hex` is implemented as `toHex(bytes)` plus a 2-character
|
||||
re-grouping with `:` separators — the same convention used by
|
||||
`ssh-keygen -lf` for MD5 fingerprints (pre-OpenSSH 6.8).
|
||||
- The `default` arm of the format switch is reachable only when a
|
||||
caller deliberately bypasses the type system — defensive guard
|
||||
rather than load-bearing. Coverage is exercised by the test suite
|
||||
via `as never` type-narrowing escape.
|
||||
@@ -0,0 +1,73 @@
|
||||
# fingerprint/spki
|
||||
|
||||
## Purpose
|
||||
|
||||
Compute a deterministic fingerprint of an RSA public key by hashing
|
||||
its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublicKeyInfo`). The
|
||||
resulting digest uniquely identifies the key independently of any PEM
|
||||
armor, JWK packaging, or other transport framing — semantically
|
||||
equivalent to the `ssh-keygen -lf` fingerprint of an OpenSSH-format
|
||||
key.
|
||||
|
||||
## Features
|
||||
|
||||
- Hashes the canonical SPKI DER bytes (RFC 5280)
|
||||
- Default SHA-256; SHA-1 / SHA-384 / SHA-512 supported
|
||||
- Wraps SPKI serialiser failures as `FINGERPRINT_FAILED` so callers see
|
||||
a single failure class regardless of underlying cause
|
||||
- Pairs with `formatFingerprint` for human-readable output
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { computeSpkiFingerprint } from "@suaveplan/crypto-rsa/fingerprint";
|
||||
|
||||
const digest = computeSpkiFingerprint(publicKey);
|
||||
// 32 bytes (SHA-256)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Combine with `formatFingerprint` to render a colon-separated hex
|
||||
string for ops dashboards and certificate-pinning workflows:
|
||||
|
||||
```ts
|
||||
import {
|
||||
computeSpkiFingerprint,
|
||||
formatFingerprint,
|
||||
} from "@suaveplan/crypto-rsa/fingerprint";
|
||||
|
||||
const digest = computeSpkiFingerprint(publicKey, { hash: "SHA-256" });
|
||||
const display = formatFingerprint(digest, "colon-hex");
|
||||
// "ab:12:cd:34:..."
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `computeSpkiFingerprint(publicKey, options?)`
|
||||
|
||||
- `publicKey: RsaPublicKey`
|
||||
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
|
||||
- **returns** `Uint8Array` — digest bytes
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on bad hash;
|
||||
`CRYPTO_RSA.FINGERPRINT_FAILED` when SPKI serialisation throws (e.g.
|
||||
malformed `n` / `e`).
|
||||
|
||||
### `SpkiFingerprintOptions`
|
||||
|
||||
- `hash?: RsaHashAlgorithm`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The fingerprint is computed over the SPKI DER bytes — NOT the PEM
|
||||
armor. PEM-armored input produces the same fingerprint as the
|
||||
underlying DER.
|
||||
- SHA-256 is the default to match modern OpenSSH conventions
|
||||
(`SHA256:base64url(...)` since OpenSSH 6.8 / 2015).
|
||||
- SHA-1 is supported for legacy interop (Ed25519 SPKI fingerprints in
|
||||
some older TLS pinning configs); production code should default to
|
||||
SHA-256 or higher.
|
||||
- Failure to serialise the SPKI bytes (e.g. negative `n`, malformed
|
||||
`bitLength`) surfaces as `FINGERPRINT_FAILED` with the underlying
|
||||
error preserved as `cause` — callers needing the original failure
|
||||
class can introspect `error.cause`.
|
||||
@@ -0,0 +1,93 @@
|
||||
# handle/registry
|
||||
|
||||
## Purpose
|
||||
|
||||
Process-wide registry for RSA key-handle providers. Every HSM, KMS, OS
|
||||
keychain, or PKCS#11 bridge registers a provider once at startup; the
|
||||
registry resolves opaque `RsaKeyHandle` values to the matching provider
|
||||
at call time and dispatches `resolve` / `sign` / `decrypt` operations
|
||||
through it. Decouples consumer code from any specific hardware
|
||||
backend.
|
||||
|
||||
## Features
|
||||
|
||||
- Single in-process registry keyed by provider name
|
||||
- Idempotent re-registration of the same provider value
|
||||
- Conflicting registration of a different provider under the same name
|
||||
rejects with `INVALID_KEY`
|
||||
- `signWithHandle` / `decryptWithHandle` mirror the `RsaCryptoEngine`
|
||||
surface so handle consumers and engine consumers share the same
|
||||
scheme-options shape
|
||||
- `unregisterKeyHandleProvider` exposed for test cleanup
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerKeyHandleProvider,
|
||||
signWithHandle,
|
||||
} from "@suaveplan/crypto-rsa/handle";
|
||||
|
||||
registerKeyHandleProvider(myHsmProvider);
|
||||
const sig = await signWithHandle(
|
||||
{ provider: "my-hsm", id: "key-1" },
|
||||
message,
|
||||
{ scheme: "pkcs1v15", hash: "SHA-256" }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For test setups that need a clean registry per test:
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerKeyHandleProvider,
|
||||
unregisterKeyHandleProvider,
|
||||
SoftwareRsaKeyHandleProvider,
|
||||
} from "@suaveplan/crypto-rsa/handle";
|
||||
|
||||
beforeEach(() => {
|
||||
registerKeyHandleProvider(new SoftwareRsaKeyHandleProvider());
|
||||
});
|
||||
afterEach(() => {
|
||||
unregisterKeyHandleProvider("software");
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `registerKeyHandleProvider(provider)`
|
||||
|
||||
- `provider: RsaKeyHandleProvider`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on conflicting
|
||||
registration of a different provider under the same name
|
||||
|
||||
### `unregisterKeyHandleProvider(name)`
|
||||
|
||||
- `name: string`
|
||||
|
||||
### `resolveKeyHandle(handle): Promise<RsaPublicKey | RsaPrivateKey>`
|
||||
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when no provider
|
||||
is registered under the handle's `provider` name
|
||||
|
||||
### `signWithHandle(handle, message, options): Promise<Uint8Array>`
|
||||
|
||||
### `decryptWithHandle(handle, ciphertext, options): Promise<Uint8Array>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is a module-level `Map<string, RsaKeyHandleProvider>`.
|
||||
The chosen scope is process-lifetime; if cross-process or per-vault
|
||||
isolation is required, callers must wrap their own dispatch layer.
|
||||
- Idempotent registration of the same provider value (object identity
|
||||
comparison) is a no-op so that import-once-then-register patterns
|
||||
work without per-import side effects.
|
||||
- Conflicting registration (two different provider objects with the
|
||||
same name) raises a typed error instead of silently winning — this
|
||||
is to detect copy-paste mistakes between adapters during integration.
|
||||
- Handle resolution failures throw `INVALID_KEY` because from the
|
||||
caller's perspective the supplied handle was not valid (the
|
||||
underlying cause — provider unregistered, id unknown — is captured
|
||||
in the error context).
|
||||
@@ -0,0 +1,78 @@
|
||||
# handle/software
|
||||
|
||||
## Purpose
|
||||
|
||||
Reference `RsaKeyHandleProvider` wrapping in-memory `RsaPrivateKey`
|
||||
values. Implements the same interface as a real HSM bridge but with
|
||||
zero hardware dependency, so consumers can develop and test
|
||||
handle-based code paths without an HSM. Production workloads typically
|
||||
replace this with their own provider (PKCS#11, AWS KMS, Apple Keychain
|
||||
via `@suaveplan/macos-keychain`, etc.).
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every `RsaKeyHandleProvider` method
|
||||
- Stores keys in a process-local `Map<string, RsaPrivateKey>` keyed by
|
||||
handle id
|
||||
- Sign / decrypt route through `nobleEngine` so semantics match the
|
||||
rest of the package exactly
|
||||
- `register(id, key)` returns the matching `RsaKeyHandle` for ergonomic
|
||||
test setups
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerKeyHandleProvider,
|
||||
signWithHandle,
|
||||
} from "@suaveplan/crypto-rsa/handle";
|
||||
import { SoftwareRsaKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
|
||||
|
||||
const provider = new SoftwareRsaKeyHandleProvider();
|
||||
registerKeyHandleProvider(provider);
|
||||
const handle = provider.register("test-key", privateKey);
|
||||
const sig = await signWithHandle(handle, message, {
|
||||
scheme: "pkcs1v15",
|
||||
hash: "SHA-256",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the software provider to drive integration tests that exercise
|
||||
the handle-based code path of a downstream consumer (e.g. a JWS signer
|
||||
that accepts `RsaKeyHandle` instead of an in-memory key):
|
||||
|
||||
```ts
|
||||
import { SoftwareRsaKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
|
||||
import { registerKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
|
||||
|
||||
const provider = new SoftwareRsaKeyHandleProvider();
|
||||
registerKeyHandleProvider(provider);
|
||||
const handle = provider.register("k1", testKey);
|
||||
|
||||
// Downstream consumer uses the handle exactly as it would an HSM key.
|
||||
const jws = await jwsSigner.sign(payload, handle);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SoftwareRsaKeyHandleProvider`
|
||||
|
||||
- `name: "software"` (readonly)
|
||||
- `register(id: string, key: RsaPrivateKey): RsaKeyHandle`
|
||||
- `resolve(handle): Promise<RsaPrivateKey>`
|
||||
- `sign(handle, message, options): Promise<Uint8Array>`
|
||||
- `decrypt(handle, ciphertext, options): Promise<Uint8Array>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The provider is class-based (vs the module-singleton style of
|
||||
`nobleEngine`) so consumers can spin up multiple instances for
|
||||
parallel-test isolation. Each instance has its own internal Map.
|
||||
- Sign/decrypt delegate to `nobleEngine` directly — this provider is
|
||||
a pure JS reference; HSM-backed providers replace these methods with
|
||||
the appropriate `crypto.subtle` / PKCS#11 / KMS API calls.
|
||||
- The "handle id missing" failure throws a plain `Error` (NOT a
|
||||
`CryptoRsaError`) because the caller is the registry layer; the
|
||||
registry maps it to a typed error before reaching application code.
|
||||
@@ -0,0 +1,59 @@
|
||||
# jwk/thumbprint-uri
|
||||
|
||||
## Purpose
|
||||
|
||||
Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical
|
||||
`urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64url(thumbprint)>`
|
||||
form used by OAuth 2.0 Demonstrating Proof-of-Possession (DPoP),
|
||||
draft-ietf-oauth-dpop, and other OAuth/OIDC binding protocols. Wraps
|
||||
`jwkThumbprint` with the hash-algorithm label and URN prefix.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 9278 §3 conformant URN format
|
||||
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported
|
||||
- Hash labels match RFC 9278 §3.1 registry: `sha-1`, `sha-256`, `sha-384`,
|
||||
`sha-512`
|
||||
- Returns a deterministic `string` ready to embed in OAuth tokens, DPoP
|
||||
proofs, or `iss`/`sub` claims
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
|
||||
|
||||
const uri = jwkThumbprintUri(publicKey);
|
||||
// "urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8u..."
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For protocols that pin a non-default hash (e.g. SHA-512 for
|
||||
high-assurance bindings), pass the `hash` option:
|
||||
|
||||
```ts
|
||||
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
|
||||
|
||||
const sha512Uri = jwkThumbprintUri(publicKey, { hash: "SHA-512" });
|
||||
// "urn:ietf:params:oauth:jwk-thumbprint:sha-512:..."
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `jwkThumbprintUri(key, options?)`
|
||||
|
||||
- `key: RsaPublicKey | RsaJwk` — public key or JWK
|
||||
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
|
||||
- **returns** `string` — full URN
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` /
|
||||
`CRYPTO_RSA.INVALID_KEY` (propagated from `jwkThumbprint`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The hash-label mapping is a frozen lookup table mirroring the
|
||||
IANA-registered names in RFC 9278 §3.1. Adding new RSA hash
|
||||
algorithms (e.g. SHA-3) requires updating both this table and the
|
||||
`RsaHashAlgorithm` union in `keys.types.ts`.
|
||||
- This helper is a thin wrapper over `jwkThumbprint` + `toBase64Url`;
|
||||
it adds no cryptographic complexity, only the URN framing. Tests
|
||||
cover the framing for every supported hash.
|
||||
@@ -0,0 +1,84 @@
|
||||
# jwk/thumbprint
|
||||
|
||||
## Purpose
|
||||
|
||||
Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The
|
||||
thumbprint is a deterministic hash over the canonical JSON form of the
|
||||
public-key members (`e`, `kty`, `n` in alphabetical order with no
|
||||
whitespace), giving every RSA key a stable identifier independent of
|
||||
its wire format. Used downstream for `kid` derivation, key rotation
|
||||
diffing, and key-pinning protocols.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 7638 §3.2 conformant — RSA member ordering `e`, `kty`, `n`
|
||||
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported via `hash` option
|
||||
- Accepts both canonical `RsaPublicKey` and `RsaJwk` shapes
|
||||
- Hand-built canonical JSON byte string (does NOT use `JSON.stringify` —
|
||||
member order is implementation-defined for that API)
|
||||
- Worked-example matched against the RFC 7638 §3.1 reference value
|
||||
`"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
|
||||
- `jwkThumbprintBase64Url` convenience returns the URL-safe base64 encoding directly
|
||||
- All thrown errors are `CryptoRsaError` with `INVALID_KEY` /
|
||||
`UNSUPPORTED_HASH`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { jwkThumbprint } from "@suaveplan/crypto-rsa/jwk";
|
||||
|
||||
const fingerprint = jwkThumbprint(publicKey);
|
||||
// 32 bytes (SHA-256 default)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The `kid` JWK member is conventionally set to the base64url-encoded
|
||||
SHA-256 thumbprint. Combine `jwkThumbprintBase64Url` with
|
||||
`serializeRsaPublicKeyJwk` to populate it on emit:
|
||||
|
||||
```ts
|
||||
import {
|
||||
jwkThumbprintBase64Url,
|
||||
serializeRsaPublicKeyJwk,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const baseJwk = serializeRsaPublicKeyJwk(publicKey);
|
||||
const jwkWithKid = {
|
||||
...baseJwk,
|
||||
kid: jwkThumbprintBase64Url(publicKey),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `jwkThumbprint(key, options?)`
|
||||
|
||||
- `key: RsaPublicKey | RsaJwk` — public key or JWK
|
||||
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
|
||||
- **returns** `Uint8Array` — digest bytes
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on bad hash;
|
||||
`CRYPTO_RSA.INVALID_KEY` on missing `e` or `n`
|
||||
|
||||
### `jwkThumbprintBase64Url(key, options?)`
|
||||
|
||||
Convenience: returns `toBase64Url(jwkThumbprint(key, options))`.
|
||||
|
||||
### `JwkThumbprintOptions`
|
||||
|
||||
- `hash?: RsaHashAlgorithm`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The canonical JSON byte string is `'{"e":"<b64u(e)>","kty":"RSA","n":"<b64u(n)>"}'`.
|
||||
Members are alphabetically ordered per RFC 7638 §3.2; only the three
|
||||
required members participate in the digest.
|
||||
- `JSON.stringify` is NOT used because member-order in object iteration
|
||||
is implementation-defined for non-integer keys; constructing the byte
|
||||
string by hand is the only correctness-stable approach.
|
||||
- When the input is a `RsaPublicKey` (canonical bigint form), the
|
||||
implementation routes through `serializeRsaPublicKeyJwk` to obtain
|
||||
the base64url members, then concatenates the canonical JSON shape.
|
||||
- Hash algorithm validation runs BEFORE member-validity checks so
|
||||
consumers see `UNSUPPORTED_HASH` on bad hashes regardless of key
|
||||
validity — useful for fail-fast configuration validators.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `keygen/exponent`
|
||||
|
||||
## Purpose
|
||||
|
||||
Public-exponent validation for RSA key generation. The `e`-only
|
||||
constraints (odd, `> 1`, `< 2^256`) live here so they can be
|
||||
asserted at the API boundary before the caller pays for prime
|
||||
sampling. Coprimality with `λ(n)` happens later in
|
||||
`generate.ts` once the primes are known.
|
||||
|
||||
## Features
|
||||
|
||||
- Single boundary check that maps every violation to
|
||||
`CRYPTO_RSA.INVALID_KEY` with an `e` context for diagnosis.
|
||||
- Auxiliary `gcd` helper exported so the higher-level keygen
|
||||
loop can verify `gcd(e, λ(n)) === 1` without duplicating the
|
||||
Euclidean algorithm.
|
||||
- Conforms to NIST FIPS 186-5 §5.4 (`2^16 < e < 2^256`); this
|
||||
module enforces the upper bound and `e > 1`. The lower bound
|
||||
`e ≥ 2^16 + 1` is profile-specific (FIPS-186-5 mode) and lives
|
||||
in the policy / profile layer.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validatePublicExponent } from "@suaveplan/crypto-rsa/keygen/exponent";
|
||||
|
||||
validatePublicExponent(65537n); // ok
|
||||
validatePublicExponent(3n); // ok — permissive profile
|
||||
validatePublicExponent(4n); // throws CRYPTO_RSA.INVALID_KEY
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Combine with `gcd` to cull candidate primes whose `(p-1)` shares
|
||||
a factor with `e`:
|
||||
|
||||
```ts
|
||||
import { gcd, validatePublicExponent } from "@suaveplan/crypto-rsa/keygen/exponent";
|
||||
|
||||
validatePublicExponent(65537n);
|
||||
const candidate = 0x...n; // some probable prime
|
||||
if (gcd(candidate - 1n, 65537n) !== 1n) {
|
||||
// discard, sample again
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validatePublicExponent(e: bigint): void`
|
||||
|
||||
Throws `CRYPTO_RSA.INVALID_KEY` on `e ≤ 1`, even `e`, or
|
||||
`e ≥ 2^256`. Returns `void` on success.
|
||||
|
||||
### `gcd(a: bigint, b: bigint): bigint`
|
||||
|
||||
Iterative Euclidean algorithm; works on any bigint pair, signs
|
||||
ignored.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `gcd` implementation is the standard iterative Euclidean
|
||||
loop rather than a recursive one to avoid V8 stack-frame
|
||||
overhead on very large bigint pairs (`λ(n)` for a 4096-bit
|
||||
modulus is roughly 4096 bits).
|
||||
- The `2^256` upper bound is hard-coded as a literal because it
|
||||
is a bright-line spec value; any future relaxation must
|
||||
modify this module deliberately, not via a config flag.
|
||||
@@ -0,0 +1,112 @@
|
||||
# `keygen/generate`
|
||||
|
||||
## Purpose
|
||||
|
||||
RSA key-pair generation per NIST FIPS 186-5 Appendix A.1 and
|
||||
SP 800-56B Rev. 2 §6.3. Two-prime by default; multi-prime
|
||||
(`primes ∈ {3, 4, 5}`) when callers explicitly opt in.
|
||||
|
||||
The algorithm samples each prime as a random odd `bits`-wide
|
||||
integer with the top two bits forced to `1` (so the product
|
||||
always reaches the target modulus length), checks coprimality
|
||||
with `e`, runs the Miller-Rabin probable-prime test from the
|
||||
sibling module, and assembles a canonical `RsaPrivateKey` with
|
||||
all CRT fields populated.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateRsaKeyPair(options)` returns a fully-formed
|
||||
`RsaPrivateKey` ready for any encrypt / decrypt / sign / verify
|
||||
surface in the package.
|
||||
- `AbortSignal` plumbed through the prime-search loop — yields
|
||||
via `await Promise.resolve()` every 64 candidates so an
|
||||
aborted signal scheduled via `queueMicrotask` lands within
|
||||
≤ 100 ms.
|
||||
- Deterministic mode — identical seeded `Rng` produces
|
||||
byte-identical keys. Used by tests and by reproducible-build
|
||||
pipelines.
|
||||
- Miller-Rabin exhaustion guard — caps each prime search at
|
||||
100 000 candidates and surfaces `CRYPTO_RSA.KEYGEN_FAILED` on
|
||||
hitting the cap (vanishingly rare in practice).
|
||||
- Multi-prime delegation — `primes !== 2` routes to the sibling
|
||||
`generateMultiPrimeKey` for the RFC 8017 §3 multi-prime path.
|
||||
- `crypto_rsa.keygen_duration_ms{modulus_bits,primes}` histogram.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
|
||||
|
||||
const key = await generateRsaKeyPair({ modulusBits: 2048 });
|
||||
// key has n, e, d, p, q, dp, dq, qInv all populated
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Cancel a slow generation on a timeout via `AbortSignal`:
|
||||
|
||||
```ts
|
||||
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 5000);
|
||||
try {
|
||||
const key = await generateRsaKeyPair({
|
||||
modulusBits: 4096,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === "CRYPTO_RSA.ABORTED") {
|
||||
// surfaced from the next yield point in the prime search
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Deterministic seeded generation for tests:
|
||||
|
||||
```ts
|
||||
const seedRng = makeSeededRng(0xdeadbeef);
|
||||
const k1 = await generateRsaKeyPair({
|
||||
modulusBits: 1024,
|
||||
rng: seedRng(),
|
||||
});
|
||||
const k2 = await generateRsaKeyPair({
|
||||
modulusBits: 1024,
|
||||
rng: seedRng(), // fresh from same seed
|
||||
});
|
||||
// k1 and k2 byte-identical
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generateRsaKeyPair(options?): Promise<RsaPrivateKey>`
|
||||
|
||||
- `options.modulusBits?: number` — defaults to 3072.
|
||||
- `options.publicExponent?: bigint` — defaults to `65537n`.
|
||||
- `options.primes?: number` — defaults to 2; accepts 3–5.
|
||||
- `options.rng?: Rng` — defaults to `defaultRng()`.
|
||||
- `options.signal?: AbortSignal` — optional cancellation.
|
||||
|
||||
### `samplePrime(bits, e, rng, signal): Promise<bigint>`
|
||||
|
||||
Building block exposed for tests and for the multi-prime path.
|
||||
Returns a probable prime with the top two bits set and coprime
|
||||
to `e`. Throws `CRYPTO_RSA.KEYGEN_FAILED` after 100 000
|
||||
candidates without success.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The top-two-bits-set rule guarantees that any two `bits`-wide
|
||||
primes `p, q` produce a `p * q` of exactly `2 * bits` bits.
|
||||
The post-multiplication length check is a paranoia guard that
|
||||
fails loudly on a pathological RNG draw.
|
||||
- The `bits` split on odd `modulusBits` is `(modulusBits + 1)/2`
|
||||
for `p` and `modulusBits/2` for `q` so the top prime carries
|
||||
the extra bit.
|
||||
- `λ(n) = lcm(p-1, q-1)` per RFC 8017's modern derivation; this
|
||||
produces a strictly smaller `d` than `φ(n) = (p-1)(q-1)`
|
||||
while remaining cryptographically equivalent.
|
||||
- `await Promise.resolve()` between candidate batches is the
|
||||
cheapest yield point in V8 — it lets the microtask queue drain
|
||||
(so a `queueMicrotask`-scheduled `signal.abort()` lands)
|
||||
without paying for `setTimeout(0)`.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `keygen/miller-rabin`
|
||||
|
||||
## Purpose
|
||||
|
||||
Probable-prime test per NIST FIPS 186-5 Appendix B.3.1, used by
|
||||
`generateRsaKeyPair` to qualify candidate primes during RSA key
|
||||
generation. The default 64 rounds achieve ≥ 128-bit security
|
||||
against composites for randomly-sampled candidates — the
|
||||
prevailing recommendation for RSA key generation in 2026.
|
||||
|
||||
## Features
|
||||
|
||||
- Trial division by the first 53 odd primes (≤ 251) before any
|
||||
modular exponentiation; eliminates ~80% of uniform random
|
||||
candidates on average.
|
||||
- Configurable round count — default 64. Lowering to 5 (e.g. for
|
||||
fast unit tests) trades probabilistic certainty for speed.
|
||||
- Uniform witness sampling via the injected `Rng` — works with
|
||||
both CSPRNG-backed `defaultRng()` and seeded deterministic
|
||||
test RNGs.
|
||||
- `uniformBigint` exported as a reusable building block for any
|
||||
caller that needs unbiased bigint sampling.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { isProbablePrime } from "@suaveplan/crypto-rsa/keygen/miller-rabin";
|
||||
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
|
||||
|
||||
console.log(isProbablePrime(2147483647n, defaultRng())); // true (Mersenne)
|
||||
console.log(isProbablePrime(561n, defaultRng())); // false (Carmichael)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Lower the round count for fast tests at the cost of negligible
|
||||
false-positive risk on Carmichael-like inputs:
|
||||
|
||||
```ts
|
||||
import { isProbablePrime } from "@suaveplan/crypto-rsa/keygen/miller-rabin";
|
||||
|
||||
const fast = (n: bigint, rng) => isProbablePrime(n, rng, 8);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isProbablePrime(n, rng, rounds = 64): boolean`
|
||||
|
||||
- `n: bigint` — candidate to test.
|
||||
- `rng: Rng` — uniform witness source.
|
||||
- `rounds: number` — default 64. Each round picks a fresh witness.
|
||||
- Returns `true` if probably prime, `false` if definitely composite.
|
||||
|
||||
### `uniformBigint(min, maxInclusive, rng): bigint`
|
||||
|
||||
Returns a uniform bigint in `[min, maxInclusive]` via byte-wise
|
||||
rejection sampling.
|
||||
|
||||
### `SMALL_PRIMES: ReadonlyArray<bigint>`
|
||||
|
||||
The first 53 odd primes through 251.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Miller-Rabin witness loop short-circuits on the first
|
||||
observed composite signature (non-trivial square root of 1)
|
||||
rather than running through every round.
|
||||
- The fallback modular-reduction path in `uniformBigint` only
|
||||
triggers after 1024 rejection attempts — for any reasonable
|
||||
range with the top-bit mask applied this never fires in
|
||||
practice. Documented for completeness; biased by at most one
|
||||
candidate's worth in the worst case.
|
||||
- `SMALL_PRIMES` is a literal array rather than a `Set` because
|
||||
the ordered scan benefits from monotonic comparison and the
|
||||
set is small enough to iterate twice in cache time.
|
||||
@@ -0,0 +1,95 @@
|
||||
# `keygen/multi-prime`
|
||||
|
||||
## Purpose
|
||||
|
||||
Multi-prime RSA key generation per RFC 8017 §3 and PKCS #1
|
||||
Appendix A.1.2 `OtherPrimeInfos` extension. Reachable via the
|
||||
public `generateRsaKeyPair({ primes: 3 | 4 | 5 })` entry point;
|
||||
this module is the algorithmic core.
|
||||
|
||||
Multi-prime RSA trades a small reduction in security per bit (the
|
||||
modulus must be slightly larger to retain the same factoring
|
||||
hardness) for a substantial speedup of the private operation —
|
||||
each prime factor is smaller, and CRT-accelerated decryption
|
||||
runs `primes` exponentiations of `bitLength / primes`-sized
|
||||
operands instead of two exponentiations of `bitLength / 2`.
|
||||
|
||||
## Features
|
||||
|
||||
- 3-, 4-, and 5-prime modes (PKCS #1 declares no upper bound but
|
||||
this module caps at 5 because beyond that the security margin
|
||||
per bit becomes uneconomical).
|
||||
- Reuses the two-prime `samplePrime` helper for each factor — same
|
||||
Miller-Rabin gate, same coprimality check, same `AbortSignal`
|
||||
threading.
|
||||
- Outputs a canonical `RsaPrivateKey` with the largest two primes
|
||||
in the standard `(p, q, dp, dq, qInv)` slots and the rest in
|
||||
the optional `otherPrimeInfos` field.
|
||||
- Round-trips through PKCS #1 (`serializeRsaPrivateKeyPkcs1` /
|
||||
`parseRsaPrivateKeyPkcs1`) — the serializer emits version 1
|
||||
with the full `OtherPrimeInfos` SEQUENCE OF when
|
||||
`otherPrimeInfos` is present.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
|
||||
|
||||
const key3 = await generateRsaKeyPair({
|
||||
modulusBits: 2048,
|
||||
primes: 3,
|
||||
});
|
||||
console.log(key3.otherPrimeInfos?.length); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Round-trip a 3-prime key through PKCS #1 DER:
|
||||
|
||||
```ts
|
||||
import {
|
||||
generateRsaKeyPair,
|
||||
parseRsaPrivateKeyPkcs1,
|
||||
serializeRsaPrivateKeyPkcs1,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const key = await generateRsaKeyPair({ modulusBits: 1536, primes: 3 });
|
||||
const der = serializeRsaPrivateKeyPkcs1(key);
|
||||
const restored = parseRsaPrivateKeyPkcs1(der);
|
||||
console.log(restored.otherPrimeInfos?.length); // 1
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generateMultiPrimeKey(options): Promise<RsaPrivateKey>`
|
||||
|
||||
Internal entry — public callers should use `generateRsaKeyPair`.
|
||||
|
||||
- `options.modulusBits: number`
|
||||
- `options.publicExponent: bigint`
|
||||
- `options.primes: number` — must be 3, 4, or 5.
|
||||
- `options.rng: Rng`
|
||||
- `options.signal: AbortSignal | undefined`
|
||||
|
||||
Throws `CRYPTO_RSA.PRIME_INVALID` on bad `primes`,
|
||||
`CRYPTO_RSA.KEYGEN_FAILED` on outer-loop exhaustion (32 attempts
|
||||
without producing a coprime modulus).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `(p, q)` selection picks the two largest primes so the
|
||||
standard CRT path operates on the bigger half of the modulus
|
||||
— this matches OpenSSL's multi-prime convention and mirrors
|
||||
what most PKCS #1 consumers expect when reading version 1
|
||||
keys.
|
||||
- The `otherPrimeInfos[i].t` coefficient is
|
||||
`(r_1 * r_2 * ... * r_(i-1))^-1 mod r_i` per RFC 8017 §3 — we
|
||||
maintain `runningProduct` rather than recomputing each step.
|
||||
- Outer loop bails after `MAX_OUTER_ATTEMPTS = 32` attempts;
|
||||
in practice every well-formed `(modulusBits, primes)` pair
|
||||
succeeds on the first attempt because the small-prime trial
|
||||
division in `isProbablePrime` already culls most non-coprime
|
||||
candidates upstream.
|
||||
- `lcm(a, b) = (a / gcd(a, b)) * b` — the reverse `(a * b) / gcd`
|
||||
formulation overflows V8's bigint scratch for `λ(n)` with
|
||||
4096-bit moduli; this rearrangement avoids it.
|
||||
@@ -0,0 +1,93 @@
|
||||
# keys/jwk-policy
|
||||
|
||||
## Purpose
|
||||
|
||||
Cross-check a JWK's `key_ops`, `use`, and `alg` members against the
|
||||
requested operation per RFC 7517 §4.2 / §4.3 / §4.4 + RFC 7518 §6.3.
|
||||
Prevents the common operational mistake of using a JWK marked
|
||||
`use: "sig"` for encryption (or vice-versa), which leaks both the key
|
||||
purpose and the operator intent into the wrong code path.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates `key_ops` ⊃ requested operation (with the standard
|
||||
encrypt → wrapKey, decrypt → unwrapKey aliases)
|
||||
- Validates `use === "sig"` permits only sign/verify and `use === "enc"`
|
||||
permits only encrypt/decrypt
|
||||
- Validates `alg` is one of the RSA-compatible JWA registry values
|
||||
(`RS{256,384,512}`, `PS{256,384,512}`, `RSA-OAEP{,−256,−384,−512}`,
|
||||
`RSA1_5`) AND is appropriate for the requested operation class
|
||||
- Each violation throws `CRYPTO_RSA.KEY_OP_NOT_PERMITTED` with a
|
||||
`reason` attribute (`"key_ops"` / `"use"` / `"alg"`) plus context
|
||||
identifying the offending member
|
||||
- Skips checks when the JWK omits the corresponding member (RFC 7517
|
||||
semantics: absence ≠ denial)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { enforceJwkPolicy } from "@suaveplan/crypto-rsa/keys/jwk-policy";
|
||||
|
||||
const jwk = {
|
||||
kty: "RSA",
|
||||
n: "...",
|
||||
e: "AQAB",
|
||||
use: "sig",
|
||||
alg: "RS256",
|
||||
key_ops: ["sign"],
|
||||
};
|
||||
enforceJwkPolicy(jwk, "sign"); // no-op
|
||||
enforceJwkPolicy(jwk, "encrypt"); // throws KEY_OP_NOT_PERMITTED
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire policy enforcement into a higher-level signer that accepts both
|
||||
canonical `RsaPrivateKey` and JWK inputs; only run the policy check on
|
||||
the JWK path because canonical keys carry no policy fields:
|
||||
|
||||
```ts
|
||||
import { enforceJwkPolicy, parseRsaPrivateKey } from "@suaveplan/crypto-rsa";
|
||||
|
||||
function loadSigningKey(input: RsaJwk | Uint8Array): RsaPrivateKey {
|
||||
if (typeof input === "object" && "kty" in input) {
|
||||
enforceJwkPolicy(input, "sign");
|
||||
return parseRsaPrivateKey(input, { format: "jwk" });
|
||||
}
|
||||
return parseRsaPrivateKey(input, { format: "pkcs8" });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `enforceJwkPolicy(jwk, operation): void`
|
||||
|
||||
- `jwk: RsaJwk`
|
||||
- `operation: "sign" | "verify" | "encrypt" | "decrypt"`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.KEY_OP_NOT_PERMITTED)` on any
|
||||
cross-policy violation; the error context carries `reason` (which
|
||||
member triggered the rejection) plus the member value
|
||||
|
||||
### `JwkOperation`
|
||||
|
||||
`"sign" | "verify" | "encrypt" | "decrypt"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The canonical `RsaPublicKey` / `RsaPrivateKey` shapes have no
|
||||
`key_ops` / `use` / `alg` fields, so direct callers using the
|
||||
canonical types bypass policy enforcement entirely. Wrap the policy
|
||||
check at the JWK ingestion boundary if you need it; the package's
|
||||
built-in sign / encrypt surfaces accept canonical keys precisely so
|
||||
they don't impose JWK-specific semantics.
|
||||
- The encrypt → wrapKey / decrypt → unwrapKey aliases follow RFC 7517
|
||||
§4.3 (`key_ops` value definitions); a JWK marked `key_ops: ["wrapKey"]`
|
||||
is permitted to encrypt because the cryptographic operation is the
|
||||
same (RSA public encryption).
|
||||
- When `use` and `alg` both appear they MUST be consistent (RFC 7517
|
||||
§4.4); this implementation does not cross-check them against each
|
||||
other because the JWA registry ties algorithm IDs to a single use,
|
||||
so the per-member checks already cover the cross-product.
|
||||
- `key_ops` and `use` together are RFC-permitted but discouraged
|
||||
(RFC 7517 §4.3 last paragraph); both checks fire independently when
|
||||
both are present.
|
||||
@@ -0,0 +1,53 @@
|
||||
# `keys/oid`
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralised OID constants for the RSA wire formats handled by this package.
|
||||
Currently exports `OID_RSA_ENCRYPTION` (`1.2.840.113549.1.1.1`), the algorithm
|
||||
identifier RFC 8017 uses for `rsaEncryption` inside `SubjectPublicKeyInfo`
|
||||
and `PrivateKeyInfo` `AlgorithmIdentifier` slots. Future stages may extend
|
||||
this file with the signature-OID family (`sha256WithRSAEncryption` etc.) once
|
||||
the sign/verify modules need them; this Stage-1 file covers the universal
|
||||
key-format OID alone.
|
||||
|
||||
## Features
|
||||
|
||||
- One definition per OID — eliminates copy-paste drift between parsers.
|
||||
- String-typed OIDs (`"1.2.840.113549.1.1.1"`) match the dotted-decimal form
|
||||
produced by `DerReader.readObjectIdentifier`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { OID_RSA_ENCRYPTION } from "@suaveplan/crypto-rsa";
|
||||
|
||||
if (oid === OID_RSA_ENCRYPTION) {
|
||||
// dispatch to RSA path
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { OID_RSA_ENCRYPTION } from "@suaveplan/crypto-rsa";
|
||||
import { encodeNull, encodeObjectIdentifier, encodeSequence } from "@suaveplan/crypto-rsa/asn1";
|
||||
|
||||
const algorithmIdentifier = encodeSequence(
|
||||
encodeObjectIdentifier(OID_RSA_ENCRYPTION),
|
||||
encodeNull()
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `OID_RSA_ENCRYPTION: "1.2.840.113549.1.1.1"`
|
||||
|
||||
The PKCS #1 `rsaEncryption` algorithm identifier per RFC 3279 §2.3.1, RFC 5280
|
||||
Appendix A, and RFC 8017 Appendix A.1.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constant is exposed as a string rather than a typed brand because
|
||||
`DerReader.readObjectIdentifier` returns plain strings, and direct equality
|
||||
checks remain trivially constant-time at the byte level. A branded type would
|
||||
add complexity without security benefit at this layer.
|
||||
@@ -0,0 +1,80 @@
|
||||
# `keys/parse-jwk`
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses RSA JWKs per RFC 7517 §6.3.1 (public) and §6.3.2 (private). Accepts
|
||||
the JWK as a plain JS object literal, as a JSON string, or as a `Uint8Array`
|
||||
of UTF-8 JSON bytes — all common ways the format is carried in the wild
|
||||
(headers vs. bodies vs. binary protocol fields).
|
||||
|
||||
JWK is the JOSE / OIDC / WebAuthn canonical RSA representation and the
|
||||
required input format for `@suaveplan/jws`'s RSA algorithms (`RS256`,
|
||||
`PS256`, etc.).
|
||||
|
||||
## Features
|
||||
|
||||
- Triple-form input (`RsaJwk` object / JSON string / UTF-8 bytes).
|
||||
- Explicit `kty: "RSA"` enforcement (`UNSUPPORTED_FORMAT` otherwise).
|
||||
- Base64url decode per RFC 7515 §2 (URL-safe alphabet, no padding) via
|
||||
`@suaveplan/crypto-utils`.
|
||||
- Private-form requires every CRT field (`d`, `p`, `q`, `dp`, `dq`, `qi`) —
|
||||
partial private JWKs reject with `INVALID_KEY`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPublicKeyJwk } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const key = parseRsaPublicKeyJwk({
|
||||
kty: "RSA",
|
||||
n: "0vx7agoeb...",
|
||||
e: "AQAB",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRsaPrivateKeyJwk,
|
||||
parseRsaPublicKeyJwk,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const fromJson = parseRsaPublicKeyJwk(jsonString);
|
||||
const fromBytes = parseRsaPublicKeyJwk(utf8Bytes);
|
||||
|
||||
const privateKey = parseRsaPrivateKeyJwk({
|
||||
kty: "RSA",
|
||||
n, e, d, p, q, dp, dq, qi,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRsaPublicKeyJwk(input: RsaJwk | string | Uint8Array): RsaPublicKey`
|
||||
|
||||
Decodes the JWK, validates `kty === "RSA"`, and decodes `n` + `e`.
|
||||
|
||||
### `parseRsaPrivateKeyJwk(input: RsaJwk | string | Uint8Array): RsaPrivateKey`
|
||||
|
||||
Same as above plus the CRT parameters. Every field is mandatory.
|
||||
|
||||
Both throw `CRYPTO_RSA.UNSUPPORTED_FORMAT` for non-`RSA` `kty`, and
|
||||
`CRYPTO_RSA.INVALID_KEY` for missing members, empty values, or invalid
|
||||
base64url.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The JWK input may be an `RsaJwk` literal (already-typed), a JSON string
|
||||
(common in headers), or a UTF-8 `Uint8Array` (common in network framing).
|
||||
Internally we normalise to a plain object before extracting members so
|
||||
the validation logic stays one path.
|
||||
|
||||
Base64url decoding goes through `@suaveplan/crypto-utils`'s `fromBase64Url`
|
||||
which accepts both padded and unpadded inputs; JWA mandates unpadded, but
|
||||
some implementations leak padding in the wild and we accept that gracefully.
|
||||
|
||||
The JWK private-key `qi` member is RFC 7517's name for what RFC 8017 calls
|
||||
`qInv` (the CRT coefficient `q⁻¹ mod p`). The canonical `RsaPrivateKey`
|
||||
shape uses `qInv` for consistency with RFC 8017, so we translate `qi → qInv`
|
||||
in the parser.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `keys/parse-pkcs1-private`
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses a DER-encoded `RSAPrivateKey` per RFC 8017 Appendix A.1.2 into the
|
||||
canonical `RsaPrivateKey` shape with mandatory CRT parameters
|
||||
(`p`, `q`, `dp`, `dq`, `qInv`). Also exports `finalisePrivateKey` so the
|
||||
PKCS#8 and JWK parsers share the same field-positivity validation.
|
||||
|
||||
Multi-prime RSA (`version == 1` with non-empty `otherPrimeInfos`) is recognised
|
||||
but explicitly rejected with `CRYPTO_RSA.UNSUPPORTED_FORMAT` — the wider
|
||||
package contract assumes two-prime RSA throughout.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-DER `RSAPrivateKey` parser via `DerReader`.
|
||||
- Version validation (`0` two-prime, `1` multi-prime — both detected; only
|
||||
`0` accepted).
|
||||
- Multi-prime detection via either `version === 1` OR a trailing SEQUENCE
|
||||
(covers conformant and non-conformant emitters).
|
||||
- Per-field positivity gate via `finalisePrivateKey`.
|
||||
- Bit-length derivation from `n.toString(2).length`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPrivateKeyPkcs1 } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const key = parseRsaPrivateKeyPkcs1(derBytes);
|
||||
// key.n, key.e, key.d, key.p, key.q, key.dp, key.dq, key.qInv, key.bitLength
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { finalisePrivateKey } from "@suaveplan/crypto-rsa/keys";
|
||||
|
||||
const key = finalisePrivateKey({ n, e, d, p, q, dp, dq, qInv });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRsaPrivateKeyPkcs1(bytes: Uint8Array): RsaPrivateKey`
|
||||
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on structural problems and
|
||||
on any non-positive field. Throws `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_FORMAT)`
|
||||
on multi-prime input.
|
||||
|
||||
### `finalisePrivateKey(inputs): RsaPrivateKey`
|
||||
|
||||
Validates each of `n, e, d, p, q, dp, dq, qInv` is `> 0n` and computes
|
||||
`bitLength` from `n`. Surfaces the same `INVALID_KEY` error class as the
|
||||
DER parser.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The parser uses both `version === 1n` and a trailing-SEQUENCE peek to detect
|
||||
multi-prime keys. Some emitters set `version` to `0` even when they include
|
||||
`otherPrimeInfos`; we reject either flag to avoid silently truncating the
|
||||
prime set.
|
||||
|
||||
The fields are read in fixed order with `seq.readInteger()` calls; if any
|
||||
field is missing the underlying `DerReader` raises `INVALID_KEY` with a
|
||||
truncation context. The final `seq.assertEnd()` ensures no extra fields
|
||||
slip through (other than the multi-prime trailing SEQUENCE that we explicitly
|
||||
detected and rejected).
|
||||
@@ -0,0 +1,60 @@
|
||||
# `keys/parse-pkcs1-public`
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses a DER-encoded `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }`
|
||||
per RFC 8017 Appendix A.1.1 into the canonical `RsaPublicKey` shape used throughout
|
||||
`@suaveplan/crypto-rsa`. Also exports `finalisePublicKey(n, e)` so the SPKI and JWK
|
||||
parsers can share the same `(n, e)` validation and `bitLength` computation without
|
||||
re-running DER parsing.
|
||||
|
||||
The parser rejects `e < 3` and even public exponents at this layer because every
|
||||
downstream operation (verify, encrypt) assumes those constraints; rejecting at parse
|
||||
time means the operations never have to defensively re-check.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-DER `RSAPublicKey` parser via `DerReader`.
|
||||
- Public-exponent validation: rejects `e < 3` and even `e`.
|
||||
- Bit-length derivation from `n.toString(2).length` (no off-by-one risk because
|
||||
the leading `1` of any positive bigint is always present).
|
||||
- `finalisePublicKey(n, e)` exported for sibling parsers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPublicKeyPkcs1 } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const key = parseRsaPublicKeyPkcs1(derBytes);
|
||||
// key.n, key.e, key.bitLength
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { finalisePublicKey } from "@suaveplan/crypto-rsa/keys";
|
||||
|
||||
// SPKI / JWK parsers share the validation logic.
|
||||
const key = finalisePublicKey(n, e);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRsaPublicKeyPkcs1(bytes: Uint8Array): RsaPublicKey`
|
||||
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on any structural problem
|
||||
(malformed DER, missing fields, trailing data, even or sub-3 exponent,
|
||||
non-positive modulus).
|
||||
|
||||
### `finalisePublicKey(n: bigint, e: bigint): RsaPublicKey`
|
||||
|
||||
Validates `(n, e)` and computes `bitLength`. Surfaces the same `INVALID_KEY`
|
||||
errors as `parseRsaPublicKeyPkcs1`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bitLength` derives from the binary representation of `n`. For a 2048-bit
|
||||
key the modulus has bit length exactly 2048; the leading bit of `n` is
|
||||
always `1` because the underlying primes are sampled with the high bit
|
||||
fixed. Consumers that need strict round-number assertions should compare
|
||||
against the canonical {2048, 3072, 4096} set in their own gate.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `keys/parse-pkcs8`
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses a PKCS#8 `PrivateKeyInfo` per RFC 5208 §5 — also accepts the
|
||||
RFC 5958 `OneAsymmetricKey` shape, which adds optional `[0] attributes`
|
||||
and `[1] publicKey` trailing fields. The inner `privateKey OCTET STRING`
|
||||
carries a PKCS#1 `RSAPrivateKey` which is delegated to the PKCS#1 parser.
|
||||
|
||||
PKCS#8 is the canonical wire format for any private key persisted to
|
||||
disk (OpenSSL `PRIVATE KEY` PEM, PKCS#12 / PFX `EncryptedData` payloads
|
||||
after decryption, JWE `kty: RSA` private-form decryption keys).
|
||||
|
||||
## Features
|
||||
|
||||
- DER outer SEQUENCE → version → AlgorithmIdentifier → OCTET STRING walk.
|
||||
- Algorithm OID equality against `OID_RSA_ENCRYPTION`.
|
||||
- RFC 5958 `[0] attributes` and `[1] publicKey` tolerant — both consumed
|
||||
and discarded.
|
||||
- Delegates to `parseRsaPrivateKeyPkcs1` for the inner RSAPrivateKey body.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPrivateKeyPkcs8 } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const key = parseRsaPrivateKeyPkcs8(pkcs8DerBytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPrivateKeyPkcs8 } from "@suaveplan/crypto-rsa";
|
||||
|
||||
// RFC 5958 OneAsymmetricKey form with optional trailing publicKey [1].
|
||||
const key = parseRsaPrivateKeyPkcs8(oneAsymmetricKeyBytes);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRsaPrivateKeyPkcs8(bytes: Uint8Array): RsaPrivateKey`
|
||||
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_FORMAT)` for non-RSA OIDs.
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on structural problems
|
||||
(unsupported `version`, missing fields, trailing non-context-specific bytes).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
PKCS#8 `version` is `0` for RFC 5208 and `1` for the RFC 5958 successor
|
||||
(the version bumps when `publicKey` is present). Both are accepted; the
|
||||
RFC 5958 trailing fields are walked through as opaque context-specific
|
||||
TLVs without attempting to parse them, because Stage 1 has no need for
|
||||
the public-key cross-check or the attribute set. Stage 4 may add a
|
||||
verification that the `[1] publicKey` matches the inner PKCS#1
|
||||
`(n, e)`.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `keys/parse-spki`
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses an X.509 `SubjectPublicKeyInfo` per RFC 5280 §4.1 + Appendix A and
|
||||
unwraps the inner PKCS#1 `RSAPublicKey`. SPKI is the canonical wire format
|
||||
for RSA public keys in TLS certificates (`Certificate.tbsCertificate.subjectPublicKeyInfo`),
|
||||
JWK headers (`x5c`/`x5t#S256` reference chains), and the WebAuthn TPM
|
||||
attestation statement.
|
||||
|
||||
The parser enforces the `1.2.840.113549.1.1.1` `rsaEncryption` algorithm OID
|
||||
and the canonical `NULL` algorithm parameters per RFC 3279 §2.3.1.
|
||||
|
||||
## Features
|
||||
|
||||
- DER outer-SEQUENCE → AlgorithmIdentifier → BIT STRING walk.
|
||||
- Algorithm OID equality check against `OID_RSA_ENCRYPTION`.
|
||||
- `NULL` algorithm-parameters enforcement (RFC 3279 §2.3.1).
|
||||
- Delegates inner PKCS#1 parse to `parseRsaPublicKeyPkcs1`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPublicKeySpki } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const key = parseRsaPublicKeySpki(spkiDerBytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPublicKeySpki } from "@suaveplan/crypto-rsa";
|
||||
|
||||
// SPKI carrying an EC OID rejects with UNSUPPORTED_FORMAT.
|
||||
try {
|
||||
parseRsaPublicKeySpki(ecSpkiBytes);
|
||||
} catch (error) {
|
||||
if (error instanceof CryptoRsaError && error.code === "CRYPTO_RSA.UNSUPPORTED_FORMAT") {
|
||||
// dispatch to @suaveplan/crypto-curve
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRsaPublicKeySpki(bytes: Uint8Array): RsaPublicKey`
|
||||
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_FORMAT)` for non-RSA OIDs.
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on structural problems
|
||||
(missing fields, trailing data, unused-bits ≠ 0 in the `BIT STRING`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `BIT STRING` payload is copied (defensive copy in `DerReader.readBitString`)
|
||||
so the inner PKCS#1 reader never aliases the input buffer. This costs one
|
||||
allocation per parse but eliminates a class of subtle bugs where downstream
|
||||
mutation of the inner buffer would corrupt the SPKI input.
|
||||
|
||||
`alg.assertEnd()` and `outer.assertEnd()` together guarantee no extra fields
|
||||
slipped through. RFC 3279 forbids extra parameters in the `rsaEncryption`
|
||||
algorithm identifier, and RFC 5280 forbids extra fields in `SubjectPublicKeyInfo`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `keys/parse`
|
||||
|
||||
## Purpose
|
||||
|
||||
Public dispatcher entry-point unifying the four key-format parsers
|
||||
(PKCS#1, SPKI, PKCS#8, JWK) under one ergonomic call. Selects the
|
||||
underlying parser via a `format` discriminator on the options object,
|
||||
matching the round-trip pattern used elsewhere in the crypto
|
||||
family.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseRsaPublicKey(input, { format: "pkcs1" | "spki" | "jwk" })`.
|
||||
- `parseRsaPrivateKey(input, { format: "pkcs1" | "pkcs8" | "jwk" })`.
|
||||
- Binary formats accept `Uint8Array`; JWK accepts object / JSON string /
|
||||
UTF-8 bytes.
|
||||
- `default` branches surface `CRYPTO_RSA.UNSUPPORTED_FORMAT` so any future
|
||||
format added to the type union without a switch arm fails loudly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPublicKey } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const key = parseRsaPublicKey(spkiBytes, { format: "spki" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRsaPrivateKey,
|
||||
parseRsaPublicKey,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const fromPkcs8 = parseRsaPrivateKey(pkcs8Bytes, { format: "pkcs8" });
|
||||
const fromJwk = parseRsaPrivateKey(jwkObject, { format: "jwk" });
|
||||
const fromJsonJwk = parseRsaPrivateKey(jwkJsonString, { format: "jwk" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRsaPublicKey(input, options): RsaPublicKey`
|
||||
|
||||
Throws `CRYPTO_RSA.INVALID_KEY` when a non-`Uint8Array` is passed for the
|
||||
binary formats.
|
||||
|
||||
### `parseRsaPrivateKey(input, options): RsaPrivateKey`
|
||||
|
||||
Same input/error contract on the private side.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dispatcher is exhaustive over the literal union via `assertNeverFormat`,
|
||||
which TypeScript verifies statically. Adding a new format requires both a
|
||||
new type member and a new switch arm — the compiler enforces both. This is
|
||||
the only safe way to evolve a discriminated dispatcher without silently
|
||||
falling through to a default branch.
|
||||
|
||||
The `requireBytes` helper is shared between the PKCS#1/SPKI/PKCS#8 paths
|
||||
because they all consume `Uint8Array`. JWK input is forwarded through the
|
||||
JWK parser which handles its own normalisation.
|
||||
@@ -0,0 +1,54 @@
|
||||
# `keys/serialize-jwk`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inverse of `parseRsaPublicKeyJwk` and `parseRsaPrivateKeyJwk`. Emits an
|
||||
`RsaJwk` with every numeric field encoded as base64url per RFC 7515 §2
|
||||
(URL-safe alphabet, no padding) over the minimum-length big-endian
|
||||
representation of each `bigint` per RFC 7518 §6.3.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-field base64url encoding via `@suaveplan/crypto-utils`'s `toBase64Url`.
|
||||
- Minimum-length big-endian byte derivation — no leading-zero padding.
|
||||
- Public-form emits `{ kty, n, e }` only; private-form adds the full CRT set.
|
||||
- Maps the canonical `qInv` field name to JWA's `qi` per RFC 7517 §6.3.2.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPublicKeyJwk } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const jwk = serializeRsaPublicKeyJwk(key);
|
||||
// { kty: "RSA", n: "...", e: "AQAB" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRsaPrivateKeyJwk,
|
||||
serializeRsaPrivateKeyJwk,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const jwk = serializeRsaPrivateKeyJwk(key);
|
||||
const roundTripped = parseRsaPrivateKeyJwk(jwk);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeRsaPublicKeyJwk(key: RsaPublicKey): RsaJwk`
|
||||
|
||||
Returns `{ kty: "RSA", n, e }`.
|
||||
|
||||
### `serializeRsaPrivateKeyJwk(key: RsaPrivateKey): RsaJwk`
|
||||
|
||||
Returns `{ kty: "RSA", n, e, d, p, q, dp, dq, qi }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The minimum-length encoding implies that `e === 65537n` (the universal
|
||||
default RSA exponent) emits as the 3-byte sequence `0x010001`, which
|
||||
base64url-encodes to `"AQAB"` — the same canonical string every JWK
|
||||
ecosystem produces. Encoding via i2osp with the byte length derived from
|
||||
`hex.length / 2` ensures we never accidentally emit a leading-zero byte.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `keys/serialize-pkcs1-private`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inverse of `parseRsaPrivateKeyPkcs1`. Emits a DER `RSAPrivateKey` SEQUENCE
|
||||
per RFC 8017 Appendix A.1.2 from a canonical `RsaPrivateKey`. Always emits
|
||||
two-prime form with `version 0`. Multi-prime keys cannot be constructed
|
||||
through this package's parsers (the parser rejects them) so the serialiser
|
||||
also never has to emit the multi-prime branch.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-DER nine-INTEGER SEQUENCE emission in canonical field order.
|
||||
- Always emits `version 0` (two-prime).
|
||||
- Pure function — no validation overhead.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPrivateKeyPkcs1 } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const der = serializeRsaPrivateKeyPkcs1(key);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRsaPrivateKeyPkcs1,
|
||||
serializeRsaPrivateKeyPkcs1,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const roundTripped = parseRsaPrivateKeyPkcs1(
|
||||
serializeRsaPrivateKeyPkcs1(key)
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeRsaPrivateKeyPkcs1(key: RsaPrivateKey): Uint8Array`
|
||||
|
||||
Returns the canonical DER encoding.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The field order matches RFC 8017 Appendix A.1.2 exactly: `version`,
|
||||
`modulus`, `publicExponent`, `privateExponent`, `prime1`, `prime2`,
|
||||
`exponent1`, `exponent2`, `coefficient`. Any reorder would still parse
|
||||
under a tolerant reader but would fail strict-DER validation.
|
||||
|
||||
`version` is encoded as `0n` rather than `0` (a `bigint`) because
|
||||
`encodeInteger` is monomorphised over `bigint` for the two-prime case.
|
||||
@@ -0,0 +1,53 @@
|
||||
# `keys/serialize-pkcs1-public`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inverse of `parseRsaPublicKeyPkcs1`. Emits a DER `RSAPublicKey` SEQUENCE
|
||||
per RFC 8017 Appendix A.1.1 from a canonical `RsaPublicKey`. The output
|
||||
is byte-identical for any input that previously round-tripped through
|
||||
the parser, and is the canonical body wrapped by SPKI public-key
|
||||
emission and PEM `-----BEGIN RSA PUBLIC KEY-----` framing.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-call DER emission via `encodeSequence(encodeInteger, encodeInteger)`.
|
||||
- Strict-DER output (minimal length, minimal integer encoding).
|
||||
- Pure function — no allocation beyond the byte buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPublicKeyPkcs1 } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const der = serializeRsaPublicKeyPkcs1(key);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRsaPublicKeyPkcs1,
|
||||
serializeRsaPublicKeyPkcs1,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
// Round-trip invariant.
|
||||
const roundTripped = parseRsaPublicKeyPkcs1(
|
||||
serializeRsaPublicKeyPkcs1(key)
|
||||
);
|
||||
// roundTripped equals key.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeRsaPublicKeyPkcs1(key: RsaPublicKey): Uint8Array`
|
||||
|
||||
Returns the canonical DER encoding. Never throws on a structurally valid
|
||||
`RsaPublicKey`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The serialiser does not re-validate `(n, e)`; that is the parser's job.
|
||||
Callers that reach this path have either constructed the key locally
|
||||
(in which case validation is the constructor's job) or routed through
|
||||
the parser (already validated). Adding a redundant check here would
|
||||
double the cost without adding any safety guarantee.
|
||||
@@ -0,0 +1,49 @@
|
||||
# `keys/serialize-pkcs8`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inverse of `parseRsaPrivateKeyPkcs8`. Wraps a serialised PKCS#1
|
||||
`RSAPrivateKey` in a DER `PrivateKeyInfo` per RFC 5208 §5, emitting the
|
||||
canonical `version 0` form (RFC 5958 `version 1` requires the optional
|
||||
`publicKey [1]` field which Stage 1 does not emit).
|
||||
|
||||
## Features
|
||||
|
||||
- Standard PKCS#8 emission with `version 0`.
|
||||
- Reuses `serializeRsaPrivateKeyPkcs1` so the inner OCTET STRING body
|
||||
matches the PKCS#1 output bit-for-bit.
|
||||
- RFC 3279 §2.3.1 conformant `NULL` algorithm parameters.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPrivateKeyPkcs8 } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const pkcs8 = serializeRsaPrivateKeyPkcs8(key);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRsaPrivateKeyPkcs8,
|
||||
serializeRsaPrivateKeyPkcs8,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const roundTripped = parseRsaPrivateKeyPkcs8(
|
||||
serializeRsaPrivateKeyPkcs8(key)
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeRsaPrivateKeyPkcs8(key: RsaPrivateKey): Uint8Array`
|
||||
|
||||
Returns the DER-encoded `PrivateKeyInfo`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
We emit `version 0` (RFC 5208) rather than `version 1` (RFC 5958) because
|
||||
Stage 1 does not have a public-key derivation pipeline yet. Stage 4 may
|
||||
add a `version 1` emission path that includes the `[1] publicKey` field
|
||||
for ecosystems (cosign, sigstore) that prefer the OneAsymmetricKey form.
|
||||
@@ -0,0 +1,47 @@
|
||||
# `keys/serialize-spki`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inverse of `parseRsaPublicKeySpki`. Wraps a serialised PKCS#1 `RSAPublicKey`
|
||||
in a DER `SubjectPublicKeyInfo` per RFC 5280 §4.1, with the canonical
|
||||
`AlgorithmIdentifier { algorithm = 1.2.840.113549.1.1.1, parameters = NULL }`
|
||||
inner SEQUENCE.
|
||||
|
||||
## Features
|
||||
|
||||
- Standard X.509 SPKI emission.
|
||||
- RFC 3279 §2.3.1 conformant `NULL` algorithm parameters.
|
||||
- Reuses `serializeRsaPublicKeyPkcs1` so the inner body matches the PKCS#1
|
||||
output bit-for-bit.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPublicKeySpki } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const spki = serializeRsaPublicKeySpki(key);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPublicKeySpki } from "@suaveplan/crypto-rsa";
|
||||
|
||||
// Compute the SPKI fingerprint downstream consumers expect for cert pinning.
|
||||
const spki = serializeRsaPublicKeySpki(key);
|
||||
const fingerprint = await crypto.subtle.digest("SHA-256", spki);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeRsaPublicKeySpki(key: RsaPublicKey): Uint8Array`
|
||||
|
||||
Returns the DER-encoded `SubjectPublicKeyInfo`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The output is the canonical RFC 5280 SPKI; many PKI libraries accept variants
|
||||
with implicit-NULL parameters (omitted entirely) but emit the explicit-NULL
|
||||
form, so we follow the de-facto majority. The PKCS#1 body inside the
|
||||
`BIT STRING` is wrapped with one leading `0x00` "unused bits" byte per
|
||||
RFC 5280 §4.1 (handled by `encodeBitString`).
|
||||
@@ -0,0 +1,52 @@
|
||||
# `keys/serialize`
|
||||
|
||||
## Purpose
|
||||
|
||||
Public dispatcher entry-point inverse of `parse`. Selects the per-format
|
||||
serialiser via the same `format` discriminator. Returns `Uint8Array` for
|
||||
binary formats (`pkcs1`, `spki`, `pkcs8`) and a structured `RsaJwk` object
|
||||
for JWK so consumers can post-process the result without re-parsing JSON.
|
||||
|
||||
## Features
|
||||
|
||||
- `serializeRsaPublicKey(key, { format })` over the PKCS#1 / SPKI / JWK set.
|
||||
- `serializeRsaPrivateKey(key, { format })` over the PKCS#1 / PKCS#8 / JWK set.
|
||||
- `default` branch surfaces `CRYPTO_RSA.UNSUPPORTED_FORMAT` for safety.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPublicKey } from "@suaveplan/crypto-rsa";
|
||||
|
||||
const der = serializeRsaPublicKey(key, { format: "spki" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
serializeRsaPrivateKey,
|
||||
serializeRsaPublicKey,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const jwk = serializeRsaPublicKey(key, { format: "jwk" });
|
||||
const pkcs8 = serializeRsaPrivateKey(key, { format: "pkcs8" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeRsaPublicKey(key, options): Uint8Array | RsaJwk`
|
||||
|
||||
Returns bytes for `pkcs1` / `spki`, a structured `RsaJwk` for `jwk`.
|
||||
|
||||
### `serializeRsaPrivateKey(key, options): Uint8Array | RsaJwk`
|
||||
|
||||
Returns bytes for `pkcs1` / `pkcs8`, a structured `RsaJwk` for `jwk`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The mixed `Uint8Array | RsaJwk` return type intentionally pushes the
|
||||
discrimination back to the caller — picking `format: "jwk"` is a deliberate
|
||||
choice and the caller already knows the result will be an object. Type
|
||||
narrowing on the `format` literal is left to consumer-side discriminated
|
||||
union helpers because Stage 1 deliberately keeps the dispatcher minimal.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `math/crt`
|
||||
|
||||
## Purpose
|
||||
|
||||
RSA private-key operation via the Chinese Remainder Theorem (RFC 8017 §5.1.2,
|
||||
second-form algorithm). Computes `m = c^d mod n` using the CRT parameters
|
||||
`p, q, dp, dq, qInv` already stored on the canonical `RsaPrivateKey` shape, so
|
||||
the working modulus during exponentiation is half the size of `n`.
|
||||
|
||||
Empirically the CRT path is 3-4× faster than the naive `c^d mod n` route on
|
||||
2048-bit keys; the speed-up grows quadratically with modulus size. Stage 1
|
||||
ships the algorithm; Stages 2-3 wire it into `rsaSign*` and `rsaDecrypt*`
|
||||
once MGF1 + EMSA encoding modules land.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure-function implementation — no allocation beyond `bigint` intermediates.
|
||||
- Reuses `modPow` so the banned-pattern grep gate stays clean.
|
||||
- Negative-h fix-up (`h += p` when negative) is constant-form in TypeScript so
|
||||
the JIT does not branch-predict away the canonicalisation step.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaPrivateOperationCrt } from "@suaveplan/crypto-rsa/math";
|
||||
|
||||
const m = rsaPrivateOperationCrt(c, privateKey);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
i2osp,
|
||||
os2ip,
|
||||
rsaPrivateOperationCrt,
|
||||
} from "@suaveplan/crypto-rsa/math";
|
||||
|
||||
function decryptCiphertextBytes(
|
||||
ciphertext: Uint8Array,
|
||||
key: RsaPrivateKey
|
||||
): Uint8Array {
|
||||
const c = os2ip(ciphertext);
|
||||
const m = rsaPrivateOperationCrt(c, key);
|
||||
return i2osp(m, ciphertext.length);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaPrivateOperationCrt(c: bigint, key: RsaPrivateKey): bigint`
|
||||
|
||||
Returns the message representative `m ∈ [0, n)` such that `m^e mod n === c`.
|
||||
Requires `c < n` (caller's responsibility — the CRT formulation does not
|
||||
verify the bound).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The second-form algorithm (RFC 8017 §5.1.2) is preferred over the first-form
|
||||
because it never materialises any value larger than `p` or `q` during the hot
|
||||
loop, keeping memory pressure halved. The final reconstruction `m = m2 + h*q`
|
||||
returns a value in `[0, n)` provided `c < n`; with `c >= n` the result is
|
||||
mathematically valid but semantically meaningless because RSA encrypted/signed
|
||||
representatives are always reduced mod `n`.
|
||||
|
||||
Negative `h` arises when `m1 < m2`. The `if (h < 0n) h += p` step canonicalises
|
||||
`h` into `[0, p)` before the multiplication so the final `m2 + h*q` is
|
||||
unambiguously non-negative.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `math/i2osp`
|
||||
|
||||
## Purpose
|
||||
|
||||
Integer-to-Octet-String Primitive (RFC 8017 §4.1). Encodes a non-negative integer
|
||||
as a fixed-length big-endian byte string. Required by every RSA signature and
|
||||
encryption scheme to convert the modular-exponentiation output into a byte
|
||||
string of the modulus's byte length.
|
||||
|
||||
This module is a thin re-export of the canonical `i2osp` in `@suaveplan/crypto-utils`
|
||||
so the entire project shares a single, audited definition. The re-export gives
|
||||
RSA consumers a path-local import that mirrors the spec section number.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed-output-length big-endian byte encoding.
|
||||
- Throws on negative input or insufficient output length.
|
||||
- Accepts both `number` and `bigint` for source-side ergonomics.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { i2osp } from "@suaveplan/crypto-rsa/math";
|
||||
|
||||
const bytes = i2osp(255n, 4); // Uint8Array([0, 0, 0, 255])
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { i2osp, modPow } from "@suaveplan/crypto-rsa/math";
|
||||
|
||||
function publicOpAsBytes(
|
||||
c: bigint,
|
||||
e: bigint,
|
||||
n: bigint,
|
||||
modulusByteLength: number
|
||||
): Uint8Array {
|
||||
const result = modPow(c, e, n);
|
||||
return i2osp(result, modulusByteLength);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `i2osp(n: number | bigint, xLen: number): Uint8Array`
|
||||
|
||||
Returns a `Uint8Array` of length `xLen`. Throws
|
||||
`CryptoUtilsError(CRYPTO.UTILS.INTEGER.OUT_OF_RANGE)` when `n` is negative or
|
||||
does not fit in `xLen` bytes; throws
|
||||
`CryptoUtilsError(CRYPTO.UTILS.BYTES.INVALID_LENGTH)` when `xLen` is negative
|
||||
or not an integer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical implementation lives in `@suaveplan/crypto-utils/bytes/i2osp/i2osp.ts`.
|
||||
This re-export is preserved instead of importing the utility directly so that
|
||||
RSA consumers reading the source see a path that maps 1:1 to RFC 8017 §4.1, and
|
||||
so the RSA package can swap the implementation later (for instance to a
|
||||
constant-time native variant) without churn at every call site.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `math/modpow`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single-source-of-truth modular exponentiation for the entire `@suaveplan/crypto-rsa`
|
||||
package. Wraps `@noble/curves/abstract/modular.pow` so every signing, verifying,
|
||||
encrypting, decrypting, and CRT path goes through one function — and so the banned
|
||||
native pattern `BigInt ** BigInt % N` never appears in package source.
|
||||
|
||||
The native `**` + `%` pattern materialises the full exponentiation result before
|
||||
modular reduction. For RSA-grade exponents (2048+ bits) this allocates digits in
|
||||
the gigabytes and is also non-constant-time. `@noble/curves/abstract/modular.pow`
|
||||
implements the standard square-and-multiply ladder over the modular ring without
|
||||
materialising the full power.
|
||||
|
||||
## Features
|
||||
|
||||
- One-line wrapper — no extra allocation, no extra branching.
|
||||
- Pinned to `@noble/curves`'s audited `pow` implementation (the same primitive
|
||||
that backs every elliptic curve in `@suaveplan/crypto-curve`).
|
||||
- Repo-grep gate ensures no other file in the package emits the banned pattern.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { modPow } from "@suaveplan/crypto-rsa/math";
|
||||
|
||||
const result = modPow(2n, 10n, 1000n); // 24n
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { modPow } from "@suaveplan/crypto-rsa/math";
|
||||
import type { RsaPublicKey } from "@suaveplan/crypto-rsa";
|
||||
|
||||
function rsaPublicOp(c: bigint, key: RsaPublicKey): bigint {
|
||||
return modPow(c, key.e, key.n);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `modPow(base: bigint, exponent: bigint, modulus: bigint): bigint`
|
||||
|
||||
Returns `base^exponent mod modulus` in `[0, modulus)`. Delegates entirely to
|
||||
`@noble/curves/abstract/modular.pow`. The noble implementation throws
|
||||
`RangeError` on `modulus <= 0n` or `exponent < 0n`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function exists purely to centralise the import. Inlining would not save any
|
||||
runtime cost (the wrapper is a single tail call) but would bypass the
|
||||
single-import grep gate that the package relies on for the `BigInt ** %` audit.
|
||||
|
||||
The dependency on `@noble/curves` is justified because the package already
|
||||
depends on it transitively for prime-related utilities and because the
|
||||
`@suaveplan/crypto-curve` precedent established the engine selection. No
|
||||
alternative pure-JS implementation in the project currently provides
|
||||
audited modular exponentiation over `bigint`.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `math/os2ip`
|
||||
|
||||
## Purpose
|
||||
|
||||
Octet-String-to-Integer Primitive (RFC 8017 §4.2). Decodes a big-endian byte string
|
||||
into a non-negative `bigint`. Used by every RSA scheme to convert a modulus-sized
|
||||
byte payload back into the integer the modular operations expect.
|
||||
|
||||
Thin re-export of `@suaveplan/crypto-utils`'s canonical implementation so the
|
||||
entire project agrees on encoding semantics.
|
||||
|
||||
## Features
|
||||
|
||||
- Always returns `bigint`, never `number`, so precision is preserved for any
|
||||
byte length.
|
||||
- Pure decoder; never throws.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { os2ip } from "@suaveplan/crypto-rsa/math";
|
||||
|
||||
const value = os2ip(new Uint8Array([0, 0, 0, 255])); // 255n
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { i2osp, modPow, os2ip } from "@suaveplan/crypto-rsa/math";
|
||||
|
||||
function privateOpRoundTrip(
|
||||
sigBytes: Uint8Array,
|
||||
e: bigint,
|
||||
n: bigint,
|
||||
modulusByteLength: number
|
||||
): Uint8Array {
|
||||
const sigInt = os2ip(sigBytes);
|
||||
const recovered = modPow(sigInt, e, n);
|
||||
return i2osp(recovered, modulusByteLength);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `os2ip(bytes: Uint8Array): bigint`
|
||||
|
||||
Returns the big-endian-decoded `bigint`. Empty input returns `0n`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical implementation lives in `@suaveplan/crypto-utils/bytes/os2ip/os2ip.ts`.
|
||||
This re-export gives RSA consumers a path-local import mirroring RFC 8017 §4.2.
|
||||
@@ -0,0 +1,85 @@
|
||||
# `observability/metrics`
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry meter + instrument registry for `@suaveplan/crypto-rsa`. Stage 1
|
||||
defines the full instrument set the proposal pins (so dashboards and SLOs can
|
||||
be authored ahead of Stages 2-4) and wires up the parse/serialise duration
|
||||
histograms and the typed-error counter — the only paths that have a hot
|
||||
production call site at this stage.
|
||||
|
||||
The instruments are lazily constructed on first use to keep the module
|
||||
import-side-effect-free; this matters because some downstream consumers
|
||||
import the package without ever installing a `MeterProvider`, and constructing
|
||||
real instruments against the no-op global meter is harmless but unnecessary.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy `meter()` and `instruments()` accessors — first access constructs,
|
||||
subsequent accesses reuse cached instruments.
|
||||
- `resetMetricsForTesting()` — drops the caches so a fresh `MeterProvider`
|
||||
installed by a test takes effect.
|
||||
- `recordParseDuration(format, ms)`, `recordSerializeDuration(format, ms)`,
|
||||
`recordError(code)` — production helpers wrapping the instrument calls.
|
||||
- `getInstrumentsForTesting()` — exposes the bag for test assertions.
|
||||
- Full instrument set (8 histograms + 6 counters) declared up-front so the
|
||||
metric names cannot drift between Stages.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordError,
|
||||
recordParseDuration,
|
||||
} from "@suaveplan/crypto-rsa/observability";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
parse();
|
||||
recordParseDuration("pkcs1", performance.now() - start);
|
||||
} catch (error) {
|
||||
recordError(error.code);
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getInstrumentsForTesting,
|
||||
resetMetricsForTesting,
|
||||
} from "@suaveplan/crypto-rsa/observability";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
resetMetricsForTesting();
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
|
||||
const bag = getInstrumentsForTesting();
|
||||
// bag.errorsTotal, bag.outcomeTotal, …
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordParseDuration(format: string, durationMs: number): void`
|
||||
- `recordSerializeDuration(format: string, durationMs: number): void`
|
||||
- `recordError(code: string): void`
|
||||
- `resetMetricsForTesting(): void`
|
||||
- `getInstrumentsForTesting(): InstrumentBag`
|
||||
|
||||
The instrument bag carries (counters): `errorsTotal`, `outcomeTotal`,
|
||||
`engineSelectedTotal`, `fingerprintTotal`, `profileViolationTotal`,
|
||||
`blindingDisabledTotal`; (histograms): `verifyDurationMs`, `signDurationMs`,
|
||||
`encryptDurationMs`, `decryptDurationMs`, `keygenDurationMs`,
|
||||
`modpowDurationMs`, `parseDurationMs`, `serializeDurationMs`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are declared even though most are not yet wired into
|
||||
production paths because the proposal pins them as the canonical observable
|
||||
surface; declaring them lets dashboards reference the metric
|
||||
names by build time, not by feature-arrival time, and prevents naming drift
|
||||
when Stages 2-4 wire them up.
|
||||
|
||||
`resetMetricsForTesting` is the documented seam for swapping
|
||||
`MeterProvider`s under test. Production code should never call it.
|
||||
@@ -0,0 +1,67 @@
|
||||
# observability/timing
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralised high-resolution clock source used by every metric histogram
|
||||
in `@suaveplan/crypto-rsa`. Resolves to `performance.now()` when
|
||||
available and falls back to `Date.now()` when it is not. The presence
|
||||
check runs once at module-load time so each call site receives the same
|
||||
resolved function reference; there is no per-call branch and no
|
||||
per-call cost beyond the indirect call.
|
||||
|
||||
## Features
|
||||
|
||||
- Single function (`nowMs`) shared across every sign / verify / encrypt / decrypt / keygen path
|
||||
- Module-load-time clock resolution — chosen once per process
|
||||
- `performance.now()` preferred (sub-millisecond resolution in modern engines)
|
||||
- `Date.now()` fallback for engines without `performance` (legacy embedded JS)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { nowMs } from "@suaveplan/crypto-rsa/observability";
|
||||
|
||||
const start = nowMs();
|
||||
doSomething();
|
||||
const elapsed = nowMs() - start;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The clock source is used internally by every `recordSignOutcome` /
|
||||
`recordVerifyOutcome` / `recordEncryptDuration` call site. Consumers
|
||||
generally do not need to call `nowMs` directly; the helper is exposed
|
||||
for instrumentation parity in package extensions or alternative
|
||||
engines.
|
||||
|
||||
```ts
|
||||
import { nowMs, getInstrumentsForTesting } from "@suaveplan/crypto-rsa/observability";
|
||||
|
||||
// Custom histogram tagging — bypassing the recordX helpers.
|
||||
const start = nowMs();
|
||||
performExpensiveOperation();
|
||||
getInstrumentsForTesting().signDurationMs.record(nowMs() - start, {
|
||||
scheme: "custom",
|
||||
hash: "SHA-256",
|
||||
engine: "custom",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `nowMs(): number`
|
||||
|
||||
- **returns** the current timestamp in milliseconds (with sub-millisecond resolution where supported)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `globalThis.performance` lookup tolerates engines that omit the
|
||||
`performance` global without causing a `ReferenceError`. The presence
|
||||
of `.now` is also checked because some embedded engines expose
|
||||
`performance` without the method.
|
||||
- The chosen clock is captured in a module-scope closure (`NOW`) so the
|
||||
hot path is one indirect call — no `typeof` guard, no ternary.
|
||||
- This module deliberately ships a single export; mocking the clock for
|
||||
determinism testing is done through OpenTelemetry's `MeterProvider`
|
||||
in conjunction with `resetMetricsForTesting`, not by replacing
|
||||
`nowMs`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# `pem/labels`
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralised PEM textual labels per RFC 7468 §5/§10/§11 used by the public
|
||||
and private PEM parsers and serialisers in this package. Keeping the labels
|
||||
in one file means the parser whitelist and serialiser emission share a
|
||||
single source of truth.
|
||||
|
||||
## Features
|
||||
|
||||
- `PEM_LABEL_PUBLIC_KEY` — `"PUBLIC KEY"` (SPKI body).
|
||||
- `PEM_LABEL_RSA_PUBLIC_KEY` — `"RSA PUBLIC KEY"` (PKCS#1 public body).
|
||||
- `PEM_LABEL_PRIVATE_KEY` — `"PRIVATE KEY"` (PKCS#8 body).
|
||||
- `PEM_LABEL_RSA_PRIVATE_KEY` — `"RSA PRIVATE KEY"` (PKCS#1 private body).
|
||||
- `PEM_LINE_LENGTH = 64` — RFC 7468 §3 maximum body line length.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PEM_LABEL_PUBLIC_KEY } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const armor = `-----BEGIN ${PEM_LABEL_PUBLIC_KEY}-----`;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
PEM_LABEL_PRIVATE_KEY,
|
||||
PEM_LABEL_RSA_PRIVATE_KEY,
|
||||
} from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
function classifyPrivatePem(label: string): "pkcs8" | "pkcs1" | "unknown" {
|
||||
if (label === PEM_LABEL_PRIVATE_KEY) return "pkcs8";
|
||||
if (label === PEM_LABEL_RSA_PRIVATE_KEY) return "pkcs1";
|
||||
return "unknown";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
All exports are `string` constants.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
RFC 7468 §3 fixes the body line length at 64 base64 characters. Some legacy
|
||||
emitters use 76 (the historic RFC 1421 PEM length); we accept both on parse
|
||||
but always emit 64.
|
||||
@@ -0,0 +1,50 @@
|
||||
# `pem/parse-private`
|
||||
|
||||
## Purpose
|
||||
|
||||
Parse an RSA private key from RFC 7468 PEM textual encoding. Accepts the
|
||||
two canonical private-key armor labels — `"PRIVATE KEY"` (PKCS#8 per RFC
|
||||
5208 / RFC 5958) and `"RSA PRIVATE KEY"` (PKCS#1 per RFC 8017 Appendix
|
||||
A.1.2) — and dispatches to the matching DER parser.
|
||||
|
||||
## Features
|
||||
|
||||
- Dual-label support (`"PRIVATE KEY"` and `"RSA PRIVATE KEY"`).
|
||||
- Delegates to existing DER parsers.
|
||||
- Strict-grammar body validation via `decodePem`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPrivateKeyPem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const key = parseRsaPrivateKeyPem(`
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAAS...
|
||||
-----END PRIVATE KEY-----
|
||||
`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPrivateKeyPem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const fromPkcs8 = parseRsaPrivateKeyPem(pkcs8Pem);
|
||||
const fromPkcs1 = parseRsaPrivateKeyPem(pkcs1Pem);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRsaPrivateKeyPem(pem: string): RsaPrivateKey`
|
||||
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.PEM_MALFORMED)` on framing errors,
|
||||
`CRYPTO_RSA.INVALID_KEY` / `CRYPTO_RSA.UNSUPPORTED_FORMAT` on inner-DER errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Same dispatcher pattern as `parse-public.ts` — `Record<label, parser>` with
|
||||
the allowlist derived from the dispatcher keys. Encrypted PEM bodies
|
||||
(legacy `Proc-Type: 4,ENCRYPTED` headers, OpenSSL `DEK-Info` lines) are
|
||||
not supported ; consumers needing those should decrypt to a
|
||||
plain PKCS#8 body before calling this function.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `pem/parse-public`
|
||||
|
||||
## Purpose
|
||||
|
||||
Parse an RSA public key from RFC 7468 PEM textual encoding. Accepts both
|
||||
canonical armor labels — `"PUBLIC KEY"` (SPKI per RFC 5280) and
|
||||
`"RSA PUBLIC KEY"` (PKCS#1 per RFC 8017) — and dispatches to the
|
||||
appropriate DER parser. Mismatched BEGIN/END, unknown labels, or non-base64
|
||||
body content surface as `CRYPTO_RSA.PEM_MALFORMED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Dual-label support (`"PUBLIC KEY"` and `"RSA PUBLIC KEY"`).
|
||||
- Delegates to existing DER parsers — no DER logic duplicated.
|
||||
- Strict-grammar body validation via `decodePem`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPublicKeyPem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const key = parseRsaPublicKeyPem(`
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
||||
-----END PUBLIC KEY-----
|
||||
`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseRsaPublicKeyPem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
// Either label is accepted.
|
||||
const fromSpki = parseRsaPublicKeyPem(spkiPem);
|
||||
const fromPkcs1 = parseRsaPublicKeyPem(pkcs1Pem);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRsaPublicKeyPem(pem: string): RsaPublicKey`
|
||||
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.PEM_MALFORMED)` on framing errors and
|
||||
`CryptoRsaError(CRYPTO_RSA.INVALID_KEY | CRYPTO_RSA.UNSUPPORTED_FORMAT)`
|
||||
on inner-DER errors per the underlying DER parsers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dispatcher is a `Record<string, parser>` lookup keyed by label. Building
|
||||
the allowed-label list from the dispatcher's keys ensures the codec's
|
||||
allowlist and the dispatcher's coverage cannot drift — adding a new label
|
||||
requires changing exactly one place.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `pem/pem-codec`
|
||||
|
||||
## Purpose
|
||||
|
||||
Underlying RFC 7468 textual-encoding codec used by the four PEM
|
||||
parse/serialise entry points in this module. Splits the codec from the
|
||||
public-key-specific dispatchers so the parser logic (label whitelist,
|
||||
mismatched BEGIN/END detection, base64 strict-grammar enforcement) lives
|
||||
in exactly one place.
|
||||
|
||||
## Features
|
||||
|
||||
- `decodePem(pem, allowedLabels)` — line-tolerant scan for matching
|
||||
BEGIN/END armor, label whitelist enforcement, body base64 round-trip.
|
||||
- `encodePem(label, body)` — canonical 64-char body line emission with
|
||||
trailing newline.
|
||||
- All failure modes raise `CryptoRsaError(CRYPTO_RSA.PEM_MALFORMED)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodePem, encodePem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const decoded = decodePem(pemString, ["PUBLIC KEY"]);
|
||||
const back = encodePem("PUBLIC KEY", decoded.body);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodePem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const { label, body } = decodePem(input, ["PUBLIC KEY", "RSA PUBLIC KEY"]);
|
||||
// Dispatch on label to the appropriate DER parser.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `decodePem(pem: string, allowedLabels: readonly string[]): DecodedPem`
|
||||
|
||||
Returns `{ label, body }`. Throws `PEM_MALFORMED` on missing BEGIN line,
|
||||
unknown label, missing matching END line, empty body, non-base64 body
|
||||
characters, or base64-decode failure.
|
||||
|
||||
### `encodePem(label: string, body: Uint8Array): string`
|
||||
|
||||
Returns the canonical PEM block.
|
||||
|
||||
### `interface DecodedPem { readonly label: string; readonly body: Uint8Array }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The body grammar is enforced before base64 decoding because some base64
|
||||
implementations silently accept non-canonical characters (whitespace, `_`,
|
||||
`-`) that violate strict RFC 7468. We require the strict alphabet
|
||||
`[A-Za-z0-9+/=]` to ensure round-trip determinism.
|
||||
|
||||
The codec accepts both LF and CRLF line endings on parse but always emits
|
||||
LF — the canonical form recommended by RFC 7468 §2 for systems with
|
||||
flexible line-ending support. CRLF emission is left to the consumer (e.g.
|
||||
HTTP frameworks) as a transport-layer concern.
|
||||
@@ -0,0 +1,43 @@
|
||||
# `pem/serialize-private`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inverse of `parseRsaPrivateKeyPem`. Emits PEM textual encoding for an RSA
|
||||
private key in either PKCS#8 (`"PRIVATE KEY"`, the modern default) or
|
||||
PKCS#1 (`"RSA PRIVATE KEY"`, the legacy OpenSSL form) per RFC 7468 §3.
|
||||
|
||||
## Features
|
||||
|
||||
- 64-char body line emission.
|
||||
- Discriminated `format` option (`"pkcs8"` default | `"pkcs1"`).
|
||||
- Reuses the underlying DER serialisers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPrivateKeyPem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const pem = serializeRsaPrivateKeyPem(key);
|
||||
// -----BEGIN PRIVATE KEY----- ...
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPrivateKeyPem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const legacy = serializeRsaPrivateKeyPem(key, { format: "pkcs1" });
|
||||
// -----BEGIN RSA PRIVATE KEY----- ...
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeRsaPrivateKeyPem(key, options?: { format?: "pkcs8" | "pkcs1" }): string`
|
||||
|
||||
Returns the PEM string with trailing newline.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
We default to PKCS#8 for the same reason `serialize-public` defaults to
|
||||
SPKI — modern PKI tooling, KMS providers, and JOSE libraries all consume
|
||||
PKCS#8. PKCS#1 remains for legacy OpenSSL and ssh-keygen interop.
|
||||
@@ -0,0 +1,44 @@
|
||||
# `pem/serialize-public`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inverse of `parseRsaPublicKeyPem`. Emits PEM textual encoding for an RSA
|
||||
public key in either SPKI (`"PUBLIC KEY"`, the default modern form) or
|
||||
PKCS#1 (`"RSA PUBLIC KEY"`, the legacy OpenSSL form) per RFC 7468 §3.
|
||||
|
||||
## Features
|
||||
|
||||
- 64-char body line emission (RFC 7468 §3 canonical).
|
||||
- Discriminated `format` option (`"spki"` default | `"pkcs1"`).
|
||||
- Reuses the underlying DER serialisers — no DER logic duplicated.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPublicKeyPem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const pem = serializeRsaPublicKeyPem(key);
|
||||
// -----BEGIN PUBLIC KEY----- ... (SPKI form)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { serializeRsaPublicKeyPem } from "@suaveplan/crypto-rsa/pem";
|
||||
|
||||
const legacyOpenSsl = serializeRsaPublicKeyPem(key, { format: "pkcs1" });
|
||||
// -----BEGIN RSA PUBLIC KEY----- ... (PKCS#1 form)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeRsaPublicKeyPem(key, options?: { format?: "spki" | "pkcs1" }): string`
|
||||
|
||||
Returns the PEM string with a trailing newline.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default is `format: "spki"` because the modern OpenSSL toolchain and
|
||||
all PKI standards (X.509 / TLS / OIDC / WebAuthn) emit and consume SPKI-
|
||||
labeled PEM. The PKCS#1 variant remains for compatibility with legacy
|
||||
OpenSSL `openssl rsa -RSAPublicKey_out` and ssh-keygen tooling.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `policy/modulus`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Modulus-length policy gate enforced at every public-API entry point in
|
||||
`@suaveplan/crypto-rsa`. Combines a default-2048-bit floor (with optional
|
||||
1024-bit legacy carve-out) and a default-16384-bit ceiling (with optional
|
||||
oversized opt-in) so consumers cannot accidentally accept dangerously short
|
||||
keys or get DoS'd by attacker-supplied oversized keys.
|
||||
|
||||
The 2048-bit default tracks NIST SP 800-131A Rev. 2's transition from
|
||||
112-bit to 128-bit security strength; 1024 bits delivers 80-bit strength
|
||||
which is broken offline and survives only for legacy WebAuthn TPM
|
||||
endorsement keys (see proposal §Why and CLAUDE.md). The 16384-bit ceiling
|
||||
matches the largest modulus a service is realistically expected to verify;
|
||||
beyond that the per-verify modpow cost (cubic in bit length) becomes a
|
||||
viable DoS vector.
|
||||
|
||||
## Features
|
||||
|
||||
- `enforceMinBitLength(key, { allowLegacy? })` — strict default 2048-bit floor.
|
||||
- `enforceMaxBitLength(key, { allowOversize? })` — strict default 16384-bit ceiling.
|
||||
- Hard sub-1024-bit reject regardless of `allowLegacy`.
|
||||
- Both throw `CryptoRsaError` with structured `context` for telemetry.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { enforceMinBitLength } from "@suaveplan/crypto-rsa/policy";
|
||||
|
||||
enforceMinBitLength(publicKey); // throws if bitLength < 2048
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
enforceMaxBitLength,
|
||||
enforceMinBitLength,
|
||||
} from "@suaveplan/crypto-rsa/policy";
|
||||
|
||||
// WebAuthn TPM legacy path: 1024 bits permitted.
|
||||
enforceMinBitLength(tpmEndorsementKey, { allowLegacy: true });
|
||||
|
||||
// Offline tooling that processes 32 768-bit keys.
|
||||
enforceMaxBitLength(researchKey, { allowOversize: true });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `enforceMinBitLength(key, options?: { allowLegacy?: boolean }): void`
|
||||
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` with `context: { bitLength, floor }`
|
||||
when `key.bitLength < floor`. `floor` is `2048` by default, `1024` under
|
||||
`allowLegacy: true`. Sub-1024-bit keys are always rejected.
|
||||
|
||||
### `enforceMaxBitLength(key, options?: { allowOversize?: boolean }): void`
|
||||
|
||||
Throws `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_LARGE)` with `context: { bitLength, ceiling }`
|
||||
when `key.bitLength > 16384`. `allowOversize: true` short-circuits the check.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hard sub-1024-bit floor is enforced even when `allowLegacy: true` because
|
||||
512-bit and 768-bit moduli have been factored on commodity hardware and there
|
||||
is no realistic legacy use-case that still relies on them.
|
||||
|
||||
The ceiling check returns early when `allowOversize: true` rather than scaling
|
||||
the comparison; this keeps the hot path branch-predictable and the policy
|
||||
scope obvious to readers.
|
||||
@@ -0,0 +1,108 @@
|
||||
# profile/profile
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`enforceProfile` applies a named profile's rule set to RSA operation
|
||||
parameters. Two profiles ship: `permissive` (no-op pass-through, the
|
||||
default) and `fips-186-5` (FIPS 186-5 §5 + Appendix B.3.1
|
||||
constraints). The function is wired into every operation surface that
|
||||
accepts a `profile` option so the rule set runs uniformly across
|
||||
sign / verify / encrypt / decrypt / keygen.
|
||||
|
||||
## Features
|
||||
|
||||
- Two profiles: `permissive` (default) and `fips-186-5`
|
||||
- FIPS profile enforces:
|
||||
- `modulusBits ∈ {2048, 3072, 4096}`
|
||||
- `publicExponent ∈ [2^16 + 1, 2^256 − 1]` AND odd
|
||||
- PSS `saltLength === hLen(hash)`
|
||||
- `hash !== "SHA-1"` for sign/verify (allowed for OAEP encrypt/decrypt
|
||||
so legacy interop still works for opaque cipher payloads)
|
||||
- Derives modulus bits from `key.bitLength` when `modulusBits` is omitted
|
||||
- Each violation throws `CRYPTO_RSA.PROFILE_VIOLATION` with `rule`
|
||||
attribute identifying which constraint failed
|
||||
- Increments `crypto_rsa.profile_violation_total{rule}` on every
|
||||
violation for dashboard aggregation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { enforceProfile } from "@suaveplan/crypto-rsa/profile";
|
||||
|
||||
enforceProfile(
|
||||
{ modulusBits: 2048, publicExponent: 65537n },
|
||||
{ profile: "fips-186-5" }
|
||||
);
|
||||
// throws if 2048 / 3072 / 4096 / 65537 don't match
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire the profile into the keygen surface to gate FIPS-only deployments:
|
||||
|
||||
```ts
|
||||
import {
|
||||
enforceProfile,
|
||||
generateRsaKeyPair,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
enforceProfile(
|
||||
{ modulusBits: 3072, publicExponent: 65537n },
|
||||
{ profile: "fips-186-5" }
|
||||
);
|
||||
const key = await generateRsaKeyPair({
|
||||
modulusBits: 3072,
|
||||
publicExponent: 65537n,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `enforceProfile(input, options): void`
|
||||
|
||||
- `input.modulusBits?: number`
|
||||
- `input.publicExponent?: bigint`
|
||||
- `input.hash?: RsaHashAlgorithm`
|
||||
- `input.scheme?: "pkcs1v15" | "pss" | "oaep"`
|
||||
- `input.saltLength?: number`
|
||||
- `input.key?: RsaPublicKey | RsaPrivateKey` — modulus bits derived
|
||||
from `key.bitLength` when `modulusBits` is omitted
|
||||
- `options.profile: "permissive" | "fips-186-5"`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.PROFILE_VIOLATION)` with `rule`
|
||||
attribute on any violation
|
||||
|
||||
### `RsaProfile`
|
||||
|
||||
`"permissive" | "fips-186-5"`.
|
||||
|
||||
### `ProfileInput`
|
||||
|
||||
Shape of the operation parameters subject to enforcement.
|
||||
|
||||
### `EnforceProfileOptions`
|
||||
|
||||
`{ profile: RsaProfile }`.
|
||||
|
||||
### `resetProfileCounterForTesting()`
|
||||
|
||||
Test-only helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The FIPS profile is intentionally strict — it rejects the popular
|
||||
`e = 3` and `e = 5` exponents which, while mathematically sound, are
|
||||
banned by FIPS 186-5 §A.1.1 due to historical interop issues with
|
||||
small-exponent attacks against poor PKCS1v15 implementations.
|
||||
- SHA-1 is allowed for OAEP because RFC 8017 §7.1.1 still references
|
||||
it in the canonical OAEP test vectors and FIPS 186-5 only deprecates
|
||||
SHA-1 for digital signatures (sign/verify), not for the MGF / hash
|
||||
binding inside OAEP.
|
||||
- The `rule` attribute on `PROFILE_VIOLATION` errors uses kebab-case
|
||||
identifiers (`modulus-bits`, `public-exponent`, `hash-algorithm`,
|
||||
`pss-salt-length`) matching the counter label values.
|
||||
- The function is intentionally side-effect-free (besides the counter
|
||||
increment); callers can call it during config validation, at
|
||||
operation-call time, or both.
|
||||
@@ -0,0 +1,90 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Zod boundary schemas covering every public input/output of `@suaveplan/crypto-rsa`.
|
||||
ships the canonical key shapes (`RsaPublicKeySchema`, `RsaPrivateKeySchema`),
|
||||
the parse/serialise format discriminators, and the option schemas for the sign,
|
||||
verify, PSS, and OAEP entry points that Stages 2-3 will wire up.
|
||||
|
||||
The schemas serve two purposes: runtime validation at every public surface, and
|
||||
single-source-of-truth typing for documentation and downstream consumer
|
||||
ecosystems (e.g. JWK parsers in `@suaveplan/jws` once it lands).
|
||||
|
||||
## Features
|
||||
|
||||
- `HashAlgorithmSchema` — `SHA-1` | `SHA-256` | `SHA-384` | `SHA-512`.
|
||||
- `RsaPublicKeySchema` and `RsaPrivateKeySchema` — every field positive `bigint`,
|
||||
`bitLength` positive integer.
|
||||
- `RsaPublicKeyFormatSchema` (`"pkcs1" | "spki" | "jwk"`).
|
||||
- `RsaPrivateKeyFormatSchema` (`"pkcs1" | "pkcs8" | "jwk"`).
|
||||
- `RsaParseOptions{Public,Private}Schema` and the union `RsaParseOptionsSchema`.
|
||||
- `RsaSerializeOptions{Public,Private}Schema` and the union `RsaSerializeOptionsSchema`.
|
||||
- `RsaSignOptionsSchema`, `RsaVerifyOptionsSchema`, `RsaPssOptionsSchema`,
|
||||
`RsaOaepOptionsSchema`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RsaPublicKeySchema } from "@suaveplan/crypto-rsa/schemas";
|
||||
|
||||
const validated = RsaPublicKeySchema.parse({
|
||||
n: 0xc0_de_n,
|
||||
e: 65537n,
|
||||
bitLength: 2048,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
RsaParseOptionsPublicSchema,
|
||||
RsaPssOptionsSchema,
|
||||
} from "@suaveplan/crypto-rsa/schemas";
|
||||
|
||||
function readKey(input: unknown) {
|
||||
const opts = RsaParseOptionsPublicSchema.parse(input);
|
||||
// opts.format is now "pkcs1" | "spki" | "jwk"
|
||||
}
|
||||
|
||||
function signOptions(input: unknown) {
|
||||
const opts = RsaPssOptionsSchema.parse(input);
|
||||
// opts.hash, opts.saltLength?, opts.mgfHash?
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Every export is a `z.ZodType<T>`. `infer` to recover the static type:
|
||||
|
||||
```ts
|
||||
import type { z } from "zod";
|
||||
import { RsaPublicKeySchema } from "@suaveplan/crypto-rsa/schemas";
|
||||
type RsaPublicKey = z.infer<typeof RsaPublicKeySchema>;
|
||||
```
|
||||
|
||||
Schemas validate strictly:
|
||||
|
||||
- `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, `qInv` MUST be positive `bigint`.
|
||||
- `bitLength` MUST be a positive integer.
|
||||
- `format` MUST be one of the discriminated string literals.
|
||||
- `hash` MUST be one of the four canonical IETF hash labels (`SHA-256` etc.,
|
||||
hyphenated, uppercase — the JOSE / WebCrypto canonical form).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`positiveBigInt` and `positiveBitLength` are inlined helpers rather than exported
|
||||
schemas to keep the public surface focused on the named shapes the proposal pins.
|
||||
|
||||
The parse/serialise option unions are `z.union(public, private)` rather than
|
||||
discriminated unions because the discriminant value sets overlap (`"pkcs1"` and
|
||||
`"jwk"` appear in both). Consumers that need stricter narrowing should call the
|
||||
specific public/private schemas directly.
|
||||
|
||||
`RsaSignOptionsSchema` carries an optional `blinding` flag for downstream RSA
|
||||
private-key blinding (Stage 2) so callers can opt out of the side-channel
|
||||
hardening for benchmarks; the default behaviour will be `blinding: true`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# sign/pkcs1v15
|
||||
|
||||
## Purpose
|
||||
|
||||
RSASSA-PKCS1-v1\_5 signature generation per RFC 8017 §8.2.1. Hashes
|
||||
the message with the configured algorithm, applies the EMSA-PKCS1-v1\_5
|
||||
encoding, and runs the RSA private operation through the CRT path. The
|
||||
output is a signature of length exactly `byteLength(n)`.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 8017 §8.2.1 conformant — uses the CRT private-key operation
|
||||
- Supports SHA-1, SHA-256, SHA-384, SHA-512
|
||||
- Modulus-length policy gates run on every call (`MODULUS_TOO_SHORT` / `MODULUS_TOO_LARGE`)
|
||||
- Re-uses the canonical `i2osp` / `os2ip` math primitives
|
||||
- Records `crypto_rsa.sign_duration_ms{scheme,hash,engine}` and `crypto_rsa.outcome_total{operation,outcome}` on every call
|
||||
- **RSA blinding (RFC 8017 §5.1.2 / Kocher 1996) wired by default** — opt-out via `{ blinding: false }` emits `crypto_rsa.blinding_disabled_total{operation="sign"}`
|
||||
- All thrown errors are `CryptoRsaError` instances; non-typed exceptions wrap as `CRYPTO_RSA.INVALID_SIGNATURE`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa/sign/pkcs1v15";
|
||||
|
||||
const message = new TextEncoder().encode("hello, signed world");
|
||||
const signature = rsaSignPkcs1v15(privateKey, message, { hash: "SHA-256" });
|
||||
console.log(signature.length); // === byteLength(n), e.g. 256 for 2048-bit
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The `allowLegacy` option lowers the modulus floor from 2048 to 1024 bits
|
||||
for legacy interop scenarios (e.g. WebAuthn TPM 1.2 endorsement keys).
|
||||
The `allowOversize` option lifts the ceiling beyond 16384 bits for
|
||||
offline tooling. Both default to `false`.
|
||||
|
||||
```ts
|
||||
import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa/sign/pkcs1v15";
|
||||
|
||||
const sig = rsaSignPkcs1v15(legacy1024PrivateKey, message, {
|
||||
hash: "SHA-1",
|
||||
allowLegacy: true,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaSignPkcs1v15(privateKey, message, options): Uint8Array`
|
||||
|
||||
- `privateKey: RsaPrivateKey` — canonical private key with mandatory CRT parameters
|
||||
- `message: Uint8Array` — bytes to be signed; will be hashed internally
|
||||
- `options.hash: RsaHashAlgorithm` — one of `"SHA-1"`, `"SHA-256"`, `"SHA-384"`, `"SHA-512"`
|
||||
- `options.allowLegacy?: boolean` — see policy doc
|
||||
- `options.allowOversize?: boolean` — see policy doc
|
||||
- `options.blinding?: boolean` — defaults to `true`; opting out emits `crypto_rsa.blinding_disabled_total{operation="sign"}`
|
||||
- `options.rng?: Rng` — randomness source for the blinding factor; defaults to `defaultRng()`
|
||||
- **returns** `Uint8Array` of length `byteLength(n)`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` / `MODULUS_TOO_LARGE` / `UNSUPPORTED_HASH` / `INVALID_KEY`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The output equals `i2osp(rsaPrivateOperationCrt(os2ip(em), privateKey), emLen)`
|
||||
where `em = emsaPkcs1v15Encode(message, emLen, hash)` and
|
||||
`emLen = byteLength(n)`. This is bit-for-bit identical to the naive
|
||||
`c = m^d mod n` reference, but uses the CRT decomposition for ~3-4×
|
||||
the throughput on a 2048-bit modulus.
|
||||
- Encoding is deterministic — every call with the same `(message, hash)`
|
||||
pair produces the same signature. This is RFC 8017 §8.2.1 behaviour:
|
||||
PKCS1-v1\_5 is a deterministic signature scheme, in contrast to PSS.
|
||||
- The function never short-circuits on early failure — even when an
|
||||
exception is raised, the duration histogram and outcome counter are
|
||||
recorded. The `outcome` attribute distinguishes `"ok"` from `"error"`.
|
||||
- The engine attribute is `"noble"` because the underlying modular
|
||||
exponentiation is `@noble/curves/abstract/modular.pow`. The
|
||||
`RsaCryptoEngine` adapter (`./engine`) selects `"subtle"` when
|
||||
`globalThis.crypto.subtle` exposes the matching algorithm.
|
||||
- **Blinding** — for every call `m' = m * r^e mod n` is computed with a
|
||||
freshly sampled `r ∈ Z*_n`, the CRT private operation runs against
|
||||
`m'`, and the result is unblinded via `s = s' * r^{-1} mod n`. This
|
||||
prevents the Kocher 1996 timing-attack class against the secret
|
||||
exponent. Output is bit-for-bit identical to the unblinded path; only
|
||||
the timing varies. The < 5% overhead is bench-verified.
|
||||
@@ -0,0 +1,105 @@
|
||||
# sign/pss
|
||||
|
||||
## Purpose
|
||||
|
||||
RSASSA-PSS signature generation per RFC 8017 §8.1.1. Hashes the
|
||||
message, draws a random salt of the configured length, runs the
|
||||
EMSA-PSS encoder, then converts the result through the CRT private-key
|
||||
operation. The output is a signature of length exactly `byteLength(n)`.
|
||||
|
||||
PSS is a probabilistic scheme — distinct calls with the same inputs
|
||||
produce different signatures unless the salt length is zero.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 8017 §8.1.1 conformant
|
||||
- Configurable `hash`, `mgfHash`, `saltLength`
|
||||
- `saltLength` defaults to `hashOutputLength(hash)`
|
||||
- `mgfHash` defaults to `hash`
|
||||
- Injectable `Rng` from `@suaveplan/types` for deterministic-replay test scenarios
|
||||
- Default RNG: `randomBytes` from `@suaveplan/crypto-utils` (CSPRNG-backed)
|
||||
- CRT-aware private operation (~3-4× faster than naive `m^d mod n` on 2048-bit keys)
|
||||
- **RSA blinding (RFC 8017 §5.1.2 / Kocher 1996) wired by default** — opt-out via `{ blinding: false }` emits `crypto_rsa.blinding_disabled_total{operation="sign"}`
|
||||
- Records `crypto_rsa.sign_duration_ms{scheme="pss",hash,engine}` and outcome counter on every call
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
|
||||
|
||||
const message = new TextEncoder().encode("hello, signed world");
|
||||
const signature = rsaSignPss(privateKey, message, { hash: "SHA-256" });
|
||||
console.log(signature.length); // === byteLength(n)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The `rng` option accepts any `Rng` shape from `@suaveplan/types`. A
|
||||
seeded RNG produces deterministic signatures, useful for golden-file
|
||||
testing or reproducible audit trails. The `saltLength: 0` shortcut also
|
||||
produces deterministic PSS without needing a custom RNG.
|
||||
|
||||
```ts
|
||||
import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
// Deterministic PSS via empty salt.
|
||||
const detSig = rsaSignPss(privateKey, message, {
|
||||
hash: "SHA-256",
|
||||
saltLength: 0,
|
||||
});
|
||||
|
||||
// Deterministic PSS via seeded RNG (reproducible for fuzz cases).
|
||||
const seeded: Rng = makeMyMulberry32Rng(0xdeadbeef);
|
||||
const reproducible = rsaSignPss(privateKey, message, {
|
||||
hash: "SHA-256",
|
||||
rng: seeded,
|
||||
});
|
||||
|
||||
// Cross-hash PSS — message hashed with SHA-256, MGF1 driven by SHA-1.
|
||||
const xHash = rsaSignPss(privateKey, message, {
|
||||
hash: "SHA-256",
|
||||
mgfHash: "SHA-1",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaSignPss(privateKey, message, options): Uint8Array`
|
||||
|
||||
- `privateKey: RsaPrivateKey`
|
||||
- `message: Uint8Array`
|
||||
- `options.hash: RsaHashAlgorithm`
|
||||
- `options.saltLength?: number` — defaults to `hashOutputLength(hash)`; must be non-negative integer
|
||||
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `options.hash`
|
||||
- `options.rng?: Rng` — defaults to `randomBytes` from `@suaveplan/crypto-utils`
|
||||
- `options.allowLegacy?: boolean`
|
||||
- `options.allowOversize?: boolean`
|
||||
- `options.blinding?: boolean` — defaults to `true`; opting out emits `crypto_rsa.blinding_disabled_total{operation="sign"}`
|
||||
- **returns** `Uint8Array` of length `byteLength(n)`
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` / `MODULUS_TOO_LARGE` / `UNSUPPORTED_HASH` / `INVALID_KEY` / `INVALID_SIGNATURE` (on bad RNG output or bad salt length)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `emBits = modulusBits - 1` per RFC 8017 §8.1.1 step 2. The encoder
|
||||
takes `emBits` (not `emLen` bytes) so the leftmost-bit clearance can
|
||||
be enforced correctly.
|
||||
- The signature byte length is always `byteLength(n) = ceil(modulusBits/8)`.
|
||||
When `modulusBits` is a multiple of 8 (the common case), this equals
|
||||
`emLen + 1` because `emBits = modulusBits - 1` rounds down by one
|
||||
byte. The `i2osp(s, Math.ceil(modulusBits / 8))` call pads the
|
||||
signature to the modulus byte length on the wire.
|
||||
- The `rng.nextBytes` path is used when available (CSPRNGs typically
|
||||
expose it directly). When only `nextUint32` is implemented, the salt
|
||||
is drawn 4 bytes at a time. Both paths produce the same statistical
|
||||
uniformity assuming the underlying RNG is uniform.
|
||||
- Salt length of `0` short-circuits the RNG entirely — useful for
|
||||
deterministic PSS validation.
|
||||
- **Blinding** — when enabled (default) the encoded message integer is
|
||||
multiplied by `r^e mod n` for a freshly sampled `r ∈ Z*_n`, the CRT
|
||||
private operation runs against that blinded value, and the unblinding
|
||||
step `s = s' * r^{-1} mod n` is applied before `i2osp`. The signature
|
||||
output is bit-identical to the unblinded path. PSS verifiers do not
|
||||
observe the difference because verification only depends on the public
|
||||
key. Disable only for benchmarks; the < 5% overhead does not justify
|
||||
the timing-side-channel exposure in production.
|
||||
@@ -0,0 +1,81 @@
|
||||
# verify/pkcs1v15
|
||||
|
||||
## Purpose
|
||||
|
||||
RSASSA-PKCS1-v1\_5 signature verification per RFC 8017 §8.2.2. Recovers
|
||||
the encoded message from `signature` via `s^e mod n`, reconstructs the
|
||||
expected EMSA-PKCS1-v1\_5 encoding of `message` with the same hash, and
|
||||
compares the two byte strings constant-time.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 8017 §8.2.2 conformant
|
||||
- Constant-time encoded-message compare via `constantTimeEqual` from `@suaveplan/crypto-utils`
|
||||
- Returns `false` on rejection (no exception); throws only on structural malformation (signature length mismatch)
|
||||
- Accepts either an `RsaPublicKey` or an `RsaPrivateKey` (both carry `n` and `e`)
|
||||
- Modulus-length policy gates run on every call
|
||||
- Records `crypto_rsa.verify_duration_ms` and `crypto_rsa.outcome_total{operation,outcome}` on every call
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaVerifyPkcs1v15 } from "@suaveplan/crypto-rsa/verify/pkcs1v15";
|
||||
import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa/sign/pkcs1v15";
|
||||
|
||||
const message = new TextEncoder().encode("hello, signed world");
|
||||
const signature = rsaSignPkcs1v15(privateKey, message, { hash: "SHA-256" });
|
||||
const ok = rsaVerifyPkcs1v15(publicKey, message, signature, {
|
||||
hash: "SHA-256",
|
||||
});
|
||||
console.log(ok); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A bit-flipped signature, a tampered message, or a hash mismatch all
|
||||
return `false` without revealing which one tripped the gate.
|
||||
|
||||
```ts
|
||||
import { rsaVerifyPkcs1v15 } from "@suaveplan/crypto-rsa/verify/pkcs1v15";
|
||||
|
||||
const tampered = new Uint8Array(signature);
|
||||
tampered[0] = tampered[0] ^ 0x01;
|
||||
console.log(
|
||||
rsaVerifyPkcs1v15(publicKey, message, tampered, { hash: "SHA-256" })
|
||||
); // false
|
||||
|
||||
console.log(
|
||||
rsaVerifyPkcs1v15(publicKey, message, signature, { hash: "SHA-512" })
|
||||
); // false (hash mismatch)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaVerifyPkcs1v15(key, message, signature, options): boolean`
|
||||
|
||||
- `key: RsaPublicKey | RsaPrivateKey`
|
||||
- `message: Uint8Array` — the bytes that were signed
|
||||
- `signature: Uint8Array` — must be exactly `byteLength(n)` long
|
||||
- `options.hash: RsaHashAlgorithm` — must match the hash used at sign time
|
||||
- `options.allowLegacy?: boolean`
|
||||
- `options.allowOversize?: boolean`
|
||||
- **returns** `true` when the signature is valid, `false` on any verify-level rejection
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_SIGNATURE)` on signature length mismatch
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` / `MODULUS_TOO_LARGE` / `UNSUPPORTED_HASH`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The signature integer `s = os2ip(signature)` is checked against `s < n`
|
||||
before the modular exponentiation; signatures with `s >= n` are
|
||||
rejected with `false` rather than allowed to wrap around.
|
||||
- Comparison uses `constantTimeEqual(recovered, expected)` — never
|
||||
`===` and never byte-by-byte short-circuit. This is the verify-side
|
||||
defence against the timing-oracle attacks described in Bleichenbacher
|
||||
2006 (the SHA-1 padding-oracle exploit) and follow-ups.
|
||||
- The encoded message is reconstructed every call rather than carried
|
||||
alongside the signature. This is the RFC 8017 §8.2.2 design — the
|
||||
verifier MUST regenerate `EM` from the public inputs to detect
|
||||
signature substitution.
|
||||
- The `verify_duration_ms` histogram is recorded on every code path
|
||||
(success, rejection, structural error) so service-side dashboards see
|
||||
the full latency distribution including the failure modes.
|
||||
@@ -0,0 +1,87 @@
|
||||
# verify/pss
|
||||
|
||||
## Purpose
|
||||
|
||||
RSASSA-PSS signature verification per RFC 8017 §8.1.2. Recovers the
|
||||
encoded message via `s^e mod n`, then runs the EMSA-PSS verifier against
|
||||
the digest of the message under the given `(hash, mgfHash, saltLength)`
|
||||
triple.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 8017 §8.1.2 conformant
|
||||
- Uses `emsaPssVerify` for the structural check (which uses `constantTimeEqual` for the salt+hash compare)
|
||||
- Returns `false` on rejection; throws only on signature length mismatch
|
||||
- `saltLength` defaults to `hashOutputLength(hash)`
|
||||
- `mgfHash` defaults to `hash`
|
||||
- Modulus-length policy gates run on every call
|
||||
- Records `crypto_rsa.verify_duration_ms{scheme="pss",hash,engine}` and outcome counter on every call
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsaVerifyPss } from "@suaveplan/crypto-rsa/verify/pss";
|
||||
import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
|
||||
|
||||
const message = new TextEncoder().encode("hello");
|
||||
const sig = rsaSignPss(privateKey, message, { hash: "SHA-256" });
|
||||
console.log(rsaVerifyPss(publicKey, message, sig, { hash: "SHA-256" })); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { rsaVerifyPss } from "@suaveplan/crypto-rsa/verify/pss";
|
||||
import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
|
||||
|
||||
// Custom saltLength: must match the value used by the signer.
|
||||
const sig = rsaSignPss(privateKey, message, { hash: "SHA-256", saltLength: 16 });
|
||||
console.log(rsaVerifyPss(publicKey, message, sig, { hash: "SHA-256", saltLength: 16 })); // true
|
||||
console.log(rsaVerifyPss(publicKey, message, sig, { hash: "SHA-256", saltLength: 32 })); // false
|
||||
|
||||
// mgfHash distinct from hash.
|
||||
const xSig = rsaSignPss(privateKey, message, {
|
||||
hash: "SHA-256",
|
||||
mgfHash: "SHA-1",
|
||||
});
|
||||
console.log(
|
||||
rsaVerifyPss(publicKey, message, xSig, {
|
||||
hash: "SHA-256",
|
||||
mgfHash: "SHA-1",
|
||||
})
|
||||
); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsaVerifyPss(key, message, signature, options): boolean`
|
||||
|
||||
- `key: RsaPublicKey | RsaPrivateKey`
|
||||
- `message: Uint8Array`
|
||||
- `signature: Uint8Array` — must be exactly `byteLength(n)` long
|
||||
- `options.hash: RsaHashAlgorithm`
|
||||
- `options.saltLength?: number` — defaults to `hashOutputLength(hash)`
|
||||
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `options.hash`
|
||||
- `options.allowLegacy?: boolean`
|
||||
- `options.allowOversize?: boolean`
|
||||
- **returns** `true` on valid, `false` on verify-level rejection
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_SIGNATURE)` on signature length mismatch
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` / `MODULUS_TOO_LARGE` / `UNSUPPORTED_HASH`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `s = os2ip(signature)` is checked against `s < n` before the modular
|
||||
exponentiation; signatures with `s >= n` are rejected with `false`.
|
||||
- The encoded message produced by `i2osp(s^e mod n, emLen)` has length
|
||||
`emLen = ceil((modulusBits - 1) / 8)`. This is one byte shorter than
|
||||
the signature itself when `modulusBits` is a multiple of 8 — the
|
||||
high-order zero pad is stripped here and the leftmost-bit clearance
|
||||
rule in `emsaPssVerify` enforces that the original encoding had the
|
||||
expected zero in that position.
|
||||
- The constant-time guarantee for the final salt+hash compare is
|
||||
delivered by `emsaPssVerify`'s use of `constantTimeEqual`. The
|
||||
earlier structural checks (length mismatch, `s >= n`) are guarded by
|
||||
fast-fail paths that do NOT leak the secret-derived signal — they
|
||||
test only public inputs.
|
||||
- The `verify_duration_ms` histogram is recorded on every code path
|
||||
(success, rejection, structural error).
|
||||
Reference in New Issue
Block a user