Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
export type {
RsaHashAlgorithm,
RsaJwk,
RsaPrivateKey,
RsaPrivateKeyFormat,
RsaPublicKey,
RsaPublicKeyFormat,
} from "./keys.types.js";
export { OID_RSA_ENCRYPTION } from "./oid.js";
export {
type ParseRsaPrivateKeyOptions,
type ParseRsaPublicKeyOptions,
parseRsaPrivateKey,
parseRsaPublicKey,
} from "./parse.js";
export {
parseRsaPrivateKeyJwk,
parseRsaPublicKeyJwk,
} from "./parse-jwk.js";
export {
finalisePrivateKey,
parseRsaPrivateKeyPkcs1,
} from "./parse-pkcs1-private.js";
export {
finalisePublicKey,
parseRsaPublicKeyPkcs1,
} from "./parse-pkcs1-public.js";
export { parseRsaPrivateKeyPkcs8 } from "./parse-pkcs8.js";
export { parseRsaPublicKeySpki } from "./parse-spki.js";
export {
type SerializeRsaPrivateKeyOptions,
type SerializeRsaPublicKeyOptions,
serializeRsaPrivateKey,
serializeRsaPublicKey,
} from "./serialize.js";
export {
serializeRsaPrivateKeyJwk,
serializeRsaPublicKeyJwk,
} from "./serialize-jwk.js";
export { serializeRsaPrivateKeyPkcs1 } from "./serialize-pkcs1-private.js";
export { serializeRsaPublicKeyPkcs1 } from "./serialize-pkcs1-public.js";
export { serializeRsaPrivateKeyPkcs8 } from "./serialize-pkcs8.js";
export { serializeRsaPublicKeySpki } from "./serialize-spki.js";

View File

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

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import { enforceJwkPolicy } from "./jwk-policy.js";
import type { RsaJwk } from "./keys.types.js";
const BASE_JWK: RsaJwk = {
kty: "RSA",
n: "AQAB",
e: "AQAB",
};
describe("enforceJwkPolicy — key_ops", () => {
it("permits sign when key_ops contains 'sign'", () => {
enforceJwkPolicy({ ...BASE_JWK, key_ops: ["sign"] }, "sign");
});
it("rejects sign when key_ops omits 'sign'", () => {
try {
enforceJwkPolicy({ ...BASE_JWK, key_ops: ["verify"] }, "sign");
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED
);
}
});
it("permits encrypt when key_ops contains 'wrapKey'", () => {
enforceJwkPolicy({ ...BASE_JWK, key_ops: ["wrapKey"] }, "encrypt");
});
it("permits decrypt when key_ops contains 'unwrapKey'", () => {
enforceJwkPolicy({ ...BASE_JWK, key_ops: ["unwrapKey"] }, "decrypt");
});
});
describe("enforceJwkPolicy — use", () => {
it("permits sign when use === 'sig'", () => {
enforceJwkPolicy({ ...BASE_JWK, use: "sig" }, "sign");
});
it("rejects encrypt when use === 'sig'", () => {
try {
enforceJwkPolicy({ ...BASE_JWK, use: "sig" }, "encrypt");
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED
);
}
});
it("rejects decrypt when use === 'sig'", () => {
try {
enforceJwkPolicy({ ...BASE_JWK, use: "sig" }, "decrypt");
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED
);
}
});
it("rejects sign when use === 'enc'", () => {
try {
enforceJwkPolicy({ ...BASE_JWK, use: "enc" }, "sign");
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED
);
}
});
it("rejects verify when use === 'enc'", () => {
try {
enforceJwkPolicy({ ...BASE_JWK, use: "enc" }, "verify");
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED
);
}
});
});
describe("enforceJwkPolicy — alg", () => {
it("rejects unknown alg with KEY_OP_NOT_PERMITTED", () => {
try {
enforceJwkPolicy({ ...BASE_JWK, alg: "ES256" }, "verify");
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED
);
}
});
it("rejects RS256 for encrypt/decrypt", () => {
try {
enforceJwkPolicy({ ...BASE_JWK, alg: "RS256" }, "encrypt");
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED
);
}
});
it("rejects RSA-OAEP for sign/verify", () => {
try {
enforceJwkPolicy({ ...BASE_JWK, alg: "RSA-OAEP-256" }, "sign");
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED
);
}
});
it("permits PS256 for sign and verify", () => {
enforceJwkPolicy({ ...BASE_JWK, alg: "PS256" }, "sign");
enforceJwkPolicy({ ...BASE_JWK, alg: "PS256" }, "verify");
});
it("permits RSA-OAEP-256 for encrypt and decrypt", () => {
enforceJwkPolicy({ ...BASE_JWK, alg: "RSA-OAEP-256" }, "encrypt");
enforceJwkPolicy({ ...BASE_JWK, alg: "RSA-OAEP-256" }, "decrypt");
});
it("permits RSA1_5 for encrypt", () => {
enforceJwkPolicy({ ...BASE_JWK, alg: "RSA1_5" }, "encrypt");
});
});
describe("enforceJwkPolicy — composite", () => {
it("accepts a JWK with no policy members", () => {
enforceJwkPolicy(BASE_JWK, "sign");
enforceJwkPolicy(BASE_JWK, "verify");
enforceJwkPolicy(BASE_JWK, "encrypt");
enforceJwkPolicy(BASE_JWK, "decrypt");
});
it("accepts a fully-aligned JWK", () => {
enforceJwkPolicy(
{
...BASE_JWK,
key_ops: ["sign"],
use: "sig",
alg: "RS256",
},
"sign"
);
});
});

View File

@@ -0,0 +1,103 @@
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaJwk } from "./keys.types.js";
/**
* Operation discriminator passed to `enforceJwkPolicy`. The policy
* cross-references this against the JWK's `key_ops`, `use`, and `alg`
* members to decide whether the JWK permits the requested operation.
*/
export type JwkOperation = "sign" | "verify" | "encrypt" | "decrypt";
const RSA_COMPATIBLE_ALGS = new Set<string>([
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"RSA-OAEP",
"RSA-OAEP-256",
"RSA-OAEP-384",
"RSA-OAEP-512",
"RSA1_5",
]);
const SIGN_ALGS = new Set<string>([
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
]);
const ENCRYPT_ALGS = new Set<string>([
"RSA-OAEP",
"RSA-OAEP-256",
"RSA-OAEP-384",
"RSA-OAEP-512",
"RSA1_5",
]);
const ALLOWED_KEY_OPS_PER_OPERATION: Readonly<
Record<JwkOperation, ReadonlyArray<string>>
> = {
sign: ["sign"],
verify: ["verify"],
encrypt: ["encrypt", "wrapKey"],
decrypt: ["decrypt", "unwrapKey"],
};
/**
* Cross-check the 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. Violations throw `CRYPTO_RSA.KEY_OP_NOT_PERMITTED` with a
* `reason` attribute identifying the offending member.
*/
export function enforceJwkPolicy(jwk: RsaJwk, operation: JwkOperation): void {
if (jwk.key_ops !== undefined) {
const allowed = ALLOWED_KEY_OPS_PER_OPERATION[operation];
const intersection = jwk.key_ops.filter((op) => allowed.includes(op));
if (intersection.length === 0) {
reject("key_ops", { keyOps: jwk.key_ops, operation });
}
}
if (jwk.use !== undefined) {
if (
jwk.use === "sig" &&
(operation === "encrypt" || operation === "decrypt")
) {
reject("use", { use: jwk.use, operation });
}
if (
jwk.use === "enc" &&
(operation === "sign" || operation === "verify")
) {
reject("use", { use: jwk.use, operation });
}
}
if (jwk.alg !== undefined) {
if (!RSA_COMPATIBLE_ALGS.has(jwk.alg)) {
reject("alg", { alg: jwk.alg, operation });
}
const isSignOp = operation === "sign" || operation === "verify";
const isEncOp = operation === "encrypt" || operation === "decrypt";
if (isSignOp && !SIGN_ALGS.has(jwk.alg)) {
reject("alg", { alg: jwk.alg, operation });
}
if (isEncOp && !ENCRYPT_ALGS.has(jwk.alg)) {
reject("alg", { alg: jwk.alg, operation });
}
}
}
function reject(
reason: "key_ops" | "use" | "alg",
context: Record<string, unknown>
): never {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED,
"error.crypto-rsa.jwk-policy.violation",
{ context: { reason, ...context } }
);
}

View File

@@ -0,0 +1,93 @@
/**
* Canonical in-memory shape of an RSA public key. `bitLength` is the bit
* length of the modulus (`n.toString(2).length`), used by the
* modulus-length policy gate.
*/
export interface RsaPublicKey {
readonly n: bigint;
readonly e: bigint;
readonly bitLength: number;
}
/**
* Canonical in-memory shape of an RSA private key. CRT parameters
* (`p`, `q`, `dp`, `dq`, `qInv`) are mandatory — every private-form key
* accepted by this package carries them, so the CRT private-key operation
* is always available without runtime branching.
*
* `bitLength` mirrors the public key's modulus bit-length.
*/
export interface RsaPrivateKey {
readonly n: bigint;
readonly e: bigint;
readonly d: bigint;
readonly p: bigint;
readonly q: bigint;
readonly dp: bigint;
readonly dq: bigint;
readonly qInv: bigint;
readonly bitLength: number;
/**
* Multi-prime RSA additional prime factors per RFC 8017 §3 / Appendix
* A.1.2 `OtherPrimeInfos`. Present only on keys generated with
* `primes ∈ {3, 4, 5}` via `generateRsaKeyPair({ primes })`. Two-prime
* keys (the overwhelming majority — every parsed PKCS#1 `version 0`
* key, every PKCS#8 / SPKI / JWK input) carry no `otherPrimeInfos`
* and operate via the standard CRT path on `(p, q, dp, dq, qInv)`.
*
* Each entry is a triple per RFC 8017 §3:
* - `r` — additional prime factor (`r_i`)
* - `d` — `d mod (r_i - 1)` exponent
* - `t` — CRT coefficient (`(r_1 * r_2 * ... * r_(i-1))^-1 mod r_i`)
*/
readonly otherPrimeInfos?: ReadonlyArray<{
readonly r: bigint;
readonly d: bigint;
readonly t: bigint;
}>;
}
/**
* Discriminator for the public-key wire formats this package recognises.
*
* - `pkcs1` — RFC 8017 Appendix A.1.1 `RSAPublicKey`
* - `spki` — RFC 5280 §4.1 `SubjectPublicKeyInfo`
* - `jwk` — RFC 7517 §6.3.1 RSA JWK
*/
export type RsaPublicKeyFormat = "pkcs1" | "spki" | "jwk";
/**
* Discriminator for the private-key wire formats this package recognises.
*
* - `pkcs1` — RFC 8017 Appendix A.1.2 `RSAPrivateKey`
* - `pkcs8` — RFC 5208 / RFC 5958 `PrivateKeyInfo` / `OneAsymmetricKey`
* - `jwk` — RFC 7517 §6.3.2 RSA JWK (full private form)
*/
export type RsaPrivateKeyFormat = "pkcs1" | "pkcs8" | "jwk";
/**
* Hash algorithms accepted by signature/encryption schemes downstream
* (Stages 2-3). Stage 1 carries the type for boundary schemas only.
*/
export type RsaHashAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
/**
* RSA JWK shape per RFC 7517 §6.3 + RFC 7518 §6.3. Stage 1 deserialises
* the binary JWK input through Zod into this canonical type before
* dispatching to the bigint conversions in `parse-jwk.ts`.
*/
export interface RsaJwk {
readonly kty: "RSA";
readonly n: string;
readonly e: string;
readonly d?: string;
readonly p?: string;
readonly q?: string;
readonly dp?: string;
readonly dq?: string;
readonly qi?: string;
readonly use?: string;
readonly key_ops?: readonly string[];
readonly alg?: string;
readonly kid?: string;
}

View File

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

View File

@@ -0,0 +1,4 @@
/**
* RSA-related OIDs used across PKCS#1, PKCS#8, and SPKI parsers/serialisers.
*/
export const OID_RSA_ENCRYPTION = "1.2.840.113549.1.1.1";

View File

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

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
RFC7638_PUBLIC_JWK,
TINY_PRIVATE_KEY,
TINY_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { CryptoRsaError } from "../errors/errors.js";
import { parseRsaPrivateKeyJwk, parseRsaPublicKeyJwk } from "./parse-jwk.js";
import {
serializeRsaPrivateKeyJwk,
serializeRsaPublicKeyJwk,
} from "./serialize-jwk.js";
describe("parseRsaPublicKeyJwk", () => {
it("round-trips the canonical fixture", { timeout: 5000 }, () => {
const jwk = serializeRsaPublicKeyJwk(TINY_PUBLIC_KEY);
const decoded = parseRsaPublicKeyJwk(jwk);
expect(decoded.n).toBe(TINY_PUBLIC_KEY.n);
expect(decoded.e).toBe(TINY_PUBLIC_KEY.e);
});
it("accepts JSON-string input", { timeout: 5000 }, () => {
const json = JSON.stringify(RFC7638_PUBLIC_JWK);
const decoded = parseRsaPublicKeyJwk(json);
expect(decoded.e).toBe(0x10001n);
expect(decoded.bitLength).toBeGreaterThan(2000);
});
it("accepts UTF-8 byte input", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode(
JSON.stringify(RFC7638_PUBLIC_JWK)
);
const decoded = parseRsaPublicKeyJwk(bytes);
expect(decoded.e).toBe(0x10001n);
});
it("rejects non-RSA kty", { timeout: 5000 }, () => {
expect(() =>
parseRsaPublicKeyJwk({
kty: "EC",
n: "AA",
e: "AQAB",
} as unknown as never)
).toThrow(CryptoRsaError);
});
it("rejects malformed JSON", { timeout: 5000 }, () => {
expect(() => parseRsaPublicKeyJwk("not json")).toThrow(CryptoRsaError);
});
it("rejects null JSON payload", { timeout: 5000 }, () => {
expect(() => parseRsaPublicKeyJwk("null")).toThrow(CryptoRsaError);
});
it("rejects primitive JSON payload", { timeout: 5000 }, () => {
expect(() => parseRsaPublicKeyJwk("42")).toThrow(CryptoRsaError);
});
it("rejects missing member", { timeout: 5000 }, () => {
expect(() =>
parseRsaPublicKeyJwk({
kty: "RSA",
n: "AQAB",
} as unknown as never)
).toThrow(CryptoRsaError);
});
it("rejects empty member", { timeout: 5000 }, () => {
expect(() =>
parseRsaPublicKeyJwk({
kty: "RSA",
n: "",
e: "AQAB",
} as unknown as never)
).toThrow(CryptoRsaError);
});
});
describe("parseRsaPrivateKeyJwk", () => {
it("round-trips the canonical fixture", { timeout: 5000 }, () => {
const jwk = serializeRsaPrivateKeyJwk(TINY_PRIVATE_KEY);
const decoded = parseRsaPrivateKeyJwk(jwk);
expect(decoded.d).toBe(TINY_PRIVATE_KEY.d);
expect(decoded.qInv).toBe(TINY_PRIVATE_KEY.qInv);
});
it("rejects partial private form (missing qi)", { timeout: 5000 }, () => {
const jwk = serializeRsaPrivateKeyJwk(TINY_PRIVATE_KEY);
const partial = { ...jwk, qi: undefined };
expect(() =>
parseRsaPrivateKeyJwk(partial as unknown as never)
).toThrow(CryptoRsaError);
});
});

View File

@@ -0,0 +1,95 @@
import { fromBase64Url, os2ip } from "@suaveplan/crypto-utils";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaJwk, RsaPrivateKey, RsaPublicKey } from "./keys.types.js";
import { finalisePrivateKey } from "./parse-pkcs1-private.js";
import { finalisePublicKey } from "./parse-pkcs1-public.js";
/**
* Decode a JWK (RFC 7517 §6.3.1) — either an object literal or a JSON
* string carrying one — into the canonical `RsaJwk` shape used throughout
* this module. Non-RSA `kty` rejects with `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
*/
function normaliseJwk(input: RsaJwk | string | Uint8Array): RsaJwk {
let candidate: unknown = input;
if (typeof input === "string") {
candidate = parseJson(input);
} else if (input instanceof Uint8Array) {
candidate = parseJson(new TextDecoder().decode(input));
}
if (!isObject(candidate)) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.jwk-not-object"
);
}
const obj = candidate as Record<string, unknown>;
if (obj["kty"] !== "RSA") {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.unsupported-format.jwk-kty",
{ context: { kty: String(obj["kty"]) } }
);
}
return obj as unknown as RsaJwk;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch (cause) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.jwk-bad-json",
{ cause }
);
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
function decodeMember(jwk: RsaJwk, name: keyof RsaJwk): bigint {
const raw = jwk[name];
if (typeof raw !== "string" || raw.length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.jwk-missing-member",
{ context: { member: name } }
);
}
const bytes = fromBase64Url(raw);
return os2ip(bytes);
}
/**
* Parse an RSA public JWK per RFC 7517 §6.3.1.
*/
export function parseRsaPublicKeyJwk(
input: RsaJwk | string | Uint8Array
): RsaPublicKey {
const jwk = normaliseJwk(input);
const n = decodeMember(jwk, "n");
const e = decodeMember(jwk, "e");
return finalisePublicKey(n, e);
}
/**
* Parse an RSA private JWK per RFC 7517 §6.3.2. Every CRT field
* (`d`, `p`, `q`, `dp`, `dq`, `qi`) is mandatory — partial private-form
* keys reject with `CRYPTO_RSA.INVALID_KEY`.
*/
export function parseRsaPrivateKeyJwk(
input: RsaJwk | string | Uint8Array
): RsaPrivateKey {
const jwk = normaliseJwk(input);
const n = decodeMember(jwk, "n");
const e = decodeMember(jwk, "e");
const d = decodeMember(jwk, "d");
const p = decodeMember(jwk, "p");
const q = decodeMember(jwk, "q");
const dp = decodeMember(jwk, "dp");
const dq = decodeMember(jwk, "dq");
const qInv = decodeMember(jwk, "qi");
return finalisePrivateKey({ n, e, d, p, q, dp, dq, qInv });
}

View File

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

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TINY_PRIVATE_KEY } from "../__fixtures__/test-keys.js";
import { encodeInteger, encodeSequence } from "../asn1/writer.js";
import { CryptoRsaError } from "../errors/errors.js";
import {
finalisePrivateKey,
parseRsaPrivateKeyPkcs1,
} from "./parse-pkcs1-private.js";
import { serializeRsaPrivateKeyPkcs1 } from "./serialize-pkcs1-private.js";
describe("parseRsaPrivateKeyPkcs1", () => {
it("round-trips the canonical fixture", { timeout: 5000 }, () => {
const der = serializeRsaPrivateKeyPkcs1(TINY_PRIVATE_KEY);
const decoded = parseRsaPrivateKeyPkcs1(der);
expect(decoded.n).toBe(TINY_PRIVATE_KEY.n);
expect(decoded.e).toBe(TINY_PRIVATE_KEY.e);
expect(decoded.d).toBe(TINY_PRIVATE_KEY.d);
expect(decoded.p).toBe(TINY_PRIVATE_KEY.p);
expect(decoded.q).toBe(TINY_PRIVATE_KEY.q);
expect(decoded.dp).toBe(TINY_PRIVATE_KEY.dp);
expect(decoded.dq).toBe(TINY_PRIVATE_KEY.dq);
expect(decoded.qInv).toBe(TINY_PRIVATE_KEY.qInv);
expect(decoded.bitLength).toBe(TINY_PRIVATE_KEY.bitLength);
});
it("rejects unsupported version", { timeout: 5000 }, () => {
const bytes = encodeSequence(
encodeInteger(2n),
encodeInteger(TINY_PRIVATE_KEY.n),
encodeInteger(TINY_PRIVATE_KEY.e),
encodeInteger(TINY_PRIVATE_KEY.d),
encodeInteger(TINY_PRIVATE_KEY.p),
encodeInteger(TINY_PRIVATE_KEY.q),
encodeInteger(TINY_PRIVATE_KEY.dp),
encodeInteger(TINY_PRIVATE_KEY.dq),
encodeInteger(TINY_PRIVATE_KEY.qInv)
);
expect(() => parseRsaPrivateKeyPkcs1(bytes)).toThrow(CryptoRsaError);
});
it("rejects multi-prime by version flag", { timeout: 5000 }, () => {
const bytes = encodeSequence(
encodeInteger(1n),
encodeInteger(TINY_PRIVATE_KEY.n),
encodeInteger(TINY_PRIVATE_KEY.e),
encodeInteger(TINY_PRIVATE_KEY.d),
encodeInteger(TINY_PRIVATE_KEY.p),
encodeInteger(TINY_PRIVATE_KEY.q),
encodeInteger(TINY_PRIVATE_KEY.dp),
encodeInteger(TINY_PRIVATE_KEY.dq),
encodeInteger(TINY_PRIVATE_KEY.qInv)
);
expect(() => parseRsaPrivateKeyPkcs1(bytes)).toThrow(CryptoRsaError);
});
it("rejects multi-prime detected by trailing SEQUENCE", {
timeout: 5000,
}, () => {
const bytes = encodeSequence(
encodeInteger(0n),
encodeInteger(TINY_PRIVATE_KEY.n),
encodeInteger(TINY_PRIVATE_KEY.e),
encodeInteger(TINY_PRIVATE_KEY.d),
encodeInteger(TINY_PRIVATE_KEY.p),
encodeInteger(TINY_PRIVATE_KEY.q),
encodeInteger(TINY_PRIVATE_KEY.dp),
encodeInteger(TINY_PRIVATE_KEY.dq),
encodeInteger(TINY_PRIVATE_KEY.qInv),
encodeSequence(encodeInteger(1n))
);
expect(() => parseRsaPrivateKeyPkcs1(bytes)).toThrow(CryptoRsaError);
});
it("rejects version=1 multi-prime with empty otherPrimeInfos sequence", {
timeout: 5000,
}, () => {
// version=1 declares multi-prime, but the otherPrimeInfos
// SEQUENCE is empty — RFC 8017 Appendix A.1.2 forbids this.
const bytes = encodeSequence(
encodeInteger(1n),
encodeInteger(TINY_PRIVATE_KEY.n),
encodeInteger(TINY_PRIVATE_KEY.e),
encodeInteger(TINY_PRIVATE_KEY.d),
encodeInteger(TINY_PRIVATE_KEY.p),
encodeInteger(TINY_PRIVATE_KEY.q),
encodeInteger(TINY_PRIVATE_KEY.dp),
encodeInteger(TINY_PRIVATE_KEY.dq),
encodeInteger(TINY_PRIVATE_KEY.qInv),
encodeSequence() // empty SEQUENCE OF OtherPrimeInfo
);
expect(() => parseRsaPrivateKeyPkcs1(bytes)).toThrow(CryptoRsaError);
});
});
describe("finalisePrivateKey", () => {
it("rejects each non-positive field", { timeout: 5000 }, () => {
const valid = {
n: 1n,
e: 3n,
d: 1n,
p: 1n,
q: 1n,
dp: 1n,
dq: 1n,
qInv: 1n,
};
const fields: Array<keyof typeof valid> = [
"n",
"e",
"d",
"p",
"q",
"dp",
"dq",
"qInv",
];
for (const field of fields) {
expect(() => finalisePrivateKey({ ...valid, [field]: 0n })).toThrow(
CryptoRsaError
);
}
});
});

View File

@@ -0,0 +1,109 @@
import { DerReader, TAG_SEQUENCE } from "../asn1/reader.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPrivateKey } from "./keys.types.js";
/**
* Parse a DER-encoded PKCS#1 `RSAPrivateKey ::= SEQUENCE { version, modulus,
* publicExponent, privateExponent, prime1, prime2, exponent1, exponent2,
* coefficient, otherPrimeInfos OPTIONAL }` per RFC 8017 Appendix A.1.2.
*
* Multi-prime RSA (`version === 1n` with non-empty `otherPrimeInfos`) is
* recognised but rejected with `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
*/
export function parseRsaPrivateKeyPkcs1(bytes: Uint8Array): RsaPrivateKey {
const reader = new DerReader(bytes);
const seq = reader.readSequence();
const version = seq.readInteger();
if (version !== 0n && version !== 1n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.bad-version",
{ context: { version: version.toString() } }
);
}
const n = seq.readInteger();
const e = seq.readInteger();
const d = seq.readInteger();
const p = seq.readInteger();
const q = seq.readInteger();
const dp = seq.readInteger();
const dq = seq.readInteger();
const qInv = seq.readInteger();
let otherPrimeInfos:
| ReadonlyArray<{ r: bigint; d: bigint; t: bigint }>
| undefined;
if (version === 1n) {
if (seq.peekTag() !== TAG_SEQUENCE) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.multi-prime-missing"
);
}
const others = seq.readSequence();
const list: { r: bigint; d: bigint; t: bigint }[] = [];
while (others.peekTag() !== undefined) {
const info = others.readSequence();
const r = info.readInteger();
const dr = info.readInteger();
const t = info.readInteger();
info.assertEnd();
list.push({ r, d: dr, t });
}
if (list.length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.multi-prime-empty"
);
}
otherPrimeInfos = list;
} else if (seq.peekTag() === TAG_SEQUENCE) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.multi-prime-version-mismatch"
);
}
seq.assertEnd();
reader.assertEnd();
const base = finalisePrivateKey({ n, e, d, p, q, dp, dq, qInv });
return otherPrimeInfos === undefined ? base : { ...base, otherPrimeInfos };
}
interface PrivateInputs {
n: bigint;
e: bigint;
d: bigint;
p: bigint;
q: bigint;
dp: bigint;
dq: bigint;
qInv: bigint;
}
/**
* Internal: validate every CRT field is positive and compute `bitLength`.
* Used by PKCS#8 / JWK parsers as well as the PKCS#1 private path.
*/
export function finalisePrivateKey(inputs: PrivateInputs): RsaPrivateKey {
const fields: Array<keyof PrivateInputs> = [
"n",
"e",
"d",
"p",
"q",
"dp",
"dq",
"qInv",
];
for (const field of fields) {
const value = inputs[field];
if (value <= 0n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.private-field",
{ context: { field } }
);
}
}
const bitLength = inputs.n.toString(2).length;
return { ...inputs, bitLength };
}

View File

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

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TINY_PUBLIC_KEY } from "../__fixtures__/test-keys.js";
import { encodeInteger, encodeSequence } from "../asn1/writer.js";
import { CryptoRsaError } from "../errors/errors.js";
import {
finalisePublicKey,
parseRsaPublicKeyPkcs1,
} from "./parse-pkcs1-public.js";
import { serializeRsaPublicKeyPkcs1 } from "./serialize-pkcs1-public.js";
describe("parseRsaPublicKeyPkcs1", () => {
it("round-trips the canonical fixture", { timeout: 5000 }, () => {
const der = serializeRsaPublicKeyPkcs1(TINY_PUBLIC_KEY);
const decoded = parseRsaPublicKeyPkcs1(der);
expect(decoded.n).toBe(TINY_PUBLIC_KEY.n);
expect(decoded.e).toBe(TINY_PUBLIC_KEY.e);
expect(decoded.bitLength).toBe(TINY_PUBLIC_KEY.bitLength);
});
it("rejects an even public exponent", { timeout: 5000 }, () => {
const bytes = encodeSequence(
encodeInteger(TINY_PUBLIC_KEY.n),
encodeInteger(0x10000n)
);
expect(() => parseRsaPublicKeyPkcs1(bytes)).toThrow(CryptoRsaError);
});
it("rejects an exponent < 3", { timeout: 5000 }, () => {
const bytes = encodeSequence(
encodeInteger(TINY_PUBLIC_KEY.n),
encodeInteger(1n)
);
expect(() => parseRsaPublicKeyPkcs1(bytes)).toThrow(CryptoRsaError);
});
it("rejects malformed DER", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x30, 0x02, 0xff, 0xff]);
expect(() => parseRsaPublicKeyPkcs1(bytes)).toThrow(CryptoRsaError);
});
it("rejects non-positive modulus via finalisePublicKey", {
timeout: 5000,
}, () => {
expect(() => finalisePublicKey(0n, 65537n)).toThrow(CryptoRsaError);
});
it("rejects trailing bytes after the SEQUENCE", { timeout: 5000 }, () => {
const valid = serializeRsaPublicKeyPkcs1(TINY_PUBLIC_KEY);
const padded = new Uint8Array(valid.length + 1);
padded.set(valid);
padded[padded.length - 1] = 0x00;
expect(() => parseRsaPublicKeyPkcs1(padded)).toThrow(CryptoRsaError);
});
});

View File

@@ -0,0 +1,48 @@
import { DerReader } from "../asn1/reader.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPublicKey } from "./keys.types.js";
/**
* Parse a DER-encoded PKCS#1 `RSAPublicKey ::= SEQUENCE { modulus INTEGER,
* publicExponent INTEGER }` per RFC 8017 Appendix A.1.1.
*
* Validates that the public exponent is odd and `>= 3`, matching the
* conformance requirement in the spec delta.
*/
export function parseRsaPublicKeyPkcs1(bytes: Uint8Array): RsaPublicKey {
const reader = new DerReader(bytes);
const seq = reader.readSequence();
const n = seq.readInteger();
const e = seq.readInteger();
seq.assertEnd();
reader.assertEnd();
return finalisePublicKey(n, e);
}
/**
* Internal: validate `(n, e)` and compute `bitLength`. Used by SPKI / JWK
* parsers as well as the PKCS#1 path.
*/
export function finalisePublicKey(n: bigint, e: bigint): RsaPublicKey {
if (n <= 0n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.modulus"
);
}
if (e < 3n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.exponent-too-small",
{ context: { e: e.toString() } }
);
}
if ((e & 1n) === 0n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.exponent-even"
);
}
const bitLength = n.toString(2).length;
return { n, e, bitLength };
}

View File

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

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TINY_PRIVATE_KEY } from "../__fixtures__/test-keys.js";
import {
encodeContextSpecific,
encodeInteger,
encodeNull,
encodeObjectIdentifier,
encodeOctetString,
encodeSequence,
} from "../asn1/writer.js";
import { CryptoRsaError } from "../errors/errors.js";
import { OID_RSA_ENCRYPTION } from "./oid.js";
import { parseRsaPrivateKeyPkcs8 } from "./parse-pkcs8.js";
import { serializeRsaPrivateKeyPkcs1 } from "./serialize-pkcs1-private.js";
import { serializeRsaPrivateKeyPkcs8 } from "./serialize-pkcs8.js";
describe("parseRsaPrivateKeyPkcs8", () => {
it("round-trips canonical PKCS#8", { timeout: 5000 }, () => {
const der = serializeRsaPrivateKeyPkcs8(TINY_PRIVATE_KEY);
const decoded = parseRsaPrivateKeyPkcs8(der);
expect(decoded.n).toBe(TINY_PRIVATE_KEY.n);
expect(decoded.d).toBe(TINY_PRIVATE_KEY.d);
});
it("accepts RFC 5958 OneAsymmetricKey trailing [1] publicKey", {
timeout: 5000,
}, () => {
const algorithm = encodeSequence(
encodeObjectIdentifier(OID_RSA_ENCRYPTION),
encodeNull()
);
const inner = encodeOctetString(
serializeRsaPrivateKeyPkcs1(TINY_PRIVATE_KEY)
);
const trailing = encodeContextSpecific(1, new Uint8Array([0x00]));
const der = encodeSequence(
encodeInteger(1n),
algorithm,
inner,
trailing
);
const decoded = parseRsaPrivateKeyPkcs8(der);
expect(decoded.n).toBe(TINY_PRIVATE_KEY.n);
});
it("rejects bad version", { timeout: 5000 }, () => {
const algorithm = encodeSequence(
encodeObjectIdentifier(OID_RSA_ENCRYPTION),
encodeNull()
);
const inner = encodeOctetString(
serializeRsaPrivateKeyPkcs1(TINY_PRIVATE_KEY)
);
const der = encodeSequence(encodeInteger(2n), algorithm, inner);
expect(() => parseRsaPrivateKeyPkcs8(der)).toThrow(CryptoRsaError);
});
it("rejects non-RSA algorithm OID", { timeout: 5000 }, () => {
const algorithm = encodeSequence(
encodeObjectIdentifier("1.2.840.10045.2.1"),
encodeNull()
);
const inner = encodeOctetString(
serializeRsaPrivateKeyPkcs1(TINY_PRIVATE_KEY)
);
const der = encodeSequence(encodeInteger(0n), algorithm, inner);
expect(() => parseRsaPrivateKeyPkcs8(der)).toThrow(CryptoRsaError);
});
it("rejects non-context-specific trailing data", { timeout: 5000 }, () => {
const algorithm = encodeSequence(
encodeObjectIdentifier(OID_RSA_ENCRYPTION),
encodeNull()
);
const inner = encodeOctetString(
serializeRsaPrivateKeyPkcs1(TINY_PRIVATE_KEY)
);
// Append a bare INTEGER tag — not [0] / [1] context-specific.
const der = encodeSequence(
encodeInteger(0n),
algorithm,
inner,
encodeInteger(7n)
);
expect(() => parseRsaPrivateKeyPkcs8(der)).toThrow(CryptoRsaError);
});
});

View File

@@ -0,0 +1,57 @@
import { DerReader, TAG_CONTEXT_SPECIFIC_BASE } from "../asn1/reader.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPrivateKey } from "./keys.types.js";
import { OID_RSA_ENCRYPTION } from "./oid.js";
import { parseRsaPrivateKeyPkcs1 } from "./parse-pkcs1-private.js";
/**
* Parse a DER-encoded PKCS#8 `PrivateKeyInfo` (RFC 5208 §5) — also accepts
* the RFC 5958 `OneAsymmetricKey` shape, which is structurally identical
* except for the optional `[1]` `publicKey` and `[0]` `attributes` fields
* that appear after `privateKey`.
*
* The inner `privateKey OCTET STRING` carries a PKCS#1 `RSAPrivateKey`
* which is delegated to the PKCS#1 private-key reader.
*
* Non-RSA OIDs throw `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
*/
export function parseRsaPrivateKeyPkcs8(bytes: Uint8Array): RsaPrivateKey {
const reader = new DerReader(bytes);
const outer = reader.readSequence();
const version = outer.readInteger();
if (version !== 0n && version !== 1n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.pkcs8-version",
{ context: { version: version.toString() } }
);
}
const alg = outer.readSequence();
const oid = alg.readObjectIdentifier();
if (oid !== OID_RSA_ENCRYPTION) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.unsupported-format.pkcs8-oid",
{ context: { oid } }
);
}
alg.readNull();
alg.assertEnd();
const inner = outer.readOctetString();
// RFC 5958 OneAsymmetricKey allows optional [0] attributes and [1]
// publicKey trailing fields; consume them so assertEnd() succeeds.
while (outer.hasMore()) {
const tag = outer.peekTag() as number;
if ((tag & 0xe0) !== TAG_CONTEXT_SPECIFIC_BASE) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.pkcs8-trailing"
);
}
const tagNumber = tag & 0x1f;
outer.readContextSpecific(tagNumber);
}
outer.assertEnd();
reader.assertEnd();
return parseRsaPrivateKeyPkcs1(inner);
}

View File

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

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TINY_PUBLIC_KEY } from "../__fixtures__/test-keys.js";
import {
encodeBitString,
encodeInteger,
encodeNull,
encodeObjectIdentifier,
encodeSequence,
} from "../asn1/writer.js";
import { CryptoRsaError } from "../errors/errors.js";
import { OID_RSA_ENCRYPTION } from "./oid.js";
import { parseRsaPublicKeySpki } from "./parse-spki.js";
import { serializeRsaPublicKeyPkcs1 } from "./serialize-pkcs1-public.js";
import { serializeRsaPublicKeySpki } from "./serialize-spki.js";
describe("parseRsaPublicKeySpki", () => {
it("accepts an rsaEncryption SPKI", { timeout: 5000 }, () => {
const der = serializeRsaPublicKeySpki(TINY_PUBLIC_KEY);
const key = parseRsaPublicKeySpki(der);
expect(key.n).toBe(TINY_PUBLIC_KEY.n);
expect(key.e).toBe(TINY_PUBLIC_KEY.e);
});
it("rejects non-RSA OID", { timeout: 5000 }, () => {
const algorithm = encodeSequence(
encodeObjectIdentifier("1.2.840.10045.2.1"), // EC
encodeNull()
);
const subjectPublicKey = encodeBitString(
serializeRsaPublicKeyPkcs1(TINY_PUBLIC_KEY)
);
const der = encodeSequence(algorithm, subjectPublicKey);
expect(() => parseRsaPublicKeySpki(der)).toThrow(CryptoRsaError);
});
it("rejects missing NULL algorithm parameters", { timeout: 5000 }, () => {
const algorithm = encodeSequence(
encodeObjectIdentifier(OID_RSA_ENCRYPTION),
encodeInteger(0n)
);
const subjectPublicKey = encodeBitString(
serializeRsaPublicKeyPkcs1(TINY_PUBLIC_KEY)
);
const der = encodeSequence(algorithm, subjectPublicKey);
expect(() => parseRsaPublicKeySpki(der)).toThrow(CryptoRsaError);
});
});

View File

@@ -0,0 +1,36 @@
import { DerReader } from "../asn1/reader.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPublicKey } from "./keys.types.js";
import { OID_RSA_ENCRYPTION } from "./oid.js";
import { parseRsaPublicKeyPkcs1 } from "./parse-pkcs1-public.js";
/**
* Parse a DER-encoded `SubjectPublicKeyInfo` per RFC 5280 §4.1 + Appendix A.
*
* The outer SEQUENCE wraps `algorithm AlgorithmIdentifier` (where the OID
* MUST be `1.2.840.113549.1.1.1`) and `subjectPublicKey BIT STRING`. The
* bit-string body carries a PKCS#1 `RSAPublicKey` which is delegated to the
* PKCS#1 reader.
*
* Non-RSA OIDs throw `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
*/
export function parseRsaPublicKeySpki(bytes: Uint8Array): RsaPublicKey {
const reader = new DerReader(bytes);
const outer = reader.readSequence();
const alg = outer.readSequence();
const oid = alg.readObjectIdentifier();
if (oid !== OID_RSA_ENCRYPTION) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.unsupported-format.spki-oid",
{ context: { oid } }
);
}
// RFC 3279 §2.3.1 mandates NULL parameters for rsaEncryption.
alg.readNull();
alg.assertEnd();
const inner = outer.readBitString();
outer.assertEnd();
reader.assertEnd();
return parseRsaPublicKeyPkcs1(inner);
}

View File

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

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoRsaError } from "../errors/errors.js";
import { parseRsaPrivateKey, parseRsaPublicKey } from "./parse.js";
describe("parseRsaPublicKey façade", () => {
it("rejects non-Uint8Array input for binary formats", {
timeout: 5000,
}, () => {
expect(() =>
parseRsaPublicKey("not bytes", { format: "pkcs1" })
).toThrow(CryptoRsaError);
expect(() =>
parseRsaPublicKey("not bytes", { format: "spki" })
).toThrow(CryptoRsaError);
});
it("rejects unknown public-key format at the dispatcher", {
timeout: 5000,
}, () => {
expect(() =>
parseRsaPublicKey(new Uint8Array(0), {
format: "unknown" as never,
})
).toThrow(CryptoRsaError);
});
});
describe("parseRsaPrivateKey façade", () => {
it("rejects non-Uint8Array input for binary formats", {
timeout: 5000,
}, () => {
expect(() =>
parseRsaPrivateKey("not bytes", { format: "pkcs1" })
).toThrow(CryptoRsaError);
expect(() =>
parseRsaPrivateKey("not bytes", { format: "pkcs8" })
).toThrow(CryptoRsaError);
});
it("rejects unknown private-key format at the dispatcher", {
timeout: 5000,
}, () => {
expect(() =>
parseRsaPrivateKey(new Uint8Array(0), {
format: "unknown" as never,
})
).toThrow(CryptoRsaError);
});
});

View File

@@ -0,0 +1,79 @@
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type {
RsaJwk,
RsaPrivateKey,
RsaPrivateKeyFormat,
RsaPublicKey,
RsaPublicKeyFormat,
} from "./keys.types.js";
import { parseRsaPrivateKeyJwk, parseRsaPublicKeyJwk } from "./parse-jwk.js";
import { parseRsaPrivateKeyPkcs1 } from "./parse-pkcs1-private.js";
import { parseRsaPublicKeyPkcs1 } from "./parse-pkcs1-public.js";
import { parseRsaPrivateKeyPkcs8 } from "./parse-pkcs8.js";
import { parseRsaPublicKeySpki } from "./parse-spki.js";
export interface ParseRsaPublicKeyOptions {
readonly format: RsaPublicKeyFormat;
}
export interface ParseRsaPrivateKeyOptions {
readonly format: RsaPrivateKeyFormat;
}
/**
* Façade dispatcher for public-key parsing. Selects between PKCS#1, SPKI,
* and JWK based on the `format` discriminator. JWK accepts either a JS
* object literal or a JSON/byte payload.
*/
export function parseRsaPublicKey(
input: Uint8Array | RsaJwk | string,
options: ParseRsaPublicKeyOptions
): RsaPublicKey {
switch (options.format) {
case "pkcs1":
return parseRsaPublicKeyPkcs1(requireBytes(input));
case "spki":
return parseRsaPublicKeySpki(requireBytes(input));
case "jwk":
return parseRsaPublicKeyJwk(input);
default:
return assertNeverFormat(options.format);
}
}
/**
* Façade dispatcher for private-key parsing.
*/
export function parseRsaPrivateKey(
input: Uint8Array | RsaJwk | string,
options: ParseRsaPrivateKeyOptions
): RsaPrivateKey {
switch (options.format) {
case "pkcs1":
return parseRsaPrivateKeyPkcs1(requireBytes(input));
case "pkcs8":
return parseRsaPrivateKeyPkcs8(requireBytes(input));
case "jwk":
return parseRsaPrivateKeyJwk(input);
default:
return assertNeverFormat(options.format);
}
}
function requireBytes(input: Uint8Array | RsaJwk | string): Uint8Array {
if (input instanceof Uint8Array) {
return input;
}
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.expected-bytes"
);
}
function assertNeverFormat(value: never): never {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.unsupported-format.unknown",
{ context: { value: String(value) } }
);
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
TINY_PRIVATE_KEY,
TINY_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import type { RsaJwk } from "./keys.types.js";
import { parseRsaPrivateKey, parseRsaPublicKey } from "./parse.js";
import { serializeRsaPrivateKey, serializeRsaPublicKey } from "./serialize.js";
describe("public key round-trip across every format", () => {
for (const format of ["pkcs1", "spki", "jwk"] as const) {
it(`round-trips through format=${format}`, { timeout: 5000 }, () => {
const serialised = serializeRsaPublicKey(TINY_PUBLIC_KEY, {
format,
});
const parsed = parseRsaPublicKey(
serialised as Uint8Array | RsaJwk,
{ format }
);
expect(parsed.n).toBe(TINY_PUBLIC_KEY.n);
expect(parsed.e).toBe(TINY_PUBLIC_KEY.e);
});
}
});
describe("private key round-trip across every format", () => {
for (const format of ["pkcs1", "pkcs8", "jwk"] as const) {
it(`round-trips through format=${format}`, { timeout: 5000 }, () => {
const serialised = serializeRsaPrivateKey(TINY_PRIVATE_KEY, {
format,
});
const parsed = parseRsaPrivateKey(
serialised as Uint8Array | RsaJwk,
{ format }
);
expect(parsed.n).toBe(TINY_PRIVATE_KEY.n);
expect(parsed.d).toBe(TINY_PRIVATE_KEY.d);
expect(parsed.qInv).toBe(TINY_PRIVATE_KEY.qInv);
});
}
});

View File

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

View File

@@ -0,0 +1,51 @@
import { toBase64Url } from "@suaveplan/crypto-utils";
import { i2osp } from "../math/i2osp.js";
import type { RsaJwk, RsaPrivateKey, RsaPublicKey } from "./keys.types.js";
/**
* Encode a non-negative bigint as the minimum-length big-endian byte
* string suitable for JWK base64url members. JWA/RFC 7518 §6.3 requires
* unsigned big-endian octets with no leading-zero padding except where
* the value itself is zero (which never occurs for RSA key fields).
*/
function bigintToMinimalBytes(value: bigint): Uint8Array {
const hex = value.toString(16);
const padded = hex.length % 2 === 0 ? hex : `0${hex}`;
const byteLen = padded.length / 2;
return i2osp(value, byteLen);
}
function encode(value: bigint): string {
return toBase64Url(bigintToMinimalBytes(value));
}
/**
* Serialise an `RsaPublicKey` to an RSA JWK per RFC 7517 §6.3.1, with
* base64url-encoded `n` and `e` per RFC 7515 §2 (no padding, URL-safe
* alphabet).
*/
export function serializeRsaPublicKeyJwk(key: RsaPublicKey): RsaJwk {
return {
kty: "RSA",
n: encode(key.n),
e: encode(key.e),
};
}
/**
* Serialise an `RsaPrivateKey` to an RSA JWK per RFC 7517 §6.3.2,
* including every CRT parameter.
*/
export function serializeRsaPrivateKeyJwk(key: RsaPrivateKey): RsaJwk {
return {
kty: "RSA",
n: encode(key.n),
e: encode(key.e),
d: encode(key.d),
p: encode(key.p),
q: encode(key.q),
dp: encode(key.dp),
dq: encode(key.dq),
qi: encode(key.qInv),
};
}

View File

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

View File

@@ -0,0 +1,53 @@
import { encodeInteger, encodeSequence } from "../asn1/writer.js";
import type { RsaPrivateKey } from "./keys.types.js";
/**
* Serialise an `RsaPrivateKey` to a DER-encoded PKCS#1 `RSAPrivateKey`
* per RFC 8017 Appendix A.1.2.
*
* Two-prime keys (no `otherPrimeInfos` field) emit `version === 0` with
* the standard 9-field SEQUENCE — byte-identical to the Stage 1 path.
*
* Multi-prime keys (`otherPrimeInfos` non-empty, length 1–3 for 3/4/5
* total primes) emit `version === 1` with the standard fields followed
* by an `OtherPrimeInfos` SEQUENCE OF `OtherPrimeInfo`. Each
* `OtherPrimeInfo` is `SEQUENCE { prime INTEGER, exponent INTEGER,
* coefficient INTEGER }` per Appendix A.1.2.
*/
export function serializeRsaPrivateKeyPkcs1(key: RsaPrivateKey): Uint8Array {
const others = key.otherPrimeInfos;
if (others === undefined || others.length === 0) {
return encodeSequence(
encodeInteger(0n),
encodeInteger(key.n),
encodeInteger(key.e),
encodeInteger(key.d),
encodeInteger(key.p),
encodeInteger(key.q),
encodeInteger(key.dp),
encodeInteger(key.dq),
encodeInteger(key.qInv)
);
}
const otherSeq = encodeSequence(
...others.map((info) =>
encodeSequence(
encodeInteger(info.r),
encodeInteger(info.d),
encodeInteger(info.t)
)
)
);
return encodeSequence(
encodeInteger(1n),
encodeInteger(key.n),
encodeInteger(key.e),
encodeInteger(key.d),
encodeInteger(key.p),
encodeInteger(key.q),
encodeInteger(key.dp),
encodeInteger(key.dq),
encodeInteger(key.qInv),
otherSeq
);
}

View File

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

View File

@@ -0,0 +1,11 @@
import { encodeInteger, encodeSequence } from "../asn1/writer.js";
import type { RsaPublicKey } from "./keys.types.js";
/**
* Serialise an `RsaPublicKey` to a DER-encoded PKCS#1 `RSAPublicKey ::=
* SEQUENCE { modulus INTEGER, publicExponent INTEGER }` per RFC 8017
* Appendix A.1.1.
*/
export function serializeRsaPublicKeyPkcs1(key: RsaPublicKey): Uint8Array {
return encodeSequence(encodeInteger(key.n), encodeInteger(key.e));
}

View File

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

View File

@@ -0,0 +1,28 @@
import {
encodeInteger,
encodeNull,
encodeObjectIdentifier,
encodeOctetString,
encodeSequence,
} from "../asn1/writer.js";
import type { RsaPrivateKey } from "./keys.types.js";
import { OID_RSA_ENCRYPTION } from "./oid.js";
import { serializeRsaPrivateKeyPkcs1 } from "./serialize-pkcs1-private.js";
/**
* Serialise an `RsaPrivateKey` to a DER-encoded PKCS#8 `PrivateKeyInfo`
* (RFC 5208 §5) wrapping a PKCS#1 `RSAPrivateKey`. Emits the canonical
* `version 0` (RFC 5208) form.
*/
export function serializeRsaPrivateKeyPkcs8(key: RsaPrivateKey): Uint8Array {
const algorithm = encodeSequence(
encodeObjectIdentifier(OID_RSA_ENCRYPTION),
encodeNull()
);
const inner = serializeRsaPrivateKeyPkcs1(key);
return encodeSequence(
encodeInteger(0n),
algorithm,
encodeOctetString(inner)
);
}

View File

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

View File

@@ -0,0 +1,22 @@
import {
encodeBitString,
encodeNull,
encodeObjectIdentifier,
encodeSequence,
} from "../asn1/writer.js";
import type { RsaPublicKey } from "./keys.types.js";
import { OID_RSA_ENCRYPTION } from "./oid.js";
import { serializeRsaPublicKeyPkcs1 } from "./serialize-pkcs1-public.js";
/**
* Serialise an `RsaPublicKey` to a DER-encoded `SubjectPublicKeyInfo`
* (RFC 5280 §4.1) wrapping a PKCS#1 `RSAPublicKey`.
*/
export function serializeRsaPublicKeySpki(key: RsaPublicKey): Uint8Array {
const algorithm = encodeSequence(
encodeObjectIdentifier(OID_RSA_ENCRYPTION),
encodeNull()
);
const subjectPublicKey = encodeBitString(serializeRsaPublicKeyPkcs1(key));
return encodeSequence(algorithm, subjectPublicKey);
}

View File

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

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
TINY_PRIVATE_KEY,
TINY_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { CryptoRsaError } from "../errors/errors.js";
import { serializeRsaPrivateKey, serializeRsaPublicKey } from "./serialize.js";
describe("serializeRsaPublicKey façade", () => {
it("rejects unknown format", { timeout: 5000 }, () => {
expect(() =>
serializeRsaPublicKey(TINY_PUBLIC_KEY, {
format: "unknown" as never,
})
).toThrow(CryptoRsaError);
});
});
describe("serializeRsaPrivateKey façade", () => {
it("rejects unknown format", { timeout: 5000 }, () => {
expect(() =>
serializeRsaPrivateKey(TINY_PRIVATE_KEY, {
format: "unknown" as never,
})
).toThrow(CryptoRsaError);
});
});

View File

@@ -0,0 +1,73 @@
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type {
RsaJwk,
RsaPrivateKey,
RsaPrivateKeyFormat,
RsaPublicKey,
RsaPublicKeyFormat,
} from "./keys.types.js";
import {
serializeRsaPrivateKeyJwk,
serializeRsaPublicKeyJwk,
} from "./serialize-jwk.js";
import { serializeRsaPrivateKeyPkcs1 } from "./serialize-pkcs1-private.js";
import { serializeRsaPublicKeyPkcs1 } from "./serialize-pkcs1-public.js";
import { serializeRsaPrivateKeyPkcs8 } from "./serialize-pkcs8.js";
import { serializeRsaPublicKeySpki } from "./serialize-spki.js";
export interface SerializeRsaPublicKeyOptions {
readonly format: RsaPublicKeyFormat;
}
export interface SerializeRsaPrivateKeyOptions {
readonly format: RsaPrivateKeyFormat;
}
/**
* Façade dispatcher for public-key serialisation. Returns `Uint8Array` for
* binary formats (`pkcs1`, `spki`) and a structured `RsaJwk` object for the
* JWK format.
*/
export function serializeRsaPublicKey(
key: RsaPublicKey,
options: SerializeRsaPublicKeyOptions
): Uint8Array | RsaJwk {
switch (options.format) {
case "pkcs1":
return serializeRsaPublicKeyPkcs1(key);
case "spki":
return serializeRsaPublicKeySpki(key);
case "jwk":
return serializeRsaPublicKeyJwk(key);
default:
return assertNeverFormat(options.format);
}
}
/**
* Façade dispatcher for private-key serialisation. Returns `Uint8Array` for
* binary formats (`pkcs1`, `pkcs8`) and a structured `RsaJwk` for JWK.
*/
export function serializeRsaPrivateKey(
key: RsaPrivateKey,
options: SerializeRsaPrivateKeyOptions
): Uint8Array | RsaJwk {
switch (options.format) {
case "pkcs1":
return serializeRsaPrivateKeyPkcs1(key);
case "pkcs8":
return serializeRsaPrivateKeyPkcs8(key);
case "jwk":
return serializeRsaPrivateKeyJwk(key);
default:
return assertNeverFormat(options.format);
}
}
function assertNeverFormat(value: never): never {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.unsupported-format.unknown",
{ context: { value: String(value) } }
);
}