From bc2917cb671bd7d0a3a232e742b70ae79efb0abf Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:37:16 +0300 Subject: [PATCH 01/21] feat(paseto)!: typed keys with purpose separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keys were raw Buffers, and a 32-byte buffer is the same shape whether it is a v4.local secret or a v4.public public key. An app that passed its public key to encrypt/decrypt therefore let anyone holding that public key mint v4.local tokens (PoC: encrypt(…, publicKey) + decrypt(…, publicKey) round-tripped). Following PASETO's algorithm-lucidity guidance, keys are now PasetoKey objects carrying version + purpose: - generateKey() → v4 local, generateKeyPair() → v4 secret + v4 public - keys.local / keys.secret / keys.public wrap existing raw material (or Ed25519 KeyObjects), validated up front - encrypt/decrypt only take local keys, sign only secret, verify only public; anything else — including a bare buffer — is the new INVALID_KEY_PURPOSE - key.export() returns the raw wire form; toJSON() never includes bytes BREAKING CHANGE: raw Buffer / KeyObject keys are refused. Wrap them with keys.local(…), keys.secret(…) or keys.public(…). --- packages/paseto/src/index.js | 4 +- packages/paseto/src/internal/errors.js | 3 + packages/paseto/src/key.js | 162 ++++++++++++++++++++++++ packages/paseto/src/keygen.js | 18 +-- packages/paseto/src/local.js | 11 +- packages/paseto/src/public.js | 11 +- packages/paseto/tests/roundtrip.test.js | 5 +- packages/paseto/tests/security.test.js | 39 +++++- packages/paseto/tests/smoke.test.js | 19 ++- 9 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 packages/paseto/src/key.js diff --git a/packages/paseto/src/index.js b/packages/paseto/src/index.js index 6fbc5972..4107b4ab 100644 --- a/packages/paseto/src/index.js +++ b/packages/paseto/src/index.js @@ -18,10 +18,11 @@ import { encrypt, decrypt } from './local.js'; import { sign, verify } from './public.js'; import { decode } from './decode.js'; import { generateKey, generateKeyPair } from './keygen.js'; +import { keys, PasetoKey } from './key.js'; import { PasetoError, ErrorCode } from './internal/errors.js'; import { SUPPORTED } from './internal/versions.js'; -export { encrypt, decrypt, sign, verify, decode, generateKey, generateKeyPair }; +export { encrypt, decrypt, sign, verify, decode, generateKey, generateKeyPair, keys, PasetoKey }; export { PasetoError, ErrorCode, SUPPORTED }; /** @@ -30,6 +31,7 @@ export { PasetoError, ErrorCode, SUPPORTED }; export const paseto = Object.freeze({ generateKey, generateKeyPair, + keys, encrypt, decrypt, sign, diff --git a/packages/paseto/src/internal/errors.js b/packages/paseto/src/internal/errors.js index 58661eea..d9081c2a 100644 --- a/packages/paseto/src/internal/errors.js +++ b/packages/paseto/src/internal/errors.js @@ -8,6 +8,8 @@ export const ErrorCode = Object.freeze({ INVALID_ARGUMENT: 'INVALID_ARGUMENT', INVALID_TOKEN: 'INVALID_TOKEN', INVALID_KEY: 'INVALID_KEY', + // A key of the wrong version/purpose (or a bare buffer) for the operation. + INVALID_KEY_PURPOSE: 'INVALID_KEY_PURPOSE', UNSUPPORTED_VERSION: 'UNSUPPORTED_VERSION', TOKEN_TOO_LARGE: 'TOKEN_TOO_LARGE', DECRYPTION_FAILED: 'DECRYPTION_FAILED', @@ -32,6 +34,7 @@ export class PasetoError extends BaseError { [ErrorCode.UNSUPPORTED_VERSION]: 400, [ErrorCode.INVALID_TOKEN]: 401, [ErrorCode.INVALID_KEY]: 401, + [ErrorCode.INVALID_KEY_PURPOSE]: 500, [ErrorCode.TOKEN_TOO_LARGE]: 413, [ErrorCode.DECRYPTION_FAILED]: 401, [ErrorCode.SIGNATURE_INVALID]: 401, diff --git a/packages/paseto/src/key.js b/packages/paseto/src/key.js new file mode 100644 index 00000000..ec1fbcd9 --- /dev/null +++ b/packages/paseto/src/key.js @@ -0,0 +1,162 @@ +/** + * Typed PASETO keys ("algorithm lucidity", PASETO spec §Key Considerations). + * + * A raw 32-byte buffer is indistinguishable between a `v4.local` secret and + * a `v4.public` public key — so an app that mixes them up would let anyone + * holding the public key mint `v4.local` tokens. Every key therefore + * carries its version and purpose, and the API refuses a key of the wrong + * purpose (or a bare buffer) with `INVALID_KEY_PURPOSE`: + * + * const key = generateKey(); // v4 local + * const { secretKey, publicKey } = generateKeyPair(); // v4 secret / public + * const imported = keys.local(rawBytesFromVault); // wrap existing material + * + * `PasetoKey` instances are only created by this module; a look-alike plain + * object is rejected. + */ + +import { createPublicKey, KeyObject } from 'node:crypto'; + +import { ed25519PrivateKey, ed25519PublicKey, rawFromKeyObject, symmetricKey } from './internal/keys.js'; +import { PasetoError, ErrorCode } from './internal/errors.js'; + +const CONSTRUCT = Symbol('PasetoKey'); + +/** @typedef {'local' | 'secret' | 'public'} KeyPurpose */ + +export class PasetoKey { + /** @type {Buffer} raw wire form */ + #bytes; + /** @type {Buffer | KeyObject | undefined} what the primitives consume, built once */ + #usable; + + /** + * @param {symbol} token + * @param {KeyPurpose} purpose + * @param {Buffer} bytes + */ + constructor(token, purpose, bytes) { + if (token !== CONSTRUCT) { + throw new PasetoError( + ErrorCode.INVALID_ARGUMENT, + 'PasetoKey is created by generateKey / generateKeyPair / keys.local|secret|public', + ); + } + /** @type {'v4'} */ + this.version = 'v4'; + /** @type {KeyPurpose} */ + this.purpose = purpose; + this.#bytes = bytes; + Object.freeze(this); + } + + /** + * The key in PASETO's raw wire form: 32 bytes for `local` and `public`, + * 64 bytes (`seed ‖ public`) for `secret`. + * + * @returns {Buffer} + */ + export() { + return Buffer.from(this.#bytes); + } + + /** Never serialize key bytes by accident (logs, JSON responses). */ + toJSON() { + return { version: this.version, purpose: this.purpose }; + } + + /** + * @internal The material the v4 primitives take, built on first use. + * @param {PasetoKey} key + * @returns {Buffer | KeyObject} + */ + static usable(key) { + if (key.#usable === undefined) { + key.#usable = + key.purpose === 'local' + ? symmetricKey(key.#bytes) + : key.purpose === 'secret' + ? ed25519PrivateKey(key.#bytes) + : ed25519PublicKey(key.#bytes); + } + return key.#usable; + } +} + +/** + * @param {KeyPurpose} purpose + * @param {Buffer} bytes + * @returns {PasetoKey} + */ +export function makeKey(purpose, bytes) { + return new PasetoKey(CONSTRUCT, purpose, bytes); +} + +/** + * Resolve a key handed to a public API to the material its primitive takes, + * refusing anything but a `PasetoKey` of the expected purpose. + * + * @param {unknown} key + * @param {KeyPurpose} purpose + * @param {string} where e.g. `'encrypt'` + * @returns {Buffer | KeyObject} + */ +export function resolveKey(key, purpose, where) { + if (!(key instanceof PasetoKey)) { + throw new PasetoError( + ErrorCode.INVALID_KEY_PURPOSE, + `${where} requires a v4 ${purpose} PasetoKey — wrap raw material with keys.${purpose}(…)`, + ); + } + if (key.purpose !== purpose) { + throw new PasetoError( + ErrorCode.INVALID_KEY_PURPOSE, + `${where} requires a v4 ${purpose} key, got a ${key.purpose} key`, + ); + } + return PasetoKey.usable(key); +} + +/** + * Wrap existing key material in a typed key. The bytes are validated now, + * not at first use. + */ +export const keys = Object.freeze({ + /** + * A `v4.local` symmetric key from 32 raw bytes. + * @param {Uint8Array} bytes + * @returns {PasetoKey} + */ + local(bytes) { + return makeKey('local', symmetricKey(bytes)); + }, + + /** + * A `v4.public` secret (signing) key from a 64-byte `seed ‖ public`, a + * 32-byte seed, or an Ed25519 private `KeyObject`. + * @param {Uint8Array | KeyObject} material + * @returns {PasetoKey} + */ + secret(material) { + if (material instanceof KeyObject && material.type !== 'private') { + throw new PasetoError(ErrorCode.INVALID_KEY_PURPOSE, 'keys.secret requires a private Ed25519 key'); + } + const privateKey = ed25519PrivateKey(material); + const seed = rawFromKeyObject(privateKey); + const pub = rawFromKeyObject(createPublicKey(privateKey)); + return makeKey('secret', Buffer.concat([seed, pub])); + }, + + /** + * A `v4.public` public (verification) key from 32 raw bytes or an + * Ed25519 public `KeyObject`. + * @param {Uint8Array | KeyObject} material + * @returns {PasetoKey} + */ + public(material) { + if (material instanceof KeyObject && material.type !== 'public') { + throw new PasetoError(ErrorCode.INVALID_KEY_PURPOSE, 'keys.public requires a public Ed25519 key'); + } + return makeKey('public', rawFromKeyObject(ed25519PublicKey(material))); + }, +}); diff --git a/packages/paseto/src/keygen.js b/packages/paseto/src/keygen.js index 092ea2b0..92ea7cfc 100644 --- a/packages/paseto/src/keygen.js +++ b/packages/paseto/src/keygen.js @@ -1,33 +1,35 @@ /** - * Key generation for the two v4 purposes. + * Key generation for the two v4 purposes. Both return typed `PasetoKey`s + * (see `key.js`); `.export()` gives the raw wire form. * - * - `generateKey()` → 32-byte symmetric key for v4.local - * - `generateKeyPair()` → Ed25519 { secretKey (64B), publicKey (32B) } - * in PASETO's raw wire form (`secretKey = seed ‖ public`). + * - `generateKey()` → v4 `local` key (32 bytes) + * - `generateKeyPair()` → { secretKey: v4 `secret` (64B seed ‖ public), + * publicKey: v4 `public` (32B) } */ import { randomBytes, generateKeyPairSync } from 'node:crypto'; import { rawFromKeyObject } from './internal/keys.js'; +import { makeKey } from './key.js'; import { V4_LOCAL, V4_PUBLIC } from './internal/versions.js'; import { PasetoError, ErrorCode } from './internal/errors.js'; /** * Generate a symmetric key for `v4.local`. * @param {string} [version='v4.local'] - * @returns {Buffer} 32 random bytes + * @returns {import('./key.js').PasetoKey} */ export function generateKey(version = V4_LOCAL) { if (version !== V4_LOCAL) { throw new PasetoError(ErrorCode.UNSUPPORTED_VERSION, `generateKey supports '${V4_LOCAL}', got '${version}'`); } - return randomBytes(32); + return makeKey('local', randomBytes(32)); } /** * Generate an Ed25519 key pair for `v4.public`, in raw wire form. * @param {string} [version='v4.public'] - * @returns {{ secretKey: Buffer, publicKey: Buffer }} + * @returns {{ secretKey: import('./key.js').PasetoKey, publicKey: import('./key.js').PasetoKey }} */ export function generateKeyPair(version = V4_PUBLIC) { if (version !== V4_PUBLIC) { @@ -36,5 +38,5 @@ export function generateKeyPair(version = V4_PUBLIC) { const { privateKey, publicKey } = generateKeyPairSync('ed25519'); const seed = rawFromKeyObject(privateKey); const pub = rawFromKeyObject(publicKey); - return { secretKey: Buffer.concat([seed, pub]), publicKey: pub }; + return { secretKey: makeKey('secret', Buffer.concat([seed, pub])), publicKey: makeKey('public', pub) }; } diff --git a/packages/paseto/src/local.js b/packages/paseto/src/local.js index eb22ac9b..eca1f6e9 100644 --- a/packages/paseto/src/local.js +++ b/packages/paseto/src/local.js @@ -12,6 +12,7 @@ import { encryptRaw, decryptRaw } from './internal/v4local.js'; import { serializePayload, serializeFooter, finalize, toBuffer } from './internal/message.js'; import { assertNonEmptyString } from './internal/guards.js'; import { assertTokenSize, DEFAULT_MAX_TOKEN_SIZE } from './internal/size.js'; +import { resolveKey } from './key.js'; /** * @typedef {import('./internal/claims.js')} _claims @@ -42,14 +43,15 @@ import { assertTokenSize, DEFAULT_MAX_TOKEN_SIZE } from './internal/size.js'; * Encrypt a payload into a `v4.local` token. * * @param {Record | string | Uint8Array} payload - * @param {Uint8Array} key 32-byte symmetric key + * @param {import('./key.js').PasetoKey} key a v4 `local` key * @param {EncryptOptions} [options] * @returns {string} */ export function encrypt(payload, key, options = {}) { + const k = resolveKey(key, 'local', 'encrypt'); return encryptRaw({ message: serializePayload(payload, options), - key, + key: k, footer: serializeFooter(options.footer), implicit: toBuffer(options.assertion, 'assertion'), }); @@ -59,16 +61,17 @@ export function encrypt(payload, key, options = {}) { * Decrypt and validate a `v4.local` token. * * @param {string} token - * @param {Uint8Array} key + * @param {import('./key.js').PasetoKey} key a v4 `local` key * @param {DecryptOptions} [options] * @returns {Record | string | { payload: unknown, footer: string, version: string, purpose: string }} */ export function decrypt(token, key, options = {}) { + const k = resolveKey(key, 'local', 'decrypt'); assertNonEmptyString(token, 'decrypt.token'); assertTokenSize(token, options.maxTokenSize ?? DEFAULT_MAX_TOKEN_SIZE); const { message, footer } = decryptRaw({ token, - key, + key: k, implicit: toBuffer(options.assertion, 'assertion'), }); return finalize(message, footer, options, 'local'); diff --git a/packages/paseto/src/public.js b/packages/paseto/src/public.js index 1e834f9f..c0739712 100644 --- a/packages/paseto/src/public.js +++ b/packages/paseto/src/public.js @@ -12,19 +12,21 @@ import { signRaw, verifyRaw } from './internal/v4public.js'; import { serializePayload, serializeFooter, finalize, toBuffer } from './internal/message.js'; import { assertNonEmptyString } from './internal/guards.js'; import { assertTokenSize, DEFAULT_MAX_TOKEN_SIZE } from './internal/size.js'; +import { resolveKey } from './key.js'; /** * Sign a payload into a `v4.public` token. * * @param {Record | string | Uint8Array} payload - * @param {import('node:crypto').KeyObject | Uint8Array} secretKey Ed25519 secret (32/64B or KeyObject) + * @param {import('./key.js').PasetoKey} secretKey a v4 `secret` key * @param {import('./local.js').EncryptOptions} [options] * @returns {string} */ export function sign(payload, secretKey, options = {}) { + const sk = resolveKey(secretKey, 'secret', 'sign'); return signRaw({ message: serializePayload(payload, options), - secretKey, + secretKey: sk, footer: serializeFooter(options.footer), implicit: toBuffer(options.assertion, 'assertion'), }); @@ -34,16 +36,17 @@ export function sign(payload, secretKey, options = {}) { * Verify a `v4.public` token and validate its claims. * * @param {string} token - * @param {import('node:crypto').KeyObject | Uint8Array} publicKey Ed25519 public (32B or KeyObject) + * @param {import('./key.js').PasetoKey} publicKey a v4 `public` key * @param {import('./local.js').DecryptOptions} [options] * @returns {Record | string | { payload: unknown, footer: string, version: string, purpose: string }} */ export function verify(token, publicKey, options = {}) { + const pk = resolveKey(publicKey, 'public', 'verify'); assertNonEmptyString(token, 'verify.token'); assertTokenSize(token, options.maxTokenSize ?? DEFAULT_MAX_TOKEN_SIZE); const { message, footer } = verifyRaw({ token, - publicKey, + publicKey: pk, implicit: toBuffer(options.assertion, 'assertion'), }); return finalize(message, footer, options, 'public'); diff --git a/packages/paseto/tests/roundtrip.test.js b/packages/paseto/tests/roundtrip.test.js index c98de814..6050da1d 100644 --- a/packages/paseto/tests/roundtrip.test.js +++ b/packages/paseto/tests/roundtrip.test.js @@ -13,6 +13,7 @@ import { decode, generateKey, generateKeyPair, + keys, PasetoError, ErrorCode, } from '../src/index.js'; @@ -61,9 +62,9 @@ test('v4.public round-trips and verifies', () => { assert.equal(payload.userId, 2); }); -test('v4.public accepts a 32-byte seed as the secret key', () => { +test('keys.secret accepts a 32-byte seed as the secret key', () => { const { secretKey, publicKey } = generateKeyPair(); - const seedOnly = secretKey.subarray(0, 32); + const seedOnly = keys.secret(secretKey.export().subarray(0, 32)); const token = sign({ ok: true }, seedOnly); assert.equal(verify(token, publicKey).ok, true); }); diff --git a/packages/paseto/tests/security.test.js b/packages/paseto/tests/security.test.js index 29239239..c24d90b0 100644 --- a/packages/paseto/tests/security.test.js +++ b/packages/paseto/tests/security.test.js @@ -6,7 +6,17 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { encrypt, decrypt, sign, verify, generateKey, generateKeyPair, PasetoError, ErrorCode } from '../src/index.js'; +import { + encrypt, + decrypt, + sign, + verify, + generateKey, + generateKeyPair, + keys, + PasetoError, + ErrorCode, +} from '../src/index.js'; test('expired token is rejected with TOKEN_EXPIRED', () => { const key = generateKey(); @@ -109,3 +119,30 @@ test('a v4.local token is rejected by verify()', () => { err => err instanceof PasetoError && err.code === ErrorCode.INVALID_TOKEN, ); }); + +// KEY PURPOSE (algorithm lucidity) + +test('a public key can never be used as a v4.local key', () => { + const { publicKey } = generateKeyPair(); + assert.throws(() => encrypt({ sub: 'forged' }, publicKey), { code: 'INVALID_KEY_PURPOSE' }); + // The raw bytes alone are refused too — they must be wrapped explicitly. + assert.throws(() => encrypt({ sub: 'forged' }, publicKey.export()), { code: 'INVALID_KEY_PURPOSE' }); +}); + +test('each operation only takes its own key purpose', () => { + const local = generateKey(); + const { secretKey, publicKey } = generateKeyPair(); + assert.throws(() => sign({}, local), { code: 'INVALID_KEY_PURPOSE' }); + assert.throws(() => sign({}, publicKey), { code: 'INVALID_KEY_PURPOSE' }); + assert.throws(() => verify(sign({}, secretKey), secretKey), { code: 'INVALID_KEY_PURPOSE' }); + assert.throws(() => decrypt(encrypt({}, local), secretKey), { code: 'INVALID_KEY_PURPOSE' }); + assert.throws(() => encrypt({}, { version: 'v4', purpose: 'local' }), { code: 'INVALID_KEY_PURPOSE' }); +}); + +test('keys.local / keys.public wrap existing raw material', () => { + const raw = generateKey().export(); + const token = encrypt({ ok: 1 }, keys.local(raw)); + assert.equal(decrypt(token, keys.local(raw)).ok, 1); + const { secretKey, publicKey } = generateKeyPair(); + assert.equal(verify(sign({ ok: 2 }, secretKey), keys.public(publicKey.export())).ok, 2); +}); diff --git a/packages/paseto/tests/smoke.test.js b/packages/paseto/tests/smoke.test.js index c7969b6f..9ca6f0f9 100644 --- a/packages/paseto/tests/smoke.test.js +++ b/packages/paseto/tests/smoke.test.js @@ -30,16 +30,23 @@ test('SUPPORTED lists the curated v4 set and nothing else', () => { // KEY GENERATION -test('generateKey returns a 32-byte symmetric key', () => { - assert.equal(generateKey().length, 32); +test('generateKey returns a typed v4 local key (32 bytes)', () => { + const key = generateKey(); + assert.equal(key.version, 'v4'); + assert.equal(key.purpose, 'local'); + assert.equal(key.export().length, 32); }); -test('generateKeyPair returns raw Ed25519 keys (64B secret ‖ 32B public)', () => { +test('generateKeyPair returns typed Ed25519 keys (64B secret ‖ 32B public)', () => { const { secretKey, publicKey } = generateKeyPair(); - assert.equal(secretKey.length, 64); - assert.equal(publicKey.length, 32); + assert.equal(secretKey.purpose, 'secret'); + assert.equal(publicKey.purpose, 'public'); + assert.equal(secretKey.export().length, 64); + assert.equal(publicKey.export().length, 32); // secretKey is seed ‖ public - assert.ok(secretKey.subarray(32).equals(publicKey)); + assert.ok(secretKey.export().subarray(32).equals(publicKey.export())); + // Key bytes never leak through JSON. + assert.deepEqual(JSON.parse(JSON.stringify(secretKey)), { version: 'v4', purpose: 'secret' }); }); test('generateKey / generateKeyPair reject the wrong version', () => { From 947db5b082113000879998c44a6c691e92f585b8 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:37:16 +0300 Subject: [PATCH 02/21] fix(oauth2): pasetoIssuer takes typed PASETO keys @exortek/paseto now requires purpose-typed keys. pasetoIssuer checks for a v4 secret and a v4 public PasetoKey at configuration time, so raw buffers or swapped keys fail at startup rather than on the first token. --- packages/oauth2/src/server/issuers/paseto.js | 23 +++++++++++++++++--- packages/oauth2/tests/paseto-issuer.test.js | 23 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 packages/oauth2/tests/paseto-issuer.test.js diff --git a/packages/oauth2/src/server/issuers/paseto.js b/packages/oauth2/src/server/issuers/paseto.js index d7b7f6a8..edb825f0 100644 --- a/packages/oauth2/src/server/issuers/paseto.js +++ b/packages/oauth2/src/server/issuers/paseto.js @@ -10,7 +10,7 @@ * claims (`client_id`, `scope`, `cnf`) live in the payload, DPoP binding * included. */ -import { sign, verify, PasetoError } from '@exortek/paseto'; +import { sign, verify, PasetoError, PasetoKey } from '@exortek/paseto'; import { parseDuration } from '@exortek/shared/duration'; import { isArray, isNonEmptyString, isNullish, isObject } from '@exortek/shared/predicates'; @@ -21,8 +21,8 @@ const DEFAULT_EXPIRES_IN = '10m'; /** * @typedef {Object} PasetoIssuerConfig - * @property {import('node:crypto').KeyObject | Uint8Array} secretKey Ed25519 secret (signing) - * @property {import('node:crypto').KeyObject | Uint8Array} publicKey Ed25519 public (introspection) + * @property {import('@exortek/paseto').PasetoKey} secretKey v4 `secret` key (signing) — `generateKeyPair()` or `keys.secret(…)` + * @property {import('@exortek/paseto').PasetoKey} publicKey v4 `public` key (introspection) — `generateKeyPair()` or `keys.public(…)` * @property {string | number} [expiresIn] access-token lifetime (default `'10m'`) */ @@ -36,6 +36,14 @@ export function pasetoIssuer(config) { if (isNullish(config.secretKey) || isNullish(config.publicKey)) { throw new OAuth2Error(ErrorCode.INVALID_ARGUMENT, 'pasetoIssuer requires secretKey and publicKey'); } + // Typed keys (algorithm lucidity): refuse raw buffers and swapped + // purposes at configuration time rather than on the first token. + if (!isPasetoKey(config.secretKey, 'secret') || !isPasetoKey(config.publicKey, 'public')) { + throw new OAuth2Error( + ErrorCode.INVALID_ARGUMENT, + 'pasetoIssuer requires a v4 secret and a v4 public PasetoKey (generateKeyPair() or keys.secret / keys.public)', + ); + } const { secretKey, publicKey } = config; const expiresInSec = Math.floor(parseDuration(config.expiresIn ?? DEFAULT_EXPIRES_IN) / 1000); @@ -122,3 +130,12 @@ function audienceString(grant) { } return grant.clientId; } + +/** + * @param {unknown} key + * @param {'secret' | 'public'} purpose + * @returns {boolean} + */ +function isPasetoKey(key, purpose) { + return key instanceof PasetoKey && key.purpose === purpose; +} diff --git a/packages/oauth2/tests/paseto-issuer.test.js b/packages/oauth2/tests/paseto-issuer.test.js new file mode 100644 index 00000000..714c6dd4 --- /dev/null +++ b/packages/oauth2/tests/paseto-issuer.test.js @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { generateKeyPair } from '@exortek/paseto'; + +import { pasetoIssuer } from '../src/server/index.js'; + +test('pasetoIssuer issues and introspects with typed v4 keys', async () => { + const { secretKey, publicKey } = generateKeyPair(); + const issuer = pasetoIssuer({ secretKey, publicKey }); + const issued = await issuer.issue({ subject: 'u1', clientId: 'app', scope: ['read'] }, { issuer: 'https://as' }); + const { active, claims } = await issuer.introspect(issued.accessToken, { issuer: 'https://as' }); + assert.equal(active, true); + assert.equal(claims.sub, 'u1'); +}); + +test('pasetoIssuer refuses raw buffers and swapped key purposes', () => { + const { secretKey, publicKey } = generateKeyPair(); + assert.throws(() => pasetoIssuer({ secretKey: secretKey.export(), publicKey: publicKey.export() }), { + code: 'INVALID_ARGUMENT', + }); + assert.throws(() => pasetoIssuer({ secretKey: publicKey, publicKey: secretKey }), { code: 'INVALID_ARGUMENT' }); +}); From 5b6204b946812764c273f6ed3e51d89f4e3011c2 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:37:16 +0300 Subject: [PATCH 03/21] fix(paseto): token-pair passes durations in the right unit tokenPair.create treats a bare-number expiresIn as seconds, but handed it straight to encrypt / sign, which read a bare number as milliseconds. access.expiresIn: 900 produced a token that expired 0.9 s later while accessExpiresAt reported 15 minutes; an opaque:false refresh token with '30d' expired after about 43 minutes. Both are now passed with an explicit seconds unit, so the token exp matches the reported expiry. --- packages/paseto/src/token-pair.js | 10 ++++++++-- packages/paseto/tests/token-pair.test.js | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/paseto/src/token-pair.js b/packages/paseto/src/token-pair.js index 2d874d65..e63fc505 100644 --- a/packages/paseto/src/token-pair.js +++ b/packages/paseto/src/token-pair.js @@ -103,7 +103,13 @@ export async function create(payload, options) { const accessExpiresAtSec = now + Math.floor(parseDurationSeconds(access.expiresIn)); const refreshExpiresAtSec = now + Math.floor(parseDurationSeconds(refresh.expiresIn)); - const accessToken = mintAccess(payload, secret.access, access); + // Token-pair durations are seconds (a bare number means seconds), while + // encrypt/sign read a bare number as milliseconds — hand them an explicit + // unit so the token's `exp` matches accessExpiresAt. + const accessToken = mintAccess(payload, secret.access, { + ...access, + expiresIn: `${accessExpiresAtSec - now}s`, + }); const { plaintext: refreshPlaintext, storeKey: refreshStoreKey } = await _generateRefresh( refresh, payload, @@ -285,7 +291,7 @@ async function _generateRefresh(refresh, payload, secret, expiresAtSec) { const expiresIn = expiresAtSec - Math.floor(Date.now() / 1000); plaintext = mintAccess({ ...payload, kind: 'refresh' }, secret, { purpose: refresh.purpose ?? 'local', - expiresIn, + expiresIn: `${expiresIn}s`, }); } else { const size = refresh.tokenSize ?? 32; diff --git a/packages/paseto/tests/token-pair.test.js b/packages/paseto/tests/token-pair.test.js index 5fc17034..1bfd4faa 100644 --- a/packages/paseto/tests/token-pair.test.js +++ b/packages/paseto/tests/token-pair.test.js @@ -141,3 +141,21 @@ test('missing required options throw INVALID_ARGUMENT', async () => { ); store._stop(); }); + +test('a numeric expiresIn is seconds for both the token exp and the reported expiry', async () => { + const store = createStore('memory'); + const secret = { access: generateKey(), refresh: generateKey() }; + const res = await create( + { userId: 1 }, + { secret, access: { expiresIn: 900 }, refresh: { expiresIn: '30d', opaque: false, store } }, + ); + const now = Date.now(); + const access = decrypt(res.accessToken, secret.access); + const accessExp = Date.parse(access.exp); + assert.ok(Math.abs(accessExp - res.accessExpiresAt.getTime()) < 2000, `access exp ${access.exp}`); + assert.ok(accessExp - now > 890_000); + + const refresh = decrypt(res.refreshToken, secret.refresh); + assert.ok(Math.abs(Date.parse(refresh.exp) - res.refreshExpiresAt.getTime()) < 2000, `refresh exp ${refresh.exp}`); + store._stop(); +}); From dc66bd652b5dfcb5d33ab62da623e74cd13cb849 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:37:31 +0300 Subject: [PATCH 04/21] fix(paseto): reject a token with an empty footer segment decrypt / verify treated a trailing '.' (an empty fourth segment) as no footer, so every token verified under a second spelling. A present but empty footer segment is now INVALID_TOKEN. --- packages/paseto/src/internal/v4local.js | 7 ++++++- packages/paseto/src/internal/v4public.js | 7 ++++++- packages/paseto/tests/security.test.js | 7 +++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/paseto/src/internal/v4local.js b/packages/paseto/src/internal/v4local.js index 491b95dd..5b7438ab 100644 --- a/packages/paseto/src/internal/v4local.js +++ b/packages/paseto/src/internal/v4local.js @@ -83,7 +83,12 @@ export function decryptRaw({ token, key, implicit = Buffer.alloc(0) }) { if (parts.length < 3 || parts.length > 4 || `${parts[0]}.${parts[1]}.` !== HEADER) { throw new PasetoError(ErrorCode.INVALID_TOKEN, 'not a v4.local token'); } - const footer = parts.length >= 4 && parts[3] ? b64.decode(parts[3]) : Buffer.alloc(0); + // An empty fourth segment (`…payload.`) is not "no footer": accepting it + // would give one token two valid spellings. + if (parts.length === 4 && parts[3].length === 0) { + throw new PasetoError(ErrorCode.INVALID_TOKEN, 'token has an empty footer segment'); + } + const footer = parts.length === 4 ? b64.decode(parts[3]) : Buffer.alloc(0); const raw = b64.decode(parts[2]); if (raw.length < 32 + 32) { diff --git a/packages/paseto/src/internal/v4public.js b/packages/paseto/src/internal/v4public.js index bba35704..8bab9440 100644 --- a/packages/paseto/src/internal/v4public.js +++ b/packages/paseto/src/internal/v4public.js @@ -54,7 +54,12 @@ export function verifyRaw({ token, publicKey, implicit = Buffer.alloc(0) }) { if (parts.length < 3 || parts.length > 4 || `${parts[0]}.${parts[1]}.` !== HEADER) { throw new PasetoError(ErrorCode.INVALID_TOKEN, 'not a v4.public token'); } - const footer = parts.length >= 4 && parts[3] ? b64.decode(parts[3]) : Buffer.alloc(0); + // An empty fourth segment (`…payload.`) is not "no footer": accepting it + // would give one token two valid spellings. + if (parts.length === 4 && parts[3].length === 0) { + throw new PasetoError(ErrorCode.INVALID_TOKEN, 'token has an empty footer segment'); + } + const footer = parts.length === 4 ? b64.decode(parts[3]) : Buffer.alloc(0); const raw = b64.decode(parts[2]); if (raw.length < SIG_LEN) { diff --git a/packages/paseto/tests/security.test.js b/packages/paseto/tests/security.test.js index c24d90b0..eef7a340 100644 --- a/packages/paseto/tests/security.test.js +++ b/packages/paseto/tests/security.test.js @@ -146,3 +146,10 @@ test('keys.local / keys.public wrap existing raw material', () => { const { secretKey, publicKey } = generateKeyPair(); assert.equal(verify(sign({ ok: 2 }, secretKey), keys.public(publicKey.export())).ok, 2); }); + +test('a trailing "." (empty footer) is not a second spelling of the token', () => { + const key = generateKey(); + const { secretKey, publicKey } = generateKeyPair(); + assert.throws(() => decrypt(`${encrypt({ a: 1 }, key)}.`, key), { code: 'INVALID_TOKEN' }); + assert.throws(() => verify(`${sign({ a: 1 }, secretKey)}.`, publicKey), { code: 'INVALID_TOKEN' }); +}); From 5e921a8501260fabd3300b45c6d13a7beddcd515 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:37:50 +0300 Subject: [PATCH 05/21] fix(paseto): claim requirements need a claims-object payload verify / decrypt validated claims only when the payload was an object. A string, bytes or array payload skipped every check, so verify(sign('user@x'), pk, { issuer, audience }) passed. When issuer, subject or audience is required and the payload is not a claims object, the token is now rejected with CLAIM_MISMATCH. --- packages/paseto/src/internal/message.js | 9 +++++++++ packages/paseto/tests/security.test.js | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/paseto/src/internal/message.js b/packages/paseto/src/internal/message.js index 71e6433b..9b072a5b 100644 --- a/packages/paseto/src/internal/message.js +++ b/packages/paseto/src/internal/message.js @@ -8,6 +8,7 @@ import { isString, isObject, isBytes } from '@exortek/shared/predicates'; import { applyClaims, validateClaims } from './claims.js'; import { invalidArgument } from './guards.js'; +import { PasetoError, ErrorCode } from './errors.js'; /** Coerce `undefined | string | bytes` to a Buffer. */ export function toBuffer(value, name) { @@ -75,6 +76,14 @@ export function finalize(message, footer, options, purpose) { if (isObject(payload)) { validateClaims(payload, options); + } else if (options.issuer !== undefined || options.subject !== undefined || options.audience !== undefined) { + // A string / bytes / array payload has no claims to check. Silently + // passing would let any such token satisfy an issuer / audience + // requirement the caller asked for. + throw new PasetoError( + ErrorCode.CLAIM_MISMATCH, + 'issuer / subject / audience were required but the payload is not a claims object', + ); } if (options.complete === true) { diff --git a/packages/paseto/tests/security.test.js b/packages/paseto/tests/security.test.js index eef7a340..9cde55ce 100644 --- a/packages/paseto/tests/security.test.js +++ b/packages/paseto/tests/security.test.js @@ -153,3 +153,12 @@ test('a trailing "." (empty footer) is not a second spelling of the token', () = assert.throws(() => decrypt(`${encrypt({ a: 1 }, key)}.`, key), { code: 'INVALID_TOKEN' }); assert.throws(() => verify(`${sign({ a: 1 }, secretKey)}.`, publicKey), { code: 'INVALID_TOKEN' }); }); + +test('claim requirements reject a payload that is not a claims object', () => { + const { secretKey, publicKey } = generateKeyPair(); + const token = sign('user@x', secretKey); + assert.throws(() => verify(token, publicKey, { issuer: 'https://as', audience: 'api' }), { + code: 'CLAIM_MISMATCH', + }); + assert.equal(verify(token, publicKey), 'user@x'); +}); From 7414ef450c8de69c5a6fa74543324e333ac00971 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:38:56 +0300 Subject: [PATCH 06/21] fix(jwt)!: signed refresh tokens are typed and keyed apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With refresh.opaque: false the refresh JWT was signed with typ JWT, so when secret.refresh equalled secret.access it verified as an access token anywhere the access key was trusted. - token-pair now refuses secret.refresh equal to secret.access when refresh tokens are signed - refresh JWTs carry typ: refresh+jwt (RFC 8725 §3.11), so verifiers with a typ allowlist can tell them apart BREAKING CHANGE: tokenPair with refresh.opaque: false needs distinct access and refresh keys. --- packages/jwt/src/token-pair.js | 38 ++++++++++++++++++++++++++- packages/jwt/tests/token-pair.test.js | 19 ++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/jwt/src/token-pair.js b/packages/jwt/src/token-pair.js index 9a338e8b..deecd7b4 100644 --- a/packages/jwt/src/token-pair.js +++ b/packages/jwt/src/token-pair.js @@ -8,7 +8,7 @@ * Subpath entry point (`@exortek/jwt/token-pair`). */ -import { randomBytes } from 'node:crypto'; +import { KeyObject, randomBytes } from 'node:crypto'; import { isFunction, isNumber, isString } from '@exortek/shared/predicates'; @@ -254,6 +254,13 @@ function _assertCreateOptions(options) { if (options.refresh.opaque === false && typeof options.refresh.alg !== 'string') { throw invalidArgument('tokenPair.options.refresh.alg is required when refresh.opaque is false'); } + // A signed refresh token under the access key is also a valid access + // token to any verifier holding that key — the two must not share one. + if (options.refresh.opaque === false && sameKey(options.secret.access, options.secret.refresh)) { + throw invalidArgument( + 'tokenPair.options.secret.refresh must differ from secret.access when refresh.opaque is false', + ); + } if (options.refresh.expiresIn === undefined) { throw invalidArgument('tokenPair.options.refresh.expiresIn is required'); } @@ -262,6 +269,32 @@ function _assertCreateOptions(options) { } } +/** + * Whether two key inputs are the same key material. + * + * @param {unknown} a + * @param {unknown} b + * @returns {boolean} + */ +function sameKey(a, b) { + if (a === b) { + return true; + } + if (a instanceof KeyObject && b instanceof KeyObject) { + return a.equals(b); + } + const bytes = v => (isString(v) ? Buffer.from(v, 'utf8') : v instanceof Uint8Array ? Buffer.from(v) : null); + const x = bytes(a); + const y = bytes(b); + if (x && y) { + return x.equals(y); + } + if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') { + return JSON.stringify(a) === JSON.stringify(b); + } + return false; +} + /** * Produce `{ plaintext, storeKey }` for a refresh token. Custom * `refresh.generate` wins; otherwise we mint random bytes, encode @@ -285,8 +318,11 @@ async function _generateRefresh(refresh, payload, secret, expiresAtSec) { let plaintext; if (refresh.opaque === false) { // JWT refresh: sign a small payload containing the family reference + // Explicitly typed (RFC 8725 §3.11) so a verifier with a `typ` + // allowlist can never mistake it for an access token. const signed = await sign({ ...payload, kind: 'refresh' }, secret, { alg: refresh.alg, + typ: 'refresh+jwt', expiresIn: expiresAtSec - Math.floor(Date.now() / 1000), }); plaintext = isString(signed) ? signed : signed.token; diff --git a/packages/jwt/tests/token-pair.test.js b/packages/jwt/tests/token-pair.test.js index 7b65376b..f8dc3b21 100644 --- a/packages/jwt/tests/token-pair.test.js +++ b/packages/jwt/tests/token-pair.test.js @@ -271,3 +271,22 @@ async function _hashOf(refreshToken) { const { createHash } = await import('node:crypto'); return createHash('sha256').update(refreshToken).digest('hex'); } + +test('create: a signed refresh token is typed refresh+jwt and needs its own key', async () => { + const store = createStore('memory', { gc: { strategy: 'lazy' } }); + const base = { + access: { alg: 'HS256', expiresIn: '15m' }, + refresh: { opaque: false, alg: 'HS256', expiresIn: '7d', store }, + }; + await assert.rejects( + create({ sub: 'u1' }, { ...base, secret: { access: ACCESS_SECRET, refresh: Buffer.from(ACCESS_SECRET) } }), + err => err instanceof JwtError && err.code === ErrorCode.INVALID_ARGUMENT, + ); + + const res = await create({ sub: 'u1' }, { ...base, secret: { access: ACCESS_SECRET, refresh: REFRESH_SECRET } }); + const header = JSON.parse(Buffer.from(res.refreshToken.split('.')[0], 'base64url').toString()); + assert.equal(header.typ, 'refresh+jwt'); + // The refresh token is not an access token under the access key. + await assert.rejects(verify(res.refreshToken, ACCESS_SECRET, { alg: ['HS256'] })); + store._stop(); +}); From ba7ec258784a51e37a7a522aadc235baeb2af651 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:39:02 +0300 Subject: [PATCH 07/21] fix(paseto): token-pair refresh tokens need their own key With refresh.opaque: false and secret.refresh equal to secret.access, a refresh token decrypted / verified as an access token under the access key. token-pair now refuses an identical access and refresh key when refresh tokens are PASETOs. --- packages/paseto/src/token-pair.js | 26 +++++++++++++++++++++--- packages/paseto/tests/token-pair.test.js | 17 ++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/paseto/src/token-pair.js b/packages/paseto/src/token-pair.js index e63fc505..f0ec59b8 100644 --- a/packages/paseto/src/token-pair.js +++ b/packages/paseto/src/token-pair.js @@ -22,6 +22,7 @@ import { resolveHashFn, resolveEncoding, randomBuffer } from './internal/polymor import { createKeyMutex } from './internal/mutex.js'; import { encrypt } from './local.js'; import { sign } from './public.js'; +import { PasetoKey } from './key.js'; // Fallback for stores without atomic markUsed — serialises the // get→check→add sequence in-process so two concurrent rotate calls for @@ -52,8 +53,8 @@ const _rotateLock = createKeyMutex(); * @property {Store} store REQUIRED. * * @typedef {Object} SecretPair - * @property {Uint8Array | import('node:crypto').KeyObject} access - * @property {Uint8Array | import('node:crypto').KeyObject} [refresh] + * @property {PasetoKey} access a v4 `local` key (or `secret` for `access.purpose: 'public'`) + * @property {PasetoKey} [refresh] required, and distinct from `access`, when `refresh.opaque` is false * * @typedef {Object} CreateOptions * @property {SecretPair} secret @@ -258,6 +259,13 @@ function _assertCreateOptions(options) { if (options.refresh.opaque === false && options.secret.refresh === undefined) { throw invalidArgument('tokenPair.options.secret.refresh is required when refresh.opaque is false'); } + // A PASETO refresh token under the access key would also decrypt / + // verify as an access token — the two must not share one. + if (options.refresh.opaque === false && sameKey(options.secret.access, options.secret.refresh)) { + throw invalidArgument( + 'tokenPair.options.secret.refresh must differ from secret.access when refresh.opaque is false', + ); + } if (options.refresh.expiresIn === undefined) { throw invalidArgument('tokenPair.options.refresh.expiresIn is required'); } @@ -266,6 +274,18 @@ function _assertCreateOptions(options) { } } +/** + * @param {unknown} a + * @param {unknown} b + * @returns {boolean} + */ +function sameKey(a, b) { + if (a === b) { + return true; + } + return a instanceof PasetoKey && b instanceof PasetoKey && a.export().equals(b.export()); +} + /** * Produce `{ plaintext, storeKey }` for a refresh token. Custom * `refresh.generate` wins; otherwise mint random bytes (opaque default) @@ -273,7 +293,7 @@ function _assertCreateOptions(options) { * * @param {RefreshOptions} refresh * @param {Record} payload - * @param {Uint8Array | import('node:crypto').KeyObject} [secret] + * @param {PasetoKey} [secret] * @param {number} expiresAtSec * @returns {Promise<{ plaintext: string, storeKey: string }>} */ diff --git a/packages/paseto/tests/token-pair.test.js b/packages/paseto/tests/token-pair.test.js index 1bfd4faa..b98d8074 100644 --- a/packages/paseto/tests/token-pair.test.js +++ b/packages/paseto/tests/token-pair.test.js @@ -159,3 +159,20 @@ test('a numeric expiresIn is seconds for both the token exp and the reported exp assert.ok(Math.abs(Date.parse(refresh.exp) - res.refreshExpiresAt.getTime()) < 2000, `refresh exp ${refresh.exp}`); store._stop(); }); + +test('opaque:false refuses a refresh key equal to the access key', async () => { + const store = createStore('memory'); + const key = generateKey(); + await assert.rejects( + create( + { userId: 1 }, + { + secret: { access: key, refresh: key }, + access: { expiresIn: '15m' }, + refresh: { expiresIn: '7d', opaque: false, store }, + }, + ), + { code: 'INVALID_ARGUMENT' }, + ); + store._stop(); +}); From 0f9e15ea1c26585b6a1daf718bf92248c4326c47 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:39:19 +0300 Subject: [PATCH 08/21] fix(jwt): HMAC algorithms reject PEM keys with INVALID_KEY A PEM public key or certificate passed for HS256 was imported as an asymmetric KeyObject and only failed deep inside node:crypto with an untyped TypeError (500), instead of the documented INVALID_KEY. HMAC algs now refuse any PEM input up front. --- packages/jwt/src/internal/keys.js | 9 +++++++++ packages/jwt/tests/security.test.js | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/packages/jwt/src/internal/keys.js b/packages/jwt/src/internal/keys.js index 56fc78f2..2ba501ff 100644 --- a/packages/jwt/src/internal/keys.js +++ b/packages/jwt/src/internal/keys.js @@ -69,6 +69,15 @@ export async function normalizeKey(key, alg, direction) { */ function _fromString(key, meta, direction, alg) { if (key.includes('-----BEGIN ')) { + // The classic RS256 → HS256 confusion hands a public PEM to an HMAC alg. + // Refuse it outright with a typed error rather than importing the PEM + // as an asymmetric key and failing later inside node:crypto. + if (meta.family === 'HMAC') { + throw new JwtError( + ErrorCode.INVALID_KEY, + `alg ${alg}: HMAC algorithms take a shared secret, not a PEM key or certificate`, + ); + } try { if (key.includes('CERTIFICATE-----')) { if (direction === 'sign') { diff --git a/packages/jwt/tests/security.test.js b/packages/jwt/tests/security.test.js index a44c17ce..5c0e20da 100644 --- a/packages/jwt/tests/security.test.js +++ b/packages/jwt/tests/security.test.js @@ -189,3 +189,10 @@ test('DoS: token above maxTokenSize refused before parsing', async () => { err => err.code === ErrorCode.TOKEN_TOO_LARGE, ); }); + +test('HS256 with a public-key PEM fails with INVALID_KEY, not a TypeError', async () => { + const { publicKey } = (await import('node:crypto')).generateKeyPairSync('rsa', { modulusLength: 2048 }); + const pem = publicKey.export({ type: 'spki', format: 'pem' }); + const forged = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ4In0.c2ln'; + await assert.rejects(verify(forged, pem, { alg: ['HS256'] }), err => err.code === 'INVALID_KEY'); +}); From be2936ec1216aae8bee210c6d5a647cb264fa3ec Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:40:05 +0300 Subject: [PATCH 09/21] fix(jws): bound verifyJson by size and signature count verifyJson decoded the payload and tried every signature with no size or count limit, unlike compact verify (maxTokenSize 8192). A JWS JSON with thousands of signatures cost thousands of verifications per call. It now enforces maxTokenSize (default 64 KiB for the serialized JSON) and maxSignatures (default 8) before decoding anything. --- packages/jws/src/json.js | 24 +++++++++++++++++++++++- packages/jws/tests/json.test.js | 17 +++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/jws/src/json.js b/packages/jws/src/json.js index eedeefe9..0c6dd2ab 100644 --- a/packages/jws/src/json.js +++ b/packages/jws/src/json.js @@ -19,6 +19,11 @@ import { assertSignSide as assertCritSign, assertVerifySide as assertCritVerify import { encode as b64uEncode, decode as b64uDecode, decodeJson as b64uDecodeJson } from './internal/base64url.js'; import { resolveKey } from './internal/resolver.js'; +// JSON serializations carry several signatures and their headers, so the +// default ceiling is wider than the compact form's 8 KiB. +const DEFAULT_MAX_JSON_SIZE = 65_536; +const DEFAULT_MAX_SIGNATURES = 8; + /** * @typedef {import('./internal/keys.js').KeyInput} KeyInput * @typedef {import('./internal/resolver.js').KeyResolverFn} KeyResolverFn @@ -91,9 +96,12 @@ export async function signJson(payload, signers) { * every signature to fail (or providing no resolvable key for any of * them) raises {@link ErrorCode.INVALID_SIGNATURE}. * + * `maxTokenSize` (default 64 KiB, the serialized JSON) and `maxSignatures` + * (default 8) bound the work before anything is decoded or verified. + * * @param {GeneralJws | FlattenedJws} jwsJson * @param {KeyInput | KeyInput[] | KeyResolverFn} keyish - * @param {VerifyOptions} options + * @param {VerifyOptions & { maxSignatures?: number }} options * @returns {Promise<{ header: Record, payload: unknown, kid?: string, matchedSignatureIndex: number }>} */ export async function verifyJson(jwsJson, keyish, options) { @@ -106,10 +114,24 @@ export async function verifyJson(jwsJson, keyish, options) { throw new JwsError(ErrorCode.INVALID_TOKEN, 'verifyJson: `payload` must be a base64url string'); } + // Bound the work before decoding anything: every signature is a + // signature verification, and the payload is decoded up front. + const maxSize = options?.maxTokenSize ?? DEFAULT_MAX_JSON_SIZE; + const size = Buffer.byteLength(JSON.stringify(jwsJson), 'utf8'); + if (size > maxSize) { + throw new JwsError(ErrorCode.TOKEN_TOO_LARGE, `verifyJson: JWS is ${size} bytes, exceeds maxTokenSize=${maxSize}`); + } const sigList = _extractSignatures(jwsJson); if (sigList.length === 0) { throw new JwsError(ErrorCode.INVALID_TOKEN, 'verifyJson: JWS carries no signatures'); } + const maxSignatures = options?.maxSignatures ?? DEFAULT_MAX_SIGNATURES; + if (sigList.length > maxSignatures) { + throw new JwsError( + ErrorCode.INVALID_TOKEN, + `verifyJson: JWS carries ${sigList.length} signatures, exceeds maxSignatures=${maxSignatures}`, + ); + } const bytes = b64uDecode(encPayload); /** @type {JwsError | null} */ diff --git a/packages/jws/tests/json.test.js b/packages/jws/tests/json.test.js index b2897e36..4781ab85 100644 --- a/packages/jws/tests/json.test.js +++ b/packages/jws/tests/json.test.js @@ -168,3 +168,20 @@ test('verifyJson: neither `signatures` nor `signature` present → INVALID_TOKEN err => err instanceof JwsError && err.code === ErrorCode.INVALID_TOKEN, ); }); + +test('verifyJson: size and signature-count limits apply before any verification', async () => { + const key = randomBytes(32); + const flat = await signJson({ a: 1 }, [{ key, options: { alg: 'HS256' } }]); + const many = { + payload: flat.payload, + signatures: Array.from({ length: 9 }, () => ({ protected: flat.protected, signature: flat.signature })), + }; + await assert.rejects( + verifyJson(many, key, { alg: ['HS256'] }), + err => err.code === 'INVALID_TOKEN' && /maxSignatures/.test(err.message), + ); + assert.ok(await verifyJson({ ...many, signatures: many.signatures.slice(0, 8) }, key, { alg: ['HS256'] })); + + const big = { ...flat, payload: 'A'.repeat(70_000) }; + await assert.rejects(verifyJson(big, key, { alg: ['HS256'] }), err => err.code === 'TOKEN_TOO_LARGE'); +}); From b45ab0e0da53b1e05a004ae7ea8f0283cd038bf3 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:40:41 +0300 Subject: [PATCH 10/21] fix(jwe): bound decryptJson by size and recipient count decryptJson tried a key unwrap for every recipient with no limit on how many there were or how large the JSON was, unlike compact decrypt (maxTokenSize 8192). It now enforces maxTokenSize (default 64 KiB for the serialized JSON) and maxRecipients (default 8) first. --- packages/jwe/src/json.js | 28 +++++++++++++++++++++++++--- packages/jwe/tests/json.test.js | 11 +++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/jwe/src/json.js b/packages/jwe/src/json.js index 634419ed..c7b8509e 100644 --- a/packages/jwe/src/json.js +++ b/packages/jwe/src/json.js @@ -149,12 +149,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} jwe * @param {KeyInput} key - * @param {DecryptOptions} options + * @param {DecryptOptions & { maxRecipients?: number }} options * @returns {Promise} */ export async function decryptJson(jwe, key, options) { @@ -165,8 +172,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); diff --git a/packages/jwe/tests/json.test.js b/packages/jwe/tests/json.test.js index 4a51d245..70099919 100644 --- a/packages/jwe/tests/json.test.js +++ b/packages/jwe/tests/json.test.js @@ -97,3 +97,14 @@ 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), + ); +}); From 4a6632b42c0c5adba567be622c3a949ac8f067e3 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:41:24 +0300 Subject: [PATCH 11/21] fix(jwe)!: JSON headers must be disjoint; protectedHeader is protected only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decryptJson merged each recipient's unprotected header over the protected one and returned the result as protectedHeader. An attacker could therefore change a header value without touching the authenticated part — and callers reading protectedHeader trusted it. The shared unprotected header was ignored, and crit could come from an unprotected header. - protected, shared-unprotected and per-recipient headers must be disjoint (RFC 7516 §7.2.1), otherwise INVALID_HEADER - crit is only accepted in the protected header - the result reports protectedHeader (authenticated only), unprotectedHeader and the joint header separately - encryptJson refuses a parameter set both in options.header and per recipient, so it never produces such a JWE BREAKING CHANGE: decryptJson's protectedHeader no longer includes unprotected members (read header or unprotectedHeader for kid etc.), and JWEs with overlapping headers are rejected. --- packages/jwe/src/decrypt.js | 4 ++- packages/jwe/src/json.js | 50 +++++++++++++++++++++++++++++++-- packages/jwe/tests/json.test.js | 33 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/jwe/src/decrypt.js b/packages/jwe/src/decrypt.js index 25bc6e56..99666c62 100644 --- a/packages/jwe/src/decrypt.js +++ b/packages/jwe/src/decrypt.js @@ -37,7 +37,9 @@ import { JweError, ErrorCode } from './internal/errors.js'; /** * @typedef {Object} DecryptResult - * @property {Record} protectedHeader + * @property {Record} protectedHeader only the integrity-protected header + * @property {Record} [unprotectedHeader] JSON serialization: shared + per-recipient unprotected members + * @property {Record} [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`. */ diff --git a/packages/jwe/src/json.js b/packages/jwe/src/json.js index c7b8509e..d3d6ebdc 100644 --- a/packages/jwe/src/json.js +++ b/packages/jwe/src/json.js @@ -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) }; }); @@ -201,8 +209,26 @@ export async function decryptJson(jwe, key, options) { /** @type {unknown} */ let lastError; + const sharedUnprotected = isObject(jwe.unprotected) ? /** @type {Record} */ (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 | 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( @@ -222,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) { @@ -247,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} a + * @param {Record} 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.`, + ); + } + } } /** diff --git a/packages/jwe/tests/json.test.js b/packages/jwe/tests/json.test.js index 70099919..2362867a 100644 --- a/packages/jwe/tests/json.test.js +++ b/packages/jwe/tests/json.test.js @@ -108,3 +108,36 @@ test('decryptJson: recipient count and size are bounded before any unwrap', asyn 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), + ); +}); From 6bd1ebb05d12c926eef927ffde52c8244d725bf8 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 15:41:38 +0300 Subject: [PATCH 12/21] fix(jwe): reject zip instead of returning compressed bytes zip was listed as an understood header but never implemented, so a token with zip: DEF decrypted to its still-deflated bytes, returned as if they were the plaintext. Any zip header is now INVALID_HEADER. --- packages/jwe/src/internal/common.js | 7 ++++++- packages/jwe/tests/security.test.js | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/jwe/src/internal/common.js b/packages/jwe/src/internal/common.js index 33b37f8d..119360b8 100644 --- a/packages/jwe/src/internal/common.js +++ b/packages/jwe/src/internal/common.js @@ -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 @@ -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; diff --git a/packages/jwe/tests/security.test.js b/packages/jwe/tests/security.test.js index 64115ad1..28737546 100644 --- a/packages/jwe/tests/security.test.js +++ b/packages/jwe/tests/security.test.js @@ -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'); +}); From ce3c5d64d9973b2d995068d81311b68eb5fd9fc3 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:04:20 +0300 Subject: [PATCH 13/21] fix(jwks): alg cross-check also covers keys found by a refetch The remote resolver compared the token's alg with the key's published alg only on a cache hit. A key that arrived through the kid-miss refetch was returned without the check, so an alg mismatch passed whenever the kid was new. Both paths now share one check. --- packages/jwks/src/remote.js | 34 +++++++++++++++++++++--------- packages/jwks/tests/remote.test.js | 10 +++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/jwks/src/remote.js b/packages/jwks/src/remote.js index 0d209f74..00cdc24f 100644 --- a/packages/jwks/src/remote.js +++ b/packages/jwks/src/remote.js @@ -247,6 +247,27 @@ export function createRemoteJWKS(uri, options = {}) { * @param {{ kid: string, alg?: string }} header * @returns {Promise} */ + /** + * 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 === '') { @@ -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; } @@ -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; } } diff --git a/packages/jwks/tests/remote.test.js b/packages/jwks/tests/remote.test.js index d0f7b1c2..6d109fcd 100644 --- a/packages/jwks/tests/remote.test.js +++ b/packages/jwks/tests/remote.test.js @@ -117,6 +117,16 @@ describe('resolver — kid-miss refetch', () => { assert.equal(fn.mock.callCount(), 2); }); + test('a key found by a kid-miss refetch still gets the alg check', async () => { + let keys = [SAMPLE_EC_JWK]; + mockFetch(async () => okResponse({ keys })); + const resolver = createRemoteJWKS('https://example.com/jwks', { cooldownMs: 0 }); + await resolver({ kid: 'k1' }); + + keys = [SAMPLE_EC_JWK, { ...SAMPLE_EC_JWK, kid: 'k3', alg: 'ES256' }]; + await assert.rejects(resolver({ kid: 'k3', alg: 'RS256' }), /alg mismatch/); + }); + test('cooldown prevents rapid refetches', async () => { const fn = mockFetch(async () => okResponse(fakeJwksResponse())); From 54bd2ee3c370e4b543a9d099210b68c04379eefe Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:05:07 +0300 Subject: [PATCH 14/21] fix(shared): JWK keys honour their declared alg, use and key_ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jws / jwt key normalizer checked a JWK's kty (and EC crv) but ignored the members that say what the key is for: a JWK published for RS512, with use: enc, or with key_ops lacking verify was still accepted for another algorithm or operation (RFC 7517 §4.2-4.4). Those mismatches are now INVALID_KEY. --- packages/jws/tests/security.test.js | 13 +++++++++++++ packages/shared/src/normalize-key.js | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/jws/tests/security.test.js b/packages/jws/tests/security.test.js index aea1c789..23ddae9f 100644 --- a/packages/jws/tests/security.test.js +++ b/packages/jws/tests/security.test.js @@ -273,3 +273,16 @@ test('DoS guard: token larger than 1 MB refused via maxTokenSize=1024', async () err => err instanceof JwsError && err.code === ErrorCode.TOKEN_TOO_LARGE, ); }); + +test('a JWK is only used for the alg, use and key_ops it declares', async () => { + const { exportJWK } = await import('@exortek/jwk'); + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const token = await sign({ a: 1 }, privateKey, { alg: 'ES256' }); + const jwk = await exportJWK(publicKey); + const rejects = bad => + assert.rejects(verify(token, { ...jwk, ...bad }, { alg: ['ES256'] }), err => err.code === 'INVALID_KEY'); + await rejects({ alg: 'ES384' }); + await rejects({ use: 'enc' }); + await rejects({ key_ops: ['encrypt'] }); + assert.ok(await verify(token, { ...jwk, alg: 'ES256', use: 'sig', key_ops: ['verify'] }, { alg: ['ES256'] })); +}); diff --git a/packages/shared/src/normalize-key.js b/packages/shared/src/normalize-key.js index 707089b9..266a5a85 100644 --- a/packages/shared/src/normalize-key.js +++ b/packages/shared/src/normalize-key.js @@ -151,6 +151,18 @@ export function createKeyNormalizer({ ErrorClass, ErrorCode, lookupAlg }) { * @param {string} alg */ function fromJwk(jwk, meta, direction, alg) { + // RFC 7517 §4.2-4.4: a JWK that declares what it is for may only be + // used for that — a key published for RS512, for encryption, or + // without the matching key_op is not a key for this operation. + if (typeof jwk.alg === 'string' && jwk.alg !== alg) { + throw new ErrorClass(ErrorCode.INVALID_KEY, `JWK is for alg ${jwk.alg}; cannot use it for ${alg}`); + } + if (jwk.use !== undefined && jwk.use !== 'sig') { + throw new ErrorClass(ErrorCode.INVALID_KEY, `JWK use=${JSON.stringify(jwk.use)} is not a signature key`); + } + if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes(direction)) { + throw new ErrorClass(ErrorCode.INVALID_KEY, `JWK key_ops does not include "${direction}"`); + } if (jwk.kty !== meta.kty) { throw new ErrorClass( ErrorCode.INVALID_KEY, From c5a547d8e66069eecb6e9989b0f4f07ea30e2a90 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:05:16 +0300 Subject: [PATCH 15/21] fix(jwk): toPublic refuses unknown key types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toPublic deleted the private members it knows for EC / OKP / RSA and returned everything else unchanged — so a JWK with an unrecognised kty (or none) came back with its secret fields intact, ready to publish. Only EC, OKP and RSA are accepted now. --- packages/jwk/src/export.js | 5 +++++ packages/jwk/tests/smoke.test.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/packages/jwk/src/export.js b/packages/jwk/src/export.js index 6cf5e901..d8910215 100644 --- a/packages/jwk/src/export.js +++ b/packages/jwk/src/export.js @@ -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') { diff --git a/packages/jwk/tests/smoke.test.js b/packages/jwk/tests/smoke.test.js index 93cfc038..91c758b6 100644 --- a/packages/jwk/tests/smoke.test.js +++ b/packages/jwk/tests/smoke.test.js @@ -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/); +}); From 837fc804fefa9572ccfd76d70ca168255d41c1eb Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:06:04 +0300 Subject: [PATCH 16/21] perf(shared): HMAC on the KeyObject, ECDSA in IEEE P1363 form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HMAC sign / verify exported the secret KeyObject's bytes on every call before handing them to createHmac, which accepts the KeyObject itself. ECDSA converted Node's DER signatures to JOSE's raw R‖S (and back) by hand; node:crypto produces and consumes that form directly with dsaEncoding: 'ieee-p1363' (available on Node 22). Measured on jws verify: HS256 2.8 → 2.5 µs, ES256 34.5 → 34.0 µs. RFC 7515 vectors unchanged. --- packages/shared/src/algorithms.js | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/algorithms.js b/packages/shared/src/algorithms.js index 301cb0e9..4e5071c6 100644 --- a/packages/shared/src/algorithms.js +++ b/packages/shared/src/algorithms.js @@ -41,7 +41,7 @@ import crypto, { constants, createHmac, sign as _sign, verify as _verify } from 'node:crypto'; -import { derToRaw, rawToDer, EC_COORD_BYTES } from './ecdsa.js'; +import { EC_COORD_BYTES } from './ecdsa.js'; /** HMAC minimum secret sizes per RFC 7518 §3.2 — key must be ≥ hash output. */ const HMAC_MIN_BYTES = { HS256: 32, HS384: 48, HS512: 64 }; @@ -98,13 +98,13 @@ export function hmac(alg, hash) { hash, kty: 'oct', hmacMinBytes: HMAC_MIN_BYTES[/** @type {keyof typeof HMAC_MIN_BYTES} */ (alg)], + // createHmac takes the secret KeyObject directly — exporting its bytes + // on every call is a copy (and a key-material allocation) for nothing. async sign(key, message) { - const raw = key.export(); - return createHmac(hash, raw).update(message).digest(); + return createHmac(hash, key).update(message).digest(); }, async verify(key, message, signature) { - const raw = key.export(); - const expected = createHmac(hash, raw).update(message).digest(); + const expected = createHmac(hash, key).update(message).digest(); if (signature.length !== expected.length) { return false; } @@ -171,8 +171,8 @@ export function rsaPss(alg, hash) { } /** - * ECDSA entry factory. Wire signature is raw R‖S per RFC 7515 §3.4; - * we convert on the way in and out through `signing/ecdsa`. + * ECDSA entry factory. Wire signature is raw R‖S per RFC 7515 §3.4, which + * is exactly node:crypto's `dsaEncoding: 'ieee-p1363'` — no DER round trip. * * @param {string} alg * @param {string} hash @@ -188,15 +188,13 @@ export function ecdsa(alg, hash, curve) { kty: 'EC', curve, async sign(key, message) { - const der = _sign(hash, message, key); - return derToRaw(der, curve); + return _sign(hash, message, { key, dsaEncoding: 'ieee-p1363' }); }, async verify(key, message, signature) { if (signature.length !== size * 2) { return false; } - const der = rawToDer(signature, curve); - return _verify(hash, message, key, der); + return _verify(hash, message, { key, dsaEncoding: 'ieee-p1363' }, signature); }, }; } From ee88d6380f0c1b96df7ba96230b18193ebf74022 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:07:26 +0300 Subject: [PATCH 17/21] perf(shared,jwt,jwks): import each key once instead of per verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every jws / jwt verify re-parsed a JWK object or PEM string into a KeyObject, and the jwks local resolver re-imported its key on every resolve. Measured on an ES256 jwt verify: KeyObject 34.4 µs, PEM 48.5 µs, JWK 47.1 µs — about 40 % of the call was the import. - shared createImportCache: JWK objects memoised by identity (WeakMap), strings in a bounded LRU; per alg + direction, successes only - jws / jwt use it for JWK inputs, jwt also for PEM / secret strings - jwks local key sets cache each entry's imported key After: PEM 34.3 µs, JWK 33.7 µs. Key inputs should be treated as immutable once used. --- packages/jwks/src/local.js | 12 +++- packages/jwks/tests/local.test.js | 5 ++ packages/jwt/src/internal/keys.js | 7 ++- packages/shared/src/normalize-key.js | 64 ++++++++++++++++++++- packages/shared/tests/normalize-key.test.js | 24 ++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/packages/jwks/src/local.js b/packages/jwks/src/local.js index ffc39fb3..7c971f3a 100644 --- a/packages/jwks/src/local.js +++ b/packages/jwks/src/local.js @@ -6,6 +6,9 @@ import { generate } from '@exortek/jwk/generate'; import { importJWK } from '@exortek/jwk/import'; + +/** @type {WeakMap>} */ +const importedKeys = new WeakMap(); import { toPublic } from '@exortek/jwk/export'; import { parseDuration } from '@exortek/shared/duration'; import { isString } from '@exortek/shared/predicates'; @@ -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; }, }; } diff --git a/packages/jwks/tests/local.test.js b/packages/jwks/tests/local.test.js index e86cacdf..53110f21 100644 --- a/packages/jwks/tests/local.test.js +++ b/packages/jwks/tests/local.test.js @@ -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); diff --git a/packages/jwt/src/internal/keys.js b/packages/jwt/src/internal/keys.js index 2ba501ff..eb982fed 100644 --- a/packages/jwt/src/internal/keys.js +++ b/packages/jwt/src/internal/keys.js @@ -20,7 +20,7 @@ import { createPrivateKey, createPublicKey, createSecretKey, X509Certificate } from 'node:crypto'; -import { createKeyNormalizer } from '@exortek/shared/normalize-key'; +import { createImportCache, createKeyNormalizer } from '@exortek/shared/normalize-key'; import { isString } from '@exortek/shared/predicates'; import { JwtError, ErrorCode } from './errors.js'; @@ -30,6 +30,9 @@ import { lookup as lookupAlg } from './algorithms.js'; * @typedef {import('node:crypto').KeyObject | Buffer | Uint8Array | string | Record} KeyInput */ +// PEM / HMAC-secret strings handed in on every call are parsed once. +const pemImports = createImportCache(); + const shared = createKeyNormalizer({ ErrorClass: JwtError, ErrorCode, @@ -44,7 +47,7 @@ const shared = createKeyNormalizer({ */ export async function normalizeKey(key, alg, direction) { if (isString(key)) { - return _fromString(key, lookupAlg(alg), direction, alg); + return pemImports.string(key, `${alg}:${direction}`, () => _fromString(key, lookupAlg(alg), direction, alg)); } const out = await shared.normalizeCore(key, alg, direction); if (out !== null) { diff --git a/packages/shared/src/normalize-key.js b/packages/shared/src/normalize-key.js index 266a5a85..e365bda3 100644 --- a/packages/shared/src/normalize-key.js +++ b/packages/shared/src/normalize-key.js @@ -37,7 +37,67 @@ import { KeyObject, createPrivateKey, createPublicKey, createSecretKey } from 'n * @param {{ INVALID_KEY: string }} opts.ErrorCode * @param {(alg: string) => AlgDescriptor} opts.lookupAlg */ +/** + * Memoise imported keys so a verifier handed the same JWK object or PEM + * string on every request doesn't re-parse it each time (~35 % of an + * ES256 verify). Only successful imports are cached, per alg + direction + * (the validation differs between them). JWK objects are keyed by + * identity in a WeakMap — treat key inputs as immutable; PEM strings live + * in a small bounded map. + */ +export function createImportCache({ maxStrings = 256 } = {}) { + /** @type {WeakMap>} */ + const byObject = new WeakMap(); + /** @type {Map} */ + const byString = new Map(); + return { + /** + * @param {object} input + * @param {string} tag + * @param {() => KeyObject} build + * @returns {KeyObject} + */ + object(input, tag, build) { + let perInput = byObject.get(input); + const hit = perInput?.get(tag); + if (hit) { + return hit; + } + const key = build(); + if (!perInput) { + perInput = new Map(); + byObject.set(input, perInput); + } + perInput.set(tag, key); + return key; + }, + /** + * @param {string} input + * @param {string} tag + * @param {() => KeyObject} build + * @returns {KeyObject} + */ + string(input, tag, build) { + const id = `${tag}\0${input}`; + const hit = byString.get(id); + if (hit) { + byString.delete(id); + byString.set(id, hit); + return hit; + } + const key = build(); + if (byString.size >= maxStrings) { + byString.delete(byString.keys().next().value); + } + byString.set(id, key); + return key; + }, + }; +} + export function createKeyNormalizer({ ErrorClass, ErrorCode, lookupAlg }) { + const imports = createImportCache(); + /** * @param {AlgDescriptor} meta * @returns {string[] | null} @@ -233,7 +293,9 @@ export function createKeyNormalizer({ ErrorClass, ErrorCode, lookupAlg }) { return fromBytes(/** @type {Buffer | Uint8Array} */ (key), meta, alg); } if (key && typeof key === 'object' && 'kty' in /** @type {any} */ (key)) { - return fromJwk(/** @type {Record} */ (key), meta, direction, alg); + return imports.object(/** @type {object} */ (key), `${alg}:${direction}`, () => + fromJwk(/** @type {Record} */ (key), meta, direction, alg), + ); } return null; } diff --git a/packages/shared/tests/normalize-key.test.js b/packages/shared/tests/normalize-key.test.js index 6d101629..4d23177f 100644 --- a/packages/shared/tests/normalize-key.test.js +++ b/packages/shared/tests/normalize-key.test.js @@ -105,3 +105,27 @@ describe('createKeyNormalizer', () => { assert.equal(out, null); }); }); + +test('createImportCache: reuses an import per input + tag, never caches failures', async () => { + const { createImportCache } = await import('../src/normalize-key.js'); + const cache = createImportCache({ maxStrings: 2 }); + const jwk = {}; + let builds = 0; + const build = () => ({ id: ++builds }); + const first = cache.object(jwk, 'ES256:verify', build); + assert.equal(cache.object(jwk, 'ES256:verify', build), first); + assert.notEqual(cache.object(jwk, 'ES256:sign', build), first); + + assert.throws(() => + cache.string('pem', 't', () => { + throw new Error('bad'); + }), + ); + assert.equal(cache.string('pem', 't', build).id, builds); + + cache.string('a', 't', build); + cache.string('b', 't', build); + const before = builds; + cache.string('pem', 't', build); // evicted by the 2-entry bound + assert.equal(builds, before + 1); +}); From 0fd4469550f60e97aa088912b8c3bb375990fac0 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:12:18 +0300 Subject: [PATCH 18/21] perf(paseto): BLAKE2b on 32-bit lanes instead of BigInt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored BLAKE2b carried each 64-bit lane as a BigInt, so every v4.local encrypt / decrypt (three BLAKE2b calls) paid for BigInt allocation in the inner loop. Lanes are now pairs of 32-bit words in preallocated Uint32Arrays. Keyed, 32-byte output: 100 B 35.0 → 2.8 µs, 1 KB 152.9 → 9.8 µs, 8 KB 1064 → 71 µs. Verified against RFC 7693 Appendix A, the BLAKE2 keyed KAT, node's blake2b512 across block boundaries, the PASETO v4 vectors, and the previous implementation on 500 random keyed inputs. Token size is already capped (maxTokenSize) before the MAC runs. --- packages/paseto/src/internal/blake2b.js | 241 ++++++++++++++---------- packages/paseto/tests/blake2b.test.js | 35 ++++ 2 files changed, 172 insertions(+), 104 deletions(-) create mode 100644 packages/paseto/tests/blake2b.test.js diff --git a/packages/paseto/src/internal/blake2b.js b/packages/paseto/src/internal/blake2b.js index 5aeb1dd3..8e6f73f2 100644 --- a/packages/paseto/src/internal/blake2b.js +++ b/packages/paseto/src/internal/blake2b.js @@ -6,97 +6,126 @@ * reachable through `createHash`, so we implement the compression * function here. * - * 64-bit lanes are carried as `BigInt`. Inputs are token-sized (a few KB - * at most), so clarity beats the two-word-per-lane micro-optimisation. - * Validated against the RFC 7693 Appendix A vector and the official - * PASETO v4 test vectors. + * Each 64-bit lane is carried as two 32-bit halves (low word first) in a + * `Uint32Array`, so the hot loop is plain integer arithmetic with no + * allocation — the BigInt formulation cost ~150 µs per kilobyte. + * Validated against the RFC 7693 Appendix A vector, the official PASETO v4 + * test vectors, and node's own (unkeyed) `blake2b512`. */ -const MASK64 = (1n << 64n) - 1n; +// SHA-512 IV — also BLAKE2b's initialisation vector — as [lo, hi] halves. +const IV = new Uint32Array([ + 0xf3bcc908, 0x6a09e667, 0x84caa73b, 0xbb67ae85, 0xfe94f82b, 0x3c6ef372, 0x5f1d36f1, 0xa54ff53a, 0xade682d1, + 0x510e527f, 0x2b3e6c1f, 0x9b05688c, 0xfb41bd6b, 0x1f83d9ab, 0x137e2179, 0x5be0cd19, +]); -// SHA-512 IV — also BLAKE2b's initialisation vector. -const IV = [ - 0x6a09e667f3bcc908n, - 0xbb67ae8584caa73bn, - 0x3c6ef372fe94f82bn, - 0xa54ff53a5f1d36f1n, - 0x510e527fade682d1n, - 0x9b05688c2b3e6c1fn, - 0x1f83d9abfb41bd6bn, - 0x5be0cd19137e2179n, -]; +// Message-word permutation schedule (rounds cycle through the first 10 +// rows), pre-doubled to index the [lo, hi] word pairs directly. +const SIGMA = new Uint8Array( + [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], + [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], + [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8], + [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], + [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9], + [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], + [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10], + [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], + [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], + ] + .flat() + .map(i => i * 2), +); -// Message-word permutation schedule (rounds cycle through the first 10 rows). -const SIGMA = [ - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], - [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], - [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], - [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8], - [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], - [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9], - [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], - [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10], - [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], - [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0], - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], - [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], -]; +// Working vector and message words, reused across calls (single-threaded). +const v = new Uint32Array(32); +const m = new Uint32Array(32); -const rotr64 = (x, n) => ((x >> n) | (x << (64n - n))) & MASK64; +/** v[a] += v[b] (64-bit, lane indices are word offsets). */ +function add64(a, b) { + const lo = v[a] + v[b]; + v[a + 1] = v[a + 1] + v[b + 1] + (lo >= 0x100000000 ? 1 : 0); + v[a] = lo; +} + +/** v[a] += m[x] (64-bit). */ +function add64m(a, x) { + const lo = v[a] + m[x]; + v[a + 1] = v[a + 1] + m[x + 1] + (lo >= 0x100000000 ? 1 : 0); + v[a] = lo; +} + +/** The G mixing function on lanes a, b, c, d with message words x, y. */ +function mix(a, b, c, d, x, y) { + add64(a, b); + add64m(a, x); + // v[d] = rotr64(v[d] ^ v[a], 32) + let lo = v[d] ^ v[a]; + let hi = v[d + 1] ^ v[a + 1]; + v[d] = hi; + v[d + 1] = lo; + add64(c, d); + // v[b] = rotr64(v[b] ^ v[c], 24) + lo = v[b] ^ v[c]; + hi = v[b + 1] ^ v[c + 1]; + v[b] = (lo >>> 24) | (hi << 8); + v[b + 1] = (hi >>> 24) | (lo << 8); + add64(a, b); + add64m(a, y); + // v[d] = rotr64(v[d] ^ v[a], 16) + lo = v[d] ^ v[a]; + hi = v[d + 1] ^ v[a + 1]; + v[d] = (lo >>> 16) | (hi << 16); + v[d + 1] = (hi >>> 16) | (lo << 16); + add64(c, d); + // v[b] = rotr64(v[b] ^ v[c], 63) — i.e. rotl 1 + lo = v[b] ^ v[c]; + hi = v[b + 1] ^ v[c + 1]; + v[b] = (hi >>> 31) | (lo << 1); + v[b + 1] = (lo >>> 31) | (hi << 1); +} /** - * The compression function F. Mixes a 128-byte block (16 little-endian - * 64-bit words) into the 8-word state `h`. + * The compression function F over the 128-byte block at `bytes[off]`. * - * @param {BigInt[]} h 8-word chaining state, mutated in place - * @param {BigInt[]} m 16 message words (little-endian) - * @param {BigInt} counter total bytes hashed so far (incl. this block) - * @param {boolean} last true for the final block + * @param {Uint32Array} h 16-word chaining state, mutated in place + * @param {Uint8Array} bytes + * @param {number} off + * @param {number} counter total bytes hashed so far (incl. this block); < 2^53 + * @param {boolean} last */ -function compress(h, m, counter, last) { - const v = [...h, ...IV]; - v[12] ^= counter & MASK64; - v[13] ^= (counter >> 64n) & MASK64; +function compress(h, bytes, off, counter, last) { + for (let i = 0; i < 16; i++) { + v[i] = h[i]; + v[i + 16] = IV[i]; + } + v[24] ^= counter >>> 0; + v[25] ^= Math.floor(counter / 0x100000000); if (last) { - v[14] ^= MASK64; + v[28] = ~v[28]; + v[29] = ~v[29]; } - - const mix = (a, b, c, d, x, y) => { - v[a] = (v[a] + v[b] + x) & MASK64; - v[d] = rotr64(v[d] ^ v[a], 32n); - v[c] = (v[c] + v[d]) & MASK64; - v[b] = rotr64(v[b] ^ v[c], 24n); - v[a] = (v[a] + v[b] + y) & MASK64; - v[d] = rotr64(v[d] ^ v[a], 16n); - v[c] = (v[c] + v[d]) & MASK64; - v[b] = rotr64(v[b] ^ v[c], 63n); - }; - - for (let r = 0; r < 12; r++) { - const s = SIGMA[r]; - mix(0, 4, 8, 12, m[s[0]], m[s[1]]); - mix(1, 5, 9, 13, m[s[2]], m[s[3]]); - mix(2, 6, 10, 14, m[s[4]], m[s[5]]); - mix(3, 7, 11, 15, m[s[6]], m[s[7]]); - mix(0, 5, 10, 15, m[s[8]], m[s[9]]); - mix(1, 6, 11, 12, m[s[10]], m[s[11]]); - mix(2, 7, 8, 13, m[s[12]], m[s[13]]); - mix(3, 4, 9, 14, m[s[14]], m[s[15]]); + for (let i = 0; i < 32; i++) { + const p = off + i * 4; + m[i] = bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16) | (bytes[p + 3] << 24); } - - for (let i = 0; i < 8; i++) { - h[i] ^= v[i] ^ v[i + 8]; + for (let r = 0; r < 12; r++) { + const s = r * 16; + mix(0, 8, 16, 24, SIGMA[s], SIGMA[s + 1]); + mix(2, 10, 18, 26, SIGMA[s + 2], SIGMA[s + 3]); + mix(4, 12, 20, 28, SIGMA[s + 4], SIGMA[s + 5]); + mix(6, 14, 22, 30, SIGMA[s + 6], SIGMA[s + 7]); + mix(0, 10, 20, 30, SIGMA[s + 8], SIGMA[s + 9]); + mix(2, 12, 22, 24, SIGMA[s + 10], SIGMA[s + 11]); + mix(4, 14, 16, 26, SIGMA[s + 12], SIGMA[s + 13]); + mix(6, 8, 18, 28, SIGMA[s + 14], SIGMA[s + 15]); } -} - -/** Read the 128-byte block starting at `off` as 16 little-endian words. */ -function blockWords(bytes, off) { - const view = new DataView(bytes.buffer, bytes.byteOffset + off, 128); - const m = new Array(16); for (let i = 0; i < 16; i++) { - m[i] = view.getBigUint64(i * 8, true); + h[i] ^= v[i] ^ v[i + 16]; } - return m; } /** @@ -118,45 +147,49 @@ export function blake2b(input, { key = new Uint8Array(0), dkLen = 64 } = {}) { const h = IV.slice(); // Parameter block: digest length, key length, fanout=1, depth=1. - h[0] ^= 0x01010000n ^ (BigInt(key.length) << 8n) ^ BigInt(dkLen); + h[0] ^= 0x01010000 ^ (key.length << 8) ^ dkLen; - // A key, if present, forms a full 128-byte first block. - let msg = input; + const block = new Uint8Array(128); + let counter = 0; + + // A key, if present, forms a full 128-byte first block — the last one + // only when there is no message after it. if (key.length > 0) { - const keyed = new Uint8Array(128 + input.length); - keyed.set(key, 0); - keyed.set(input, 128); - msg = keyed; + block.set(key); + counter = 128; + compress(h, block, 0, counter, input.length === 0); + if (input.length === 0) { + return output(h, dkLen); + } } - let counter = 0n; + // Every block except the last (BLAKE2b defers the final block so it can + // flag it, even when the message is empty). let off = 0; - // Process every block except the last (BLAKE2b defers the final block - // so it can flag it, even when the message is empty). - while (msg.length - off > 128) { - counter += 128n; - compress(h, blockWords(msg, off), counter, false); + while (input.length - off > 128) { + counter += 128; + compress(h, input, off, counter, false); off += 128; } // Final block, zero-padded to 128 bytes. - const remaining = msg.length - off; - const finalBlock = new Uint8Array(128); - finalBlock.set(msg.subarray(off, off + remaining)); - counter += BigInt(remaining); - compress(h, blockWords(finalBlock, 0), counter, true); + const remaining = input.length - off; + block.fill(0); + block.set(input.subarray(off, off + remaining)); + counter += remaining; + compress(h, block, 0, counter, true); + return output(h, dkLen); +} +/** + * @param {Uint32Array} h + * @param {number} dkLen + * @returns {Uint8Array} + */ +function output(h, dkLen) { const out = new Uint8Array(dkLen); - const view = new DataView(out.buffer); - for (let i = 0; i < dkLen; i += 8) { - // Last chunk may be < 8 bytes: write to a scratch lane then copy. - if (i + 8 <= dkLen) { - view.setBigUint64(i, h[i / 8], true); - } else { - const lane = new Uint8Array(8); - new DataView(lane.buffer).setBigUint64(0, h[i / 8], true); - out.set(lane.subarray(0, dkLen - i), i); - } + for (let i = 0; i < dkLen; i++) { + out[i] = h[i >>> 2] >>> (8 * (i & 3)); } return out; } diff --git a/packages/paseto/tests/blake2b.test.js b/packages/paseto/tests/blake2b.test.js new file mode 100644 index 00000000..a86c9c47 --- /dev/null +++ b/packages/paseto/tests/blake2b.test.js @@ -0,0 +1,35 @@ +/** + * BLAKE2b against RFC 7693 Appendix A and node's own unkeyed blake2b512. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; + +import { blake2b } from '../src/internal/blake2b.js'; + +test('RFC 7693 Appendix A: BLAKE2b-512("abc")', () => { + const expected = + 'ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d1' + + '7d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923'; + assert.equal(Buffer.from(blake2b(Buffer.from('abc'))).toString('hex'), expected); +}); + +test('unkeyed 64-byte digests match node blake2b512 across block boundaries', () => { + for (const size of [0, 1, 127, 128, 129, 255, 256, 257, 1000, 4096]) { + const msg = randomBytes(size); + assert.equal( + Buffer.from(blake2b(msg)).toString('hex'), + createHash('blake2b512').update(msg).digest('hex'), + `size ${size}`, + ); + } +}); + +test('keyed BLAKE2b of an empty message uses the key block as the final block', () => { + // RFC 7693 Appendix-style keyed vector (BLAKE2b-512, key = 00..3f, msg = ''). + const key = Uint8Array.from({ length: 64 }, (_, i) => i); + const expected = + '10ebb67700b1868efb4417987acf4690ae9d972fb7a590c2f02871799aaa4786' + + 'b5e996e8f0f4eb981fc214b005f42d2ff4233499391653df7aefcbc13fc51568'; + assert.equal(Buffer.from(blake2b(new Uint8Array(0), { key })).toString('hex'), expected); +}); From f193c0928157dd79b2fe30ce26f086f4b32f5f4a Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:16:55 +0300 Subject: [PATCH 19/21] docs(paseto): typed keys, refresh key separation, duration units README and web docs for PasetoKey (generateKey / generateKeyPair return typed keys, keys.local / secret / public wrap raw material, INVALID_KEY_PURPOSE), the distinct refresh key for opaque:false, and the seconds-based token-pair expiries. --- packages/paseto/README.md | 8 +++++--- web/content/paseto/errors.mdx | 1 + web/content/paseto/local.mdx | 16 +++++++++++++--- web/content/paseto/public.mdx | 19 ++++++++++--------- web/content/paseto/token-pair.mdx | 7 +++++-- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/paseto/README.md b/packages/paseto/README.md index 78448000..5a56ea85 100644 --- a/packages/paseto/README.md +++ b/packages/paseto/README.md @@ -39,7 +39,9 @@ PASETO fixes JWT's original sins, but the existing JS options leave gaps: production login reinvents: 1. **No `alg` header, ever.** The version dictates the primitive — - confusion attacks cannot be expressed. + confusion attacks cannot be expressed. Keys are typed too + (`PasetoKey`: version + purpose), so a public key can never be + used as a `v4.local` secret. 2. **`v4.local` = encrypted tokens.** Confidential payloads, not just authenticated — the JWE role without the JWE footgun matrix. 3. **`tokenPair` with reuse detection.** Access + refresh, RFC 6749 @@ -78,7 +80,7 @@ For tokens your own services both mint and read. ```js import { generateKey, encrypt, decrypt } from '@exortek/paseto'; -const key = generateKey(); // 32-byte symmetric key +const key = generateKey(); // typed v4 local key — keys.local(bytes) wraps your own const token = encrypt({ userId: 1, role: 'admin' }, key, { expiresIn: '1h', @@ -172,7 +174,7 @@ await revokeAll(familyId, { store }); | Subpath | Purpose | | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| [`@exortek/paseto`](https://github.com/ExorTek/auth/blob/master/packages/paseto/src/index.js) | `encrypt`, `decrypt`, `sign`, `verify`, `decode`, `generateKey`, `generateKeyPair` | +| [`@exortek/paseto`](https://github.com/ExorTek/auth/blob/master/packages/paseto/src/index.js) | `encrypt`, `decrypt`, `sign`, `verify`, `decode`, `generateKey`, `generateKeyPair`, `keys`, `PasetoKey` | | [`@exortek/paseto/token-pair`](https://github.com/ExorTek/auth/blob/master/packages/paseto/src/token-pair.js) | `create` / `rotate` / `revoke` / `revokeAll` with RFC 6749 §10.4 reuse detection | | [`@exortek/paseto/stores`](https://github.com/ExorTek/auth/blob/master/packages/paseto/src/stores.js) | `createStore('memory' \| 'redis' \| 'custom', ...)` with interval / lazy / lru GC strategies | diff --git a/web/content/paseto/errors.mdx b/web/content/paseto/errors.mdx index e858005b..0f339c57 100644 --- a/web/content/paseto/errors.mdx +++ b/web/content/paseto/errors.mdx @@ -43,6 +43,7 @@ try { | `UNSUPPORTED_VERSION` | 400 | `generateKey` / `generateKeyPair` given the wrong version. | | `INVALID_TOKEN` | 401 | Malformed token, or wrong purpose for the operation. | | `INVALID_KEY` | 401 | Key is the wrong size / type. | +| `INVALID_KEY_PURPOSE` | 500 | Not a `PasetoKey` of the purpose the operation needs (e.g. a public key passed to `encrypt`, or a bare buffer). | | `TOKEN_TOO_LARGE` | 413 | Token exceeds `maxTokenSize` (default 8192 bytes). | | `DECRYPTION_FAILED` | 401 | v4.local MAC mismatch — wrong key or tampered ciphertext. | | `SIGNATURE_INVALID` | 401 | v4.public Ed25519 signature check failed. | diff --git a/web/content/paseto/local.mdx b/web/content/paseto/local.mdx index 5434f286..a776b102 100644 --- a/web/content/paseto/local.mdx +++ b/web/content/paseto/local.mdx @@ -31,9 +31,19 @@ const data = decrypt(token, key, { ## Keys -`generateKey()` returns 32 random bytes (a `Buffer`). Any 32-byte -`Buffer` / `Uint8Array` works — load your own from a KMS or env. A -wrong-length or non-byte key throws `INVALID_KEY`. +`generateKey()` returns a typed `PasetoKey` (version `v4`, purpose +`local`). Load your own 32 bytes from a KMS or env with +`keys.local(bytes)` — a bare buffer is refused, because a 32-byte buffer +could just as well be a v4.public public key, and mixing the two up would +let anyone holding the public key mint `v4.local` tokens. A key of another +purpose throws `INVALID_KEY_PURPOSE`; a wrong-length key `INVALID_KEY`. +`key.export()` returns the raw bytes; `JSON.stringify(key)` never does. + +```js +import { keys } from '@exortek/paseto'; + +const key = keys.local(Buffer.from(process.env.PASETO_LOCAL_KEY, 'base64url')); +``` ## `encrypt` diff --git a/web/content/paseto/public.mdx b/web/content/paseto/public.mdx index f68341d4..3ca58211 100644 --- a/web/content/paseto/public.mdx +++ b/web/content/paseto/public.mdx @@ -23,18 +23,19 @@ const payload = verify(token, publicKey); ## Keys -`generateKeyPair()` returns raw keys in PASETO's wire form: +`generateKeyPair()` returns two typed `PasetoKey`s — `secretKey` (purpose +`secret`) and `publicKey` (purpose `public`). `sign` only takes a `secret` +key and `verify` only a `public` one; anything else, including a bare +buffer, throws `INVALID_KEY_PURPOSE`. `.export()` gives PASETO's wire form +(64-byte `seed ‖ public` / 32-byte public key) for storage. -- `secretKey` — 64 bytes (`seed ‖ public`). -- `publicKey` — 32 bytes. - -`sign` also accepts a bare 32-byte seed or a Node `KeyObject`; `verify` -accepts a 32-byte public key or a `KeyObject`. Anything else throws -`INVALID_KEY`. +Wrap existing material explicitly: ```js -const seedOnly = secretKey.subarray(0, 32); // 32-byte seed works too -sign({ ok: true }, seedOnly); +import { keys } from '@exortek/paseto'; + +const secretKey = keys.secret(seedOrSeedAndPublicBytes); // 32 or 64 bytes, or an Ed25519 private KeyObject +const publicKey = keys.public(publicKeyBytes); // 32 bytes, or an Ed25519 public KeyObject ``` ## Options diff --git a/web/content/paseto/token-pair.mdx b/web/content/paseto/token-pair.mdx index 394488aa..d2c573ff 100644 --- a/web/content/paseto/token-pair.mdx +++ b/web/content/paseto/token-pair.mdx @@ -61,8 +61,11 @@ await revokeAll(familyId, { store }); By default the refresh token is **opaque** — random bytes, hashed before storage, carrying nothing. Set `refresh.opaque: false` to mint a PASETO -refresh token instead (needs `secret.refresh`); `refresh.purpose` -selects its purpose. `tokenSize`, `encoding`, and `hashAlgo` / `hashFn` +refresh token instead (needs `secret.refresh`, a key distinct from +`secret.access` — otherwise the refresh token would also pass as an +access token); `refresh.purpose` selects its purpose. Numeric `expiresIn` +values are seconds, and the token's own `exp` always matches +`accessExpiresAt` / `refreshExpiresAt`. `tokenSize`, `encoding`, and `hashAlgo` / `hashFn` tune the opaque form, and a custom `generate()` overrides it entirely. ## Stores From 2c71f5e6dd25ace4cf92b5e58d46d8cafde673bf Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:16:55 +0300 Subject: [PATCH 20/21] docs(jose): JSON serialization limits, disjoint JWE headers, key checks jws verifyJson / jwe decryptJson size and count limits, the separate protected / unprotected / joint JWE headers, zip rejection, JWK alg / use / key_ops checks, toPublic's kty allowlist and typed jwt refresh tokens. --- web/content/jwe/decrypt.mdx | 1 + web/content/jwe/json.mdx | 13 +++++++++++-- web/content/jwk/export.mdx | 4 +++- web/content/jws/json.mdx | 4 ++++ web/content/jws/verify.mdx | 2 +- web/content/jwt/token-pair.mdx | 2 +- 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/web/content/jwe/decrypt.mdx b/web/content/jwe/decrypt.mdx index a28fab26..9c689c60 100644 --- a/web/content/jwe/decrypt.mdx +++ b/web/content/jwe/decrypt.mdx @@ -53,6 +53,7 @@ decrypt(token, key, options) → Promise<{ protectedHeader, payload }> | token's `enc` not in allowlist | `ENCRYPTION_MISMATCH` | | bad tag, wrong key, tampered segment | `DECRYPTION_FAILED` | | unknown `crit` header | `CRIT_UNSUPPORTED` | +| `zip` header (compression is not supported) | `INVALID_HEADER` | | `exp` in the past | `TOKEN_EXPIRED` | | token over `maxTokenSize` | `TOKEN_TOO_LARGE` | diff --git a/web/content/jwe/json.mdx b/web/content/jwe/json.mdx index f282cf56..93b3f5bc 100644 --- a/web/content/jwe/json.mdx +++ b/web/content/jwe/json.mdx @@ -31,9 +31,17 @@ const { payload: out } = await decryptJson(jwe, rsaPrivateKey, { ```ts encryptJson(payload, recipients, options) → Promise -decryptJson(jwe, key, options) → Promise<{ protectedHeader, payload }> +decryptJson(jwe, key, options) → Promise<{ protectedHeader, unprotectedHeader, header, payload }> ``` +`protectedHeader` holds only what the authentication tag covers; +`unprotectedHeader` is the shared + per-recipient unprotected members and +`header` the joint header the recipient was processed with. The three +header sources must be disjoint (RFC 7516 §7.2.1) and `crit` must be +protected — an unprotected copy of a protected parameter is +`INVALID_HEADER`, never a silent override. `maxTokenSize` (default 64 KiB) +and `maxRecipients` (default 8) bound the work before any unwrap. + `decryptJson` accepts both the General shape (a `recipients` array) and the Flattened shape (a single recipient inlined at the top level). @@ -41,7 +49,8 @@ Flattened shape (a single recipient inlined at the top level). - **`recipients`** — `[{ key, alg, kid?, header?, apu?, apv? }]`, non-empty. - **`options.enc`** — required content-encryption algorithm. -- **`options.header`** — extra protected params (`enc` lives here). +- **`options.header`** — extra protected params (`enc` lives here). A + parameter may not be set both here and on a recipient. - **`options.aad`** — optional JWE AAD (`string` / `Buffer`), bound into the tag and emitted as the `aad` member. - **`options.expiresIn`** — stamps `exp` on object payloads. diff --git a/web/content/jwk/export.mdx b/web/content/jwk/export.mdx index 2e70fd27..fc75d1cc 100644 --- a/web/content/jwk/export.mdx +++ b/web/content/jwk/export.mdx @@ -99,7 +99,9 @@ const publishable = jwk.toPublic(privateJwk) ``` `toPublic` refuses `oct` — symmetric keys have no public projection, -and the throw catches accidental publication of a secret. That guard +and the throw catches accidental publication of a secret. It also refuses +any `kty` other than `RSA` / `EC` / `OKP`, whose private members it could +not know to strip. That guard is the whole reason this helper exists at a higher level than a spread + `delete`. diff --git a/web/content/jws/json.mdx b/web/content/jws/json.mdx index 0da2eba7..a334ccd5 100644 --- a/web/content/jws/json.mdx +++ b/web/content/jws/json.mdx @@ -25,6 +25,10 @@ const { matchedSignatureIndex, header, payload } = await verifyJson(jws, jwks, { }) ``` +`verifyJson` bounds the work before decoding: `maxTokenSize` (default +64 KiB, the serialized JSON) and `maxSignatures` (default 8) — a JWS with +more is rejected rather than verified signature by signature. + ## Forms diff --git a/web/content/jws/verify.mdx b/web/content/jws/verify.mdx index 5b3d196e..de6f3e05 100644 --- a/web/content/jws/verify.mdx +++ b/web/content/jws/verify.mdx @@ -42,7 +42,7 @@ The second argument is polymorphic. All five shapes are first-class. | ------------------------------ | ------------------------------------------------------------------------------------ | | `KeyObject` (public / secret) | Used directly. | | `Buffer` / `Uint8Array` | HMAC secret. Non-HS* algorithms raise `INVALID_KEY`. | -| JWK object | Normalised into a `KeyObject`. `kty` must match the algorithm family. | +| JWK object | Normalised into a `KeyObject` (once per object). `kty` must match the algorithm family; a declared `alg`, `use` (`sig`) or `key_ops` (`verify`) must match too. | | JWK array (JWKS-like) | Picked by matching `kid` against the token's header. Single-key array bypasses match. | | `async (header) => key` | Awaited, then normalised. Return a `KeyObject`, JWK, or `Buffer`. | diff --git a/web/content/jwt/token-pair.mdx b/web/content/jwt/token-pair.mdx index d6cac23d..f4becb0b 100644 --- a/web/content/jwt/token-pair.mdx +++ b/web/content/jwt/token-pair.mdx @@ -54,7 +54,7 @@ const { accessToken, refreshToken, accessExpiresAt, refreshExpiresAt, familyId } | `secret.access` | `KeyInput` | REQUIRED. Any shape `sign` accepts. | | `secret.refresh` | `KeyInput` | REQUIRED. Independent from `secret.access`. | | `access` | `SignOptions` | Same shape as `sign`'s options — `alg` mandatory, `expiresIn` recommended. | -| `refresh.alg` | `string` | Required only when `opaque: false` (signed-JWT refresh). Ignored on the default opaque path — random bytes never touch it. | +| `refresh.alg` | `string` | Required only when `opaque: false` (signed-JWT refresh). Ignored on the default opaque path — random bytes never touch it. Signed refresh tokens carry `typ: refresh+jwt`, and `secret.refresh` must differ from `secret.access`. | | `refresh.expiresIn` | `string \| number` | REQUIRED. `'7d'`, `'30d'`, etc. | | `refresh.opaque` | `boolean` | Default `true` — random string. `false` → signed JWT refresh carrying the family reference. See note below. | | `refresh.tokenSize` | `number` | Default 32 bytes. | From 542bdb9f6f8e3875aedb98b0c59615f28ce043e0 Mon Sep 17 00:00:00 2001 From: Memet Date: Sat, 24 Oct 2026 16:17:18 +0300 Subject: [PATCH 21/21] chore: changesets for JOSE hardening --- .changeset/jwe-json-headers.md | 10 ++++++++++ .changeset/jwk-topublic-kty.md | 5 +++++ .changeset/jwks-alg-refetch.md | 6 ++++++ .changeset/jws-json-limits.md | 7 +++++++ .changeset/jwt-refresh-typing.md | 10 ++++++++++ .changeset/oauth2-paseto-issuer-keys.md | 5 +++++ .changeset/paseto-typed-keys.md | 17 +++++++++++++++++ 7 files changed, 60 insertions(+) create mode 100644 .changeset/jwe-json-headers.md create mode 100644 .changeset/jwk-topublic-kty.md create mode 100644 .changeset/jwks-alg-refetch.md create mode 100644 .changeset/jws-json-limits.md create mode 100644 .changeset/jwt-refresh-typing.md create mode 100644 .changeset/oauth2-paseto-issuer-keys.md create mode 100644 .changeset/paseto-typed-keys.md diff --git a/.changeset/jwe-json-headers.md b/.changeset/jwe-json-headers.md new file mode 100644 index 00000000..f8a36cbb --- /dev/null +++ b/.changeset/jwe-json-headers.md @@ -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`. diff --git a/.changeset/jwk-topublic-kty.md b/.changeset/jwk-topublic-kty.md new file mode 100644 index 00000000..6231afd9 --- /dev/null +++ b/.changeset/jwk-topublic-kty.md @@ -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. diff --git a/.changeset/jwks-alg-refetch.md b/.changeset/jwks-alg-refetch.md new file mode 100644 index 00000000..a4f9c242 --- /dev/null +++ b/.changeset/jwks-alg-refetch.md @@ -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. diff --git a/.changeset/jws-json-limits.md b/.changeset/jws-json-limits.md new file mode 100644 index 00000000..e7eff777 --- /dev/null +++ b/.changeset/jws-json-limits.md @@ -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. diff --git a/.changeset/jwt-refresh-typing.md b/.changeset/jwt-refresh-typing.md new file mode 100644 index 00000000..399d806e --- /dev/null +++ b/.changeset/jwt-refresh-typing.md @@ -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. diff --git a/.changeset/oauth2-paseto-issuer-keys.md b/.changeset/oauth2-paseto-issuer-keys.md new file mode 100644 index 00000000..19039a06 --- /dev/null +++ b/.changeset/oauth2-paseto-issuer-keys.md @@ -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. diff --git a/.changeset/paseto-typed-keys.md b/.changeset/paseto-typed-keys.md new file mode 100644 index 00000000..56de1987 --- /dev/null +++ b/.changeset/paseto-typed-keys.md @@ -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`.