Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bc2917c
feat(paseto)!: typed keys with purpose separation
ExorTek Oct 24, 2026
947db5b
fix(oauth2): pasetoIssuer takes typed PASETO keys
ExorTek Oct 24, 2026
5b6204b
fix(paseto): token-pair passes durations in the right unit
ExorTek Oct 24, 2026
dc66bd6
fix(paseto): reject a token with an empty footer segment
ExorTek Oct 24, 2026
5e921a8
fix(paseto): claim requirements need a claims-object payload
ExorTek Oct 24, 2026
7414ef4
fix(jwt)!: signed refresh tokens are typed and keyed apart
ExorTek Oct 24, 2026
ba7ec25
fix(paseto): token-pair refresh tokens need their own key
ExorTek Oct 24, 2026
0f9e15e
fix(jwt): HMAC algorithms reject PEM keys with INVALID_KEY
ExorTek Oct 24, 2026
be2936e
fix(jws): bound verifyJson by size and signature count
ExorTek Oct 24, 2026
b45ab0e
fix(jwe): bound decryptJson by size and recipient count
ExorTek Oct 24, 2026
4a6632b
fix(jwe)!: JSON headers must be disjoint; protectedHeader is protecte…
ExorTek Oct 24, 2026
6bd1ebb
fix(jwe): reject zip instead of returning compressed bytes
ExorTek Oct 24, 2026
ce3c5d6
fix(jwks): alg cross-check also covers keys found by a refetch
ExorTek Oct 24, 2026
54bd2ee
fix(shared): JWK keys honour their declared alg, use and key_ops
ExorTek Oct 24, 2026
c5a547d
fix(jwk): toPublic refuses unknown key types
ExorTek Oct 24, 2026
837fc80
perf(shared): HMAC on the KeyObject, ECDSA in IEEE P1363 form
ExorTek Oct 24, 2026
ee88d63
perf(shared,jwt,jwks): import each key once instead of per verification
ExorTek Oct 24, 2026
0fd4469
perf(paseto): BLAKE2b on 32-bit lanes instead of BigInt
ExorTek Oct 24, 2026
f193c09
docs(paseto): typed keys, refresh key separation, duration units
ExorTek Oct 24, 2026
2c71f5e
docs(jose): JSON serialization limits, disjoint JWE headers, key checks
ExorTek Oct 24, 2026
542bdb9
chore: changesets for JOSE hardening
ExorTek Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/jwe-json-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@exortek/jwe': major
---

- `decryptJson` requires the protected, shared-unprotected and per-recipient headers to be disjoint (RFC 7516 §7.2.1) and `crit` to be protected; it returns `protectedHeader` (authenticated members only), `unprotectedHeader` and the joint `header` separately. Previously an unprotected recipient header could override a protected value and was reported as protected.
- `encryptJson` refuses a parameter set both in `options.header` and on a recipient.
- `decryptJson` enforces `maxTokenSize` (default 64 KiB) and `maxRecipients` (default 8).
- A `zip` header is rejected instead of returning still-compressed bytes.

**Migration:** read `kid` and other per-recipient members from `header` or `unprotectedHeader`, not `protectedHeader`.
5 changes: 5 additions & 0 deletions .changeset/jwk-topublic-kty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@exortek/jwk': patch
---

`toPublic` refuses key types other than RSA / EC / OKP instead of returning an unknown-kty JWK with its private members intact.
6 changes: 6 additions & 0 deletions .changeset/jwks-alg-refetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@exortek/jwks': patch
---

- The remote resolver's `alg` cross-check now also applies to a key found by a kid-miss refetch.
- Local key sets import each key once instead of on every resolve.
7 changes: 7 additions & 0 deletions .changeset/jws-json-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@exortek/jws': minor
---

- `verifyJson` enforces `maxTokenSize` (default 64 KiB for the serialized JSON) and `maxSignatures` (default 8) before decoding or verifying.
- A JWK that declares `alg`, `use` or `key_ops` is only used for that alg / signatures / that operation.
- JWK keys are imported once per object; HMAC signs with the KeyObject directly and ECDSA uses IEEE P1363 natively.
10 changes: 10 additions & 0 deletions .changeset/jwt-refresh-typing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@exortek/jwt': major
---

- `tokenPair` with `refresh.opaque: false` signs refresh tokens with `typ: refresh+jwt` and refuses a `secret.refresh` equal to `secret.access` (a refresh token under the access key verified as an access token).
- HMAC algorithms reject a PEM key or certificate with `INVALID_KEY` instead of an untyped TypeError.
- A JWK that declares `alg`, `use` or `key_ops` is only used for that alg / signatures / that operation.
- JWK objects and PEM / secret strings are imported once and reused (ES256 verify with a PEM: 48.5 → 34.3 µs); HMAC signs with the KeyObject directly and ECDSA uses IEEE P1363 natively.

**Migration:** give `tokenPair` distinct access and refresh keys when `refresh.opaque` is `false`; treat key objects as immutable once used.
5 changes: 5 additions & 0 deletions .changeset/oauth2-paseto-issuer-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@exortek/oauth2': major
---

`pasetoIssuer` requires typed PASETO keys (`generateKeyPair()` or `keys.secret` / `keys.public` from `@exortek/paseto`) and checks their purposes at configuration time.
17 changes: 17 additions & 0 deletions .changeset/paseto-typed-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@exortek/paseto': major
---

Typed keys and token hardening.

- **Typed keys (algorithm lucidity).** `generateKey()` / `generateKeyPair()` return `PasetoKey` objects carrying version + purpose; `keys.local` / `keys.secret` / `keys.public` wrap existing material. `encrypt` / `decrypt` only take `local` keys, `sign` only `secret`, `verify` only `public` — anything else, including a bare buffer, throws the new `INVALID_KEY_PURPOSE`. Previously a v4.public public key worked as a v4.local key, so anyone holding it could mint local tokens.
- `tokenPair` passed seconds where `encrypt` / `sign` expect milliseconds: `access.expiresIn: 900` produced a token valid for 0.9 s. Token `exp` now matches the reported expiry.
- `tokenPair` with `refresh.opaque: false` refuses a refresh key equal to the access key.
- A token with an empty footer segment (trailing `.`) is rejected.
- `issuer` / `subject` / `audience` requirements reject a payload that is not a claims object.
- BLAKE2b is ~15× faster (32-bit lanes instead of BigInt).

**Migration**

- Replace raw key buffers with `keys.local(bytes)`, `keys.secret(bytes)` and `keys.public(bytes)`; persist keys with `key.export()`.
- Give `tokenPair` distinct `secret.access` and `secret.refresh` keys when `refresh.opaque` is `false`.
4 changes: 3 additions & 1 deletion packages/jwe/src/decrypt.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import { JweError, ErrorCode } from './internal/errors.js';

/**
* @typedef {Object} DecryptResult
* @property {Record<string, unknown>} protectedHeader
* @property {Record<string, unknown>} protectedHeader only the integrity-protected header
* @property {Record<string, unknown>} [unprotectedHeader] JSON serialization: shared + per-recipient unprotected members
* @property {Record<string, unknown>} [header] JSON serialization: the joint header the recipient was processed with
* @property {unknown} payload Parsed JSON when the plaintext is JSON, else the raw `Buffer`.
*/

Expand Down
7 changes: 6 additions & 1 deletion packages/jwe/src/internal/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { parseDuration } from '@exortek/shared/duration';
import { JweError, ErrorCode } from './errors.js';

/** Header parameters this package understands and processes itself. */
const UNDERSTOOD_HEADERS = new Set(['alg', 'enc', 'epk', 'apu', 'apv', 'kid', 'crit', 'zip']);
const UNDERSTOOD_HEADERS = new Set(['alg', 'enc', 'epk', 'apu', 'apv', 'kid', 'crit']);

/**
* @param {unknown} value
Expand Down Expand Up @@ -91,6 +91,11 @@ export function enforceExpiry(payload, options) {
* @param {{ knownCriticalHeaders?: string[] }} options
*/
export function checkCrit(header, options) {
// `zip` (RFC 7516 §4.1.3) is not implemented. Decrypting anyway would hand
// back the still-deflated bytes as if they were the plaintext.
if (header.zip !== undefined) {
throw new JweError(ErrorCode.INVALID_HEADER, `"zip" (${JSON.stringify(header.zip)}) is not supported.`);
}
const crit = header.crit;
if (crit === undefined) {
return;
Expand Down
78 changes: 73 additions & 5 deletions packages/jwe/src/json.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ export async function encryptJson(payload, recipients, options) {
header.kid = recipient.kid;
}
Object.assign(header, result.header);
for (const name of Object.keys(header)) {
if (Object.hasOwn(protectedHeader, name)) {
throw new JweError(
ErrorCode.INVALID_ARGUMENT,
`encryptJson: "${name}" is set both in options.header and per recipient; JWE headers must be disjoint.`,
);
}
}
return { header, encrypted_key: b64uEncode(result.encryptedKey) };
});

Expand All @@ -149,12 +157,19 @@ export async function encryptJson(payload, recipients, options) {
* @typedef {import('./decrypt.js').DecryptOptions} DecryptOptions
*/

// A JSON serialization carries several recipients, so its default ceiling
// is wider than the compact form's 8 KiB.
const DEFAULT_MAX_JSON_SIZE = 65_536;
const DEFAULT_MAX_RECIPIENTS = 8;

/**
* Decrypt a General or Flattened JWE JSON serialization.
* Decrypt a General or Flattened JWE JSON serialization. `maxTokenSize`
* (default 64 KiB, the serialized JSON) and `maxRecipients` (default 8)
* bound the work before any recipient is tried.
*
* @param {GeneralJwe | Record<string, unknown>} jwe
* @param {KeyInput} key
* @param {DecryptOptions} options
* @param {DecryptOptions & { maxRecipients?: number }} options
* @returns {Promise<DecryptResult>}
*/
export async function decryptJson(jwe, key, options) {
Expand All @@ -165,8 +180,23 @@ export async function decryptJson(jwe, key, options) {
throw new JweError(ErrorCode.INVALID_TOKEN, 'decryptJson: expected a JWE JSON object with a "protected" member.');
}

const protectedHeader = assertHeaderObject(b64uDecodeJson(/** @type {string} */ (jwe.protected)));
// Bound the work before decoding anything: each recipient is a key
// unwrap attempt (potentially an ECDH / RSA operation).
const maxSize = options.maxTokenSize ?? DEFAULT_MAX_JSON_SIZE;
const size = Buffer.byteLength(JSON.stringify(jwe), 'utf8');
if (size > maxSize) {
throw new JweError(ErrorCode.TOKEN_TOO_LARGE, `decryptJson: JWE is ${size} bytes, over the ${maxSize} limit.`);
}
const recipientList = normalizeRecipients(jwe);
const maxRecipients = options.maxRecipients ?? DEFAULT_MAX_RECIPIENTS;
if (recipientList.length > maxRecipients) {
throw new JweError(
ErrorCode.INVALID_TOKEN,
`decryptJson: JWE has ${recipientList.length} recipients, over maxRecipients=${maxRecipients}.`,
);
}

const protectedHeader = assertHeaderObject(b64uDecodeJson(/** @type {string} */ (jwe.protected)));
const encodedAad = jwe.aad === undefined ? undefined : /** @type {string} */ (jwe.aad);
const aad = buildAad(/** @type {string} */ (jwe.protected), encodedAad);

Expand All @@ -179,8 +209,26 @@ export async function decryptJson(jwe, key, options) {
/** @type {unknown} */
let lastError;

const sharedUnprotected = isObject(jwe.unprotected) ? /** @type {Record<string, unknown>} */ (jwe.unprotected) : {};
assertDisjoint(protectedHeader, sharedUnprotected, 'unprotected');
// RFC 7516 §4.1.13: `crit` must be integrity protected.
if ('crit' in sharedUnprotected) {
throw new JweError(ErrorCode.INVALID_HEADER, '"crit" must appear in the protected header only.');
}
/** @type {Record<string, unknown> | undefined} */
let matchedUnprotected;

for (const recipient of recipientList) {
const header = { ...protectedHeader, ...(recipient.header ?? {}) };
const perRecipient = recipient.header ?? {};
// RFC 7516 §7.2.1: protected, shared-unprotected and per-recipient
// headers MUST be disjoint — an unprotected copy may never shadow a
// protected value (it would also be reported as protected).
assertDisjoint(protectedHeader, perRecipient, 'recipient');
assertDisjoint(sharedUnprotected, perRecipient, 'recipient');
if ('crit' in perRecipient) {
throw new JweError(ErrorCode.INVALID_HEADER, '"crit" must appear in the protected header only.');
}
const header = { ...protectedHeader, ...sharedUnprotected, ...perRecipient };
try {
if (!isString(header.alg) || !algAllowlist.includes(/** @type {string} */ (header.alg))) {
throw new JweError(
Expand All @@ -200,6 +248,7 @@ export async function decryptJson(jwe, key, options) {
const encryptedKey = recipient.encrypted_key ? b64uDecode(recipient.encrypted_key) : Buffer.alloc(0);
cek = unwrapCek(alg, enc, key, header, encryptedKey);
matchedHeader = header;
matchedUnprotected = { ...sharedUnprotected, ...perRecipient };
matchedEnc = enc;
break;
} catch (err) {
Expand All @@ -225,7 +274,26 @@ export async function decryptJson(jwe, key, options) {

const payload = decodePlaintext(plaintext);
enforceExpiry(payload, options);
return { protectedHeader: matchedHeader, payload };
// `protectedHeader` is exactly what the tag authenticated; the
// unprotected members are reported separately, and `header` is the
// joint header the recipient was processed with.
return { protectedHeader, unprotectedHeader: matchedUnprotected, header: matchedHeader, payload };
}

/**
* @param {Record<string, unknown>} a
* @param {Record<string, unknown>} b
* @param {string} which
*/
function assertDisjoint(a, b, which) {
for (const name of Object.keys(b)) {
if (Object.hasOwn(a, name)) {
throw new JweError(
ErrorCode.INVALID_HEADER,
`header parameter "${name}" appears in more than one header (${which}); RFC 7516 §7.2.1 requires them to be disjoint.`,
);
}
}
}

/**
Expand Down
44 changes: 44 additions & 0 deletions packages/jwe/tests/json.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,47 @@ test('decryptJson enforces the allowlist', async () => {
isCode(ErrorCode.ALGORITHM_MISMATCH),
);
});

test('decryptJson: recipient count and size are bounded before any unwrap', async () => {
const jwe = await encryptJson(PAYLOAD, [{ key: rsa.publicKey, alg: 'RSA-OAEP-256' }], { enc: 'A256GCM' });
const opts = { alg: ['RSA-OAEP-256'], enc: ['A256GCM'] };
const flood = { ...jwe, recipients: Array.from({ length: 9 }, () => jwe.recipients[0]) };
await assert.rejects(decryptJson(flood, rsa.privateKey, opts), isCode(ErrorCode.INVALID_TOKEN));
await assert.rejects(
decryptJson({ ...jwe, ciphertext: 'A'.repeat(70_000) }, rsa.privateKey, opts),
isCode(ErrorCode.TOKEN_TOO_LARGE),
);
});

test('decryptJson: an unprotected header may not shadow a protected value', async () => {
const jwe = await encryptJson(PAYLOAD, [{ key: rsa.publicKey, alg: 'RSA-OAEP-256', kid: 'r1' }], {
enc: 'A256GCM',
header: { typ: 'secure+jwe' },
});
const opts = { alg: ['RSA-OAEP-256'], enc: ['A256GCM'] };
const shadowed = {
...jwe,
recipients: [{ ...jwe.recipients[0], header: { ...jwe.recipients[0].header, typ: 'evil' } }],
};
await assert.rejects(decryptJson(shadowed, rsa.privateKey, opts), isCode(ErrorCode.INVALID_HEADER));
await assert.rejects(
decryptJson({ ...jwe, unprotected: { crit: ['x'], x: 1 } }, rsa.privateKey, opts),
isCode(ErrorCode.INVALID_HEADER),
);

const ok = await decryptJson(jwe, rsa.privateKey, opts);
assert.equal(ok.protectedHeader.typ, 'secure+jwe');
assert.equal(ok.protectedHeader.kid, undefined, 'unprotected members are not reported as protected');
assert.equal(ok.unprotectedHeader.kid, 'r1');
assert.equal(ok.header.kid, 'r1');
});

test('encryptJson refuses a header set both shared and per recipient', async () => {
await assert.rejects(
encryptJson(PAYLOAD, [{ key: rsa.publicKey, alg: 'RSA-OAEP-256', kid: 'r1' }], {
enc: 'A256GCM',
header: { kid: 'shared' },
}),
isCode(ErrorCode.INVALID_ARGUMENT),
);
});
8 changes: 8 additions & 0 deletions packages/jwe/tests/security.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,11 @@ test('unknown critical header is rejected unless declared known', async () => {
const { payload } = await decrypt(token, key, { alg: ['dir'], enc: ['A256GCM'], knownCriticalHeaders: ['exp2'] });
assert.equal(payload.a, 1);
});

test('a JWE with zip is rejected instead of returning deflated bytes', async () => {
const { randomBytes } = await import('node:crypto');
const { encrypt, decrypt } = await import('../src/index.js');
const key = randomBytes(32);
const token = await encrypt({ a: 1 }, key, { alg: 'dir', enc: 'A256GCM', header: { zip: 'DEF' } });
await assert.rejects(decrypt(token, key, { alg: ['dir'], enc: ['A256GCM'] }), err => err.code === 'INVALID_HEADER');
});
5 changes: 5 additions & 0 deletions packages/jwk/src/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ export function toPublic(jwk) {
if (j.kty === 'oct') {
throw invalidArgument('toPublic.jwk: `oct` JWKs are symmetric — they have no public projection');
}
// Only key types whose private members are known can be stripped safely;
// an unknown kty would be "published" with whatever secret fields it has.
if (j.kty !== 'EC' && j.kty !== 'OKP' && j.kty !== 'RSA') {
throw invalidArgument(`toPublic.jwk: unsupported kty ${JSON.stringify(j.kty)}`);
}
const clone = { ...j };
delete clone.d;
if (j.kty === 'RSA') {
Expand Down
5 changes: 5 additions & 0 deletions packages/jwk/tests/smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,8 @@ test('importPEM x509: extracts public key', async () => {
err => err instanceof JwkError && err.code === ErrorCode.INVALID_KEY,
);
});

test('toPublic refuses a kty whose private members it does not know', () => {
assert.throws(() => toPublic({ kty: 'AKP', alg: 'ML-DSA-44', pub: 'x', priv: 'secret' }), /kty/);
assert.throws(() => toPublic({ d: 'secret' }), /kty/);
});
12 changes: 11 additions & 1 deletion packages/jwks/src/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

import { generate } from '@exortek/jwk/generate';
import { importJWK } from '@exortek/jwk/import';

/** @type {WeakMap<object, Promise<import('node:crypto').KeyObject>>} */
const importedKeys = new WeakMap();
import { toPublic } from '@exortek/jwk/export';
import { parseDuration } from '@exortek/shared/duration';
import { isString } from '@exortek/shared/predicates';
Expand Down Expand Up @@ -311,7 +314,14 @@ export async function createLocalKeySet(specs, options = {}) {
`kid=${JSON.stringify(kid)} found but alg mismatch: key has ${entry.alg}, token has ${header.alg}`,
);
}
return importJWK(entry.publicJwk);
// Import once per key entry, not once per verification.
let imported = importedKeys.get(entry);
if (!imported) {
imported = importJWK(entry.publicJwk);
importedKeys.set(entry, imported);
imported.catch(() => importedKeys.delete(entry));
}
return imported;
},
};
}
34 changes: 24 additions & 10 deletions packages/jwks/src/remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,27 @@ export function createRemoteJWKS(uri, options = {}) {
* @param {{ kid: string, alg?: string }} header
* @returns {Promise<import('node:crypto').KeyObject>}
*/
/**
* A key published with an `alg` never verifies a token declaring a
* different one.
*
* @param {string} kid
* @param {{ alg?: string }} header
*/
function assertAlgMatches(kid, header) {
const pinned = jwksCache?.get(kid)?.alg;
if (isString(header.alg) && pinned && header.alg !== pinned) {
const err = new JwksError(
ErrorCode.KID_NOT_FOUND,
`kid=${JSON.stringify(kid)} found but alg mismatch: key has ${pinned}, token has ${header.alg}`,
);
if (onInvalidKey) {
onInvalidKey(header, err);
}
throw err;
}
}

async function resolver(header) {
const kid = header?.kid;
if (!isString(kid) || kid === '') {
Expand All @@ -269,16 +290,7 @@ export function createRemoteJWKS(uri, options = {}) {

let key = await getKeyObject(kid);
if (key) {
if (isString(header.alg) && jwksCache?.get(kid)?.alg && jwksCache.get(kid).alg !== header.alg) {
const err = new JwksError(
ErrorCode.KID_NOT_FOUND,
`kid=${JSON.stringify(kid)} found but alg mismatch: key has ${jwksCache.get(kid).alg}, token has ${header.alg}`,
);
if (onInvalidKey) {
onInvalidKey(header, err);
}
throw err;
}
assertAlgMatches(kid, header);
return key;
}

Expand All @@ -293,6 +305,8 @@ export function createRemoteJWKS(uri, options = {}) {
}
key = await getKeyObject(kid);
if (key) {
// A freshly-fetched key gets the same alg check as a cached one.
assertAlgMatches(kid, header);
return key;
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/jwks/tests/local.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ describe('resolve', () => {
assert.equal(key.type, 'public');
});

test('imports a key once and reuses it', async () => {
const ks = await createLocalKeySet([{ alg: 'ES256', kid: 'r1' }]);
assert.equal(await ks.resolve({ kid: 'r1' }), await ks.resolve({ kid: 'r1' }));
});

test('throws KID_NOT_FOUND for unknown kid', async () => {
const ks = await createLocalKeySet([{ alg: 'ES256', kid: 'r1' }]);
const err = await ks.resolve({ kid: 'unknown' }).catch(e => e);
Expand Down
Loading
Loading