Initial Commit
This commit is contained in:
43
packages/security-stack/crypto/crypto-rsa/src/keys/index.ts
Normal file
43
packages/security-stack/crypto/crypto-rsa/src/keys/index.ts
Normal 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";
|
||||
@@ -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,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"
|
||||
);
|
||||
});
|
||||
});
|
||||
103
packages/security-stack/crypto/crypto-rsa/src/keys/jwk-policy.ts
Normal file
103
packages/security-stack/crypto/crypto-rsa/src/keys/jwk-policy.ts
Normal 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 } }
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
53
packages/security-stack/crypto/crypto-rsa/src/keys/oid.md
Normal file
53
packages/security-stack/crypto/crypto-rsa/src/keys/oid.md
Normal 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.
|
||||
@@ -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";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
62
packages/security-stack/crypto/crypto-rsa/src/keys/parse.md
Normal file
62
packages/security-stack/crypto/crypto-rsa/src/keys/parse.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
79
packages/security-stack/crypto/crypto-rsa/src/keys/parse.ts
Normal file
79
packages/security-stack/crypto/crypto-rsa/src/keys/parse.ts
Normal 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) } }
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,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),
|
||||
};
|
||||
}
|
||||
@@ -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 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -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,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));
|
||||
}
|
||||
@@ -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,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)
|
||||
);
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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) } }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user