Initial Commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type JwkThumbprintOptions,
|
||||
jwkThumbprint,
|
||||
jwkThumbprintBase64Url,
|
||||
} from "./thumbprint.js";
|
||||
export { jwkThumbprintUri } from "./thumbprint-uri.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# jwk/thumbprint-uri
|
||||
|
||||
## Purpose
|
||||
|
||||
Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical
|
||||
`urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64url(thumbprint)>`
|
||||
form used by OAuth 2.0 Demonstrating Proof-of-Possession (DPoP),
|
||||
draft-ietf-oauth-dpop, and other OAuth/OIDC binding protocols. Wraps
|
||||
`jwkThumbprint` with the hash-algorithm label and URN prefix.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 9278 §3 conformant URN format
|
||||
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported
|
||||
- Hash labels match RFC 9278 §3.1 registry: `sha-1`, `sha-256`, `sha-384`,
|
||||
`sha-512`
|
||||
- Returns a deterministic `string` ready to embed in OAuth tokens, DPoP
|
||||
proofs, or `iss`/`sub` claims
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
|
||||
|
||||
const uri = jwkThumbprintUri(publicKey);
|
||||
// "urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8u..."
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For protocols that pin a non-default hash (e.g. SHA-512 for
|
||||
high-assurance bindings), pass the `hash` option:
|
||||
|
||||
```ts
|
||||
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
|
||||
|
||||
const sha512Uri = jwkThumbprintUri(publicKey, { hash: "SHA-512" });
|
||||
// "urn:ietf:params:oauth:jwk-thumbprint:sha-512:..."
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `jwkThumbprintUri(key, options?)`
|
||||
|
||||
- `key: RsaPublicKey | RsaJwk` — public key or JWK
|
||||
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
|
||||
- **returns** `string` — full URN
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` /
|
||||
`CRYPTO_RSA.INVALID_KEY` (propagated from `jwkThumbprint`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The hash-label mapping is a frozen lookup table mirroring the
|
||||
IANA-registered names in RFC 9278 §3.1. Adding new RSA hash
|
||||
algorithms (e.g. SHA-3) requires updating both this table and the
|
||||
`RsaHashAlgorithm` union in `keys.types.ts`.
|
||||
- This helper is a thin wrapper over `jwkThumbprint` + `toBase64Url`;
|
||||
it adds no cryptographic complexity, only the URN framing. Tests
|
||||
cover the framing for every supported hash.
|
||||
@@ -0,0 +1,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:");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# jwk/thumbprint
|
||||
|
||||
## Purpose
|
||||
|
||||
Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The
|
||||
thumbprint is a deterministic hash over the canonical JSON form of the
|
||||
public-key members (`e`, `kty`, `n` in alphabetical order with no
|
||||
whitespace), giving every RSA key a stable identifier independent of
|
||||
its wire format. Used downstream for `kid` derivation, key rotation
|
||||
diffing, and key-pinning protocols.
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 7638 §3.2 conformant — RSA member ordering `e`, `kty`, `n`
|
||||
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported via `hash` option
|
||||
- Accepts both canonical `RsaPublicKey` and `RsaJwk` shapes
|
||||
- Hand-built canonical JSON byte string (does NOT use `JSON.stringify` —
|
||||
member order is implementation-defined for that API)
|
||||
- Worked-example matched against the RFC 7638 §3.1 reference value
|
||||
`"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
|
||||
- `jwkThumbprintBase64Url` convenience returns the URL-safe base64 encoding directly
|
||||
- All thrown errors are `CryptoRsaError` with `INVALID_KEY` /
|
||||
`UNSUPPORTED_HASH`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { jwkThumbprint } from "@suaveplan/crypto-rsa/jwk";
|
||||
|
||||
const fingerprint = jwkThumbprint(publicKey);
|
||||
// 32 bytes (SHA-256 default)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The `kid` JWK member is conventionally set to the base64url-encoded
|
||||
SHA-256 thumbprint. Combine `jwkThumbprintBase64Url` with
|
||||
`serializeRsaPublicKeyJwk` to populate it on emit:
|
||||
|
||||
```ts
|
||||
import {
|
||||
jwkThumbprintBase64Url,
|
||||
serializeRsaPublicKeyJwk,
|
||||
} from "@suaveplan/crypto-rsa";
|
||||
|
||||
const baseJwk = serializeRsaPublicKeyJwk(publicKey);
|
||||
const jwkWithKid = {
|
||||
...baseJwk,
|
||||
kid: jwkThumbprintBase64Url(publicKey),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `jwkThumbprint(key, options?)`
|
||||
|
||||
- `key: RsaPublicKey | RsaJwk` — public key or JWK
|
||||
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
|
||||
- **returns** `Uint8Array` — digest bytes
|
||||
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on bad hash;
|
||||
`CRYPTO_RSA.INVALID_KEY` on missing `e` or `n`
|
||||
|
||||
### `jwkThumbprintBase64Url(key, options?)`
|
||||
|
||||
Convenience: returns `toBase64Url(jwkThumbprint(key, options))`.
|
||||
|
||||
### `JwkThumbprintOptions`
|
||||
|
||||
- `hash?: RsaHashAlgorithm`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The canonical JSON byte string is `'{"e":"<b64u(e)>","kty":"RSA","n":"<b64u(n)>"}'`.
|
||||
Members are alphabetically ordered per RFC 7638 §3.2; only the three
|
||||
required members participate in the digest.
|
||||
- `JSON.stringify` is NOT used because member-order in object iteration
|
||||
is implementation-defined for non-integer keys; constructing the byte
|
||||
string by hand is the only correctness-stable approach.
|
||||
- When the input is a `RsaPublicKey` (canonical bigint form), the
|
||||
implementation routes through `serializeRsaPublicKeyJwk` to obtain
|
||||
the base64url members, then concatenates the canonical JSON shape.
|
||||
- Hash algorithm validation runs BEFORE member-validity checks so
|
||||
consumers see `UNSUPPORTED_HASH` on bad hashes regardless of key
|
||||
validity — useful for fail-fast configuration validators.
|
||||
@@ -0,0 +1,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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user