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,6 @@
export {
type JwkThumbprintOptions,
jwkThumbprint,
jwkThumbprintBase64Url,
} from "./thumbprint.js";
export { jwkThumbprintUri } from "./thumbprint-uri.js";

View File

@@ -0,0 +1,59 @@
# jwk/thumbprint-uri
## Purpose
Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical
`urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64url(thumbprint)>`
form used by OAuth 2.0 Demonstrating Proof-of-Possession (DPoP),
draft-ietf-oauth-dpop, and other OAuth/OIDC binding protocols. Wraps
`jwkThumbprint` with the hash-algorithm label and URN prefix.
## Features
- RFC 9278 §3 conformant URN format
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported
- Hash labels match RFC 9278 §3.1 registry: `sha-1`, `sha-256`, `sha-384`,
`sha-512`
- Returns a deterministic `string` ready to embed in OAuth tokens, DPoP
proofs, or `iss`/`sub` claims
## Usage: Basic Example
```ts
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
const uri = jwkThumbprintUri(publicKey);
// "urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8u..."
```
## Usage: Advanced Example
For protocols that pin a non-default hash (e.g. SHA-512 for
high-assurance bindings), pass the `hash` option:
```ts
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
const sha512Uri = jwkThumbprintUri(publicKey, { hash: "SHA-512" });
// "urn:ietf:params:oauth:jwk-thumbprint:sha-512:..."
```
## API Reference
### `jwkThumbprintUri(key, options?)`
- `key: RsaPublicKey | RsaJwk` — public key or JWK
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
- **returns** `string` — full URN
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` /
`CRYPTO_RSA.INVALID_KEY` (propagated from `jwkThumbprint`)
## Implementation Notes
- The hash-label mapping is a frozen lookup table mirroring the
IANA-registered names in RFC 9278 §3.1. Adding new RSA hash
algorithms (e.g. SHA-3) requires updating both this table and the
`RsaHashAlgorithm` union in `keys.types.ts`.
- This helper is a thin wrapper over `jwkThumbprint` + `toBase64Url`;
it adds no cryptographic complexity, only the URN framing. Tests
cover the framing for every supported hash.

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { jwkThumbprintUri } from "./thumbprint-uri.js";
const RFC_7638_JWK = {
kty: "RSA" as const,
n:
"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx" +
"4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCi" +
"FV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6" +
"Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb" +
"9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFT" +
"WhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls" +
"1jF44-csFCur-kEgU8awapJzKnqDKgw",
e: "AQAB",
};
describe("jwkThumbprintUri", () => {
it("emits the RFC 9278 URN with the SHA-256 thumbprint", {
timeout: 5000,
}, () => {
const uri = jwkThumbprintUri(RFC_7638_JWK);
expect(uri).toBe(
"urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
);
});
it("supports SHA-384 + SHA-512 hash overrides", { timeout: 5000 }, () => {
const sha384 = jwkThumbprintUri(RFC_7638_JWK, { hash: "SHA-384" });
const sha512 = jwkThumbprintUri(RFC_7638_JWK, { hash: "SHA-512" });
expect(sha384).toContain(":sha-384:");
expect(sha512).toContain(":sha-512:");
});
it("supports SHA-1 hash override", { timeout: 5000 }, () => {
const sha1 = jwkThumbprintUri(RFC_7638_JWK, { hash: "SHA-1" });
expect(sha1).toContain(":sha-1:");
});
});

View File

@@ -0,0 +1,30 @@
import { toBase64Url } from "@suaveplan/crypto-utils";
import type {
RsaHashAlgorithm,
RsaJwk,
RsaPublicKey,
} from "../keys/keys.types.js";
import { type JwkThumbprintOptions, jwkThumbprint } from "./thumbprint.js";
const HASH_TO_URI_LABEL: Readonly<Record<RsaHashAlgorithm, string>> = {
"SHA-1": "sha-1",
"SHA-256": "sha-256",
"SHA-384": "sha-384",
"SHA-512": "sha-512",
};
/**
* Compute the JWK Thumbprint URI per RFC 9278 §3 — `urn:ietf:params:oauth:
* jwk-thumbprint:<hash-alg>:<base64url(thumbprint)>`. The hash defaults to
* SHA-256 (the canonical RFC 7638 thumbprint algorithm).
*/
export function jwkThumbprintUri(
key: RsaPublicKey | RsaJwk,
options: JwkThumbprintOptions = {}
): string {
const hash = options.hash ?? "SHA-256";
const digest = jwkThumbprint(key, options);
const label = HASH_TO_URI_LABEL[hash];
const encoded = toBase64Url(digest);
return `urn:ietf:params:oauth:jwk-thumbprint:${label}:${encoded}`;
}

View File

@@ -0,0 +1,84 @@
# jwk/thumbprint
## Purpose
Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The
thumbprint is a deterministic hash over the canonical JSON form of the
public-key members (`e`, `kty`, `n` in alphabetical order with no
whitespace), giving every RSA key a stable identifier independent of
its wire format. Used downstream for `kid` derivation, key rotation
diffing, and key-pinning protocols.
## Features
- RFC 7638 §3.2 conformant — RSA member ordering `e`, `kty`, `n`
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported via `hash` option
- Accepts both canonical `RsaPublicKey` and `RsaJwk` shapes
- Hand-built canonical JSON byte string (does NOT use `JSON.stringify` —
member order is implementation-defined for that API)
- Worked-example matched against the RFC 7638 §3.1 reference value
`"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
- `jwkThumbprintBase64Url` convenience returns the URL-safe base64 encoding directly
- All thrown errors are `CryptoRsaError` with `INVALID_KEY` /
`UNSUPPORTED_HASH`
## Usage: Basic Example
```ts
import { jwkThumbprint } from "@suaveplan/crypto-rsa/jwk";
const fingerprint = jwkThumbprint(publicKey);
// 32 bytes (SHA-256 default)
```
## Usage: Advanced Example
The `kid` JWK member is conventionally set to the base64url-encoded
SHA-256 thumbprint. Combine `jwkThumbprintBase64Url` with
`serializeRsaPublicKeyJwk` to populate it on emit:
```ts
import {
jwkThumbprintBase64Url,
serializeRsaPublicKeyJwk,
} from "@suaveplan/crypto-rsa";
const baseJwk = serializeRsaPublicKeyJwk(publicKey);
const jwkWithKid = {
...baseJwk,
kid: jwkThumbprintBase64Url(publicKey),
};
```
## API Reference
### `jwkThumbprint(key, options?)`
- `key: RsaPublicKey | RsaJwk` — public key or JWK
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
- **returns** `Uint8Array` — digest bytes
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on bad hash;
`CRYPTO_RSA.INVALID_KEY` on missing `e` or `n`
### `jwkThumbprintBase64Url(key, options?)`
Convenience: returns `toBase64Url(jwkThumbprint(key, options))`.
### `JwkThumbprintOptions`
- `hash?: RsaHashAlgorithm`
## Implementation Notes
- The canonical JSON byte string is `'{"e":"<b64u(e)>","kty":"RSA","n":"<b64u(n)>"}'`.
Members are alphabetically ordered per RFC 7638 §3.2; only the three
required members participate in the digest.
- `JSON.stringify` is NOT used because member-order in object iteration
is implementation-defined for non-integer keys; constructing the byte
string by hand is the only correctness-stable approach.
- When the input is a `RsaPublicKey` (canonical bigint form), the
implementation routes through `serializeRsaPublicKeyJwk` to obtain
the base64url members, then concatenates the canonical JSON shape.
- Hash algorithm validation runs BEFORE member-validity checks so
consumers see `UNSUPPORTED_HASH` on bad hashes regardless of key
validity — useful for fail-fast configuration validators.

View File

@@ -0,0 +1,90 @@
import { toBase64Url } from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import { parseRsaPublicKeyJwk } from "../keys/parse-jwk.js";
import { jwkThumbprint, jwkThumbprintBase64Url } from "./thumbprint.js";
// RFC 7638 §3.1 worked example.
const RFC_7638_JWK = {
kty: "RSA" as const,
n:
"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx" +
"4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCi" +
"FV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6" +
"Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb" +
"9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFT" +
"WhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls" +
"1jF44-csFCur-kEgU8awapJzKnqDKgw",
e: "AQAB",
};
const RFC_7638_EXPECTED_BASE64URL =
"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs";
describe("jwkThumbprint", () => {
it("matches RFC 7638 §3.1 worked example", { timeout: 5000 }, () => {
const got = toBase64Url(jwkThumbprint(RFC_7638_JWK));
expect(got).toBe(RFC_7638_EXPECTED_BASE64URL);
});
it("matches the same value via the convenience helper", {
timeout: 5000,
}, () => {
expect(jwkThumbprintBase64Url(RFC_7638_JWK)).toBe(
RFC_7638_EXPECTED_BASE64URL
);
});
it("accepts an RsaPublicKey value (parsed from the JWK)", {
timeout: 5000,
}, () => {
const key = parseRsaPublicKeyJwk(RFC_7638_JWK);
const got = toBase64Url(jwkThumbprint(key));
expect(got).toBe(RFC_7638_EXPECTED_BASE64URL);
});
it("accepts the SHA-512 hash override", { timeout: 5000 }, () => {
const a = jwkThumbprint(RFC_7638_JWK);
const b = jwkThumbprint(RFC_7638_JWK, { hash: "SHA-512" });
// Different hash → different output, different length.
expect(b.length).toBe(64);
expect(a.length).toBe(32);
});
it("rejects unsupported hash algorithm", { timeout: 5000 }, () => {
try {
jwkThumbprint(RFC_7638_JWK, {
hash: "MD5" as never,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH
);
}
});
it("rejects empty `e` member", { timeout: 5000 }, () => {
const broken = { ...RFC_7638_JWK, e: "" };
try {
jwkThumbprint(broken);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
}
});
it("rejects empty `n` member", { timeout: 5000 }, () => {
const broken = { ...RFC_7638_JWK, n: "" };
try {
jwkThumbprint(broken);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
}
});
});

View File

@@ -0,0 +1,78 @@
import { toBase64Url } from "@suaveplan/crypto-utils";
import { digestWith, hashOutputLength } from "../encoding/mgf1.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type {
RsaHashAlgorithm,
RsaJwk,
RsaPublicKey,
} from "../keys/keys.types.js";
import { serializeRsaPublicKeyJwk } from "../keys/serialize-jwk.js";
/**
* Optional input for `jwkThumbprint`. The hash defaults to `"SHA-256"` per
* RFC 7638 §3.2 (the canonical thumbprint hash).
*/
export interface JwkThumbprintOptions {
readonly hash?: RsaHashAlgorithm;
}
/**
* Compute an RSA JWK thumbprint per RFC 7638 §3.2.
*
* The canonical input is the JSON object `{"e":"<b64u(e)>","kty":"RSA",
* "n":"<b64u(n)>"}` with members in alphabetical order — `e`, `kty`, `n`.
* `JSON.stringify` is NOT used because key insertion order is implementation
* defined; this implementation builds the canonical UTF-8 byte string by
* hand, hashes it with the configured algorithm, and returns the digest as
* a `Uint8Array`.
*
* @throws CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH) when `hash` is not
* one of the four declared `RsaHashAlgorithm` values.
*/
export function jwkThumbprint(
key: RsaPublicKey | RsaJwk,
options: JwkThumbprintOptions = {}
): Uint8Array {
const hash = options.hash ?? "SHA-256";
// Validate hash early so the caller gets a typed error before any
// digest work.
void hashOutputLength(hash);
const jwk: RsaJwk = isJwk(key) ? key : serializeRsaPublicKeyJwk(key);
const eMember = jwk.e;
const nMember = jwk.n;
if (typeof eMember !== "string" || eMember.length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.jwk-thumbprint.missing-e"
);
}
if (typeof nMember !== "string" || nMember.length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.jwk-thumbprint.missing-n"
);
}
const canonical = `{"e":"${eMember}","kty":"RSA","n":"${nMember}"}`;
const bytes = new TextEncoder().encode(canonical);
return digestWith(hash, bytes);
}
/**
* Compute an RSA JWK thumbprint and return the URL-safe base64 encoding
* directly. Convenience over `jwkThumbprint` + `toBase64Url`.
*/
export function jwkThumbprintBase64Url(
key: RsaPublicKey | RsaJwk,
options: JwkThumbprintOptions = {}
): string {
return toBase64Url(jwkThumbprint(key, options));
}
function isJwk(value: RsaPublicKey | RsaJwk): value is RsaJwk {
return (
typeof value === "object" &&
value !== null &&
"kty" in value &&
(value as { kty: unknown }).kty === "RSA"
);
}