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`. 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/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/src/json.js b/packages/jwe/src/json.js index 634419ed..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) }; }); @@ -149,12 +157,19 @@ export async function encryptJson(payload, recipients, options) { * @typedef {import('./decrypt.js').DecryptOptions} DecryptOptions */ +// A JSON serialization carries several recipients, so its default ceiling +// is wider than the compact form's 8 KiB. +const DEFAULT_MAX_JSON_SIZE = 65_536; +const DEFAULT_MAX_RECIPIENTS = 8; + /** - * Decrypt a General or Flattened JWE JSON serialization. + * Decrypt a General or Flattened JWE JSON serialization. `maxTokenSize` + * (default 64 KiB, the serialized JSON) and `maxRecipients` (default 8) + * bound the work before any recipient is tried. * * @param {GeneralJwe | Record} jwe * @param {KeyInput} key - * @param {DecryptOptions} options + * @param {DecryptOptions & { maxRecipients?: number }} options * @returns {Promise} */ export async function decryptJson(jwe, key, options) { @@ -165,8 +180,23 @@ export async function decryptJson(jwe, key, options) { throw new JweError(ErrorCode.INVALID_TOKEN, 'decryptJson: expected a JWE JSON object with a "protected" member.'); } - const protectedHeader = assertHeaderObject(b64uDecodeJson(/** @type {string} */ (jwe.protected))); + // Bound the work before decoding anything: each recipient is a key + // unwrap attempt (potentially an ECDH / RSA operation). + const maxSize = options.maxTokenSize ?? DEFAULT_MAX_JSON_SIZE; + const size = Buffer.byteLength(JSON.stringify(jwe), 'utf8'); + if (size > maxSize) { + throw new JweError(ErrorCode.TOKEN_TOO_LARGE, `decryptJson: JWE is ${size} bytes, over the ${maxSize} limit.`); + } const recipientList = normalizeRecipients(jwe); + const maxRecipients = options.maxRecipients ?? DEFAULT_MAX_RECIPIENTS; + if (recipientList.length > maxRecipients) { + throw new JweError( + ErrorCode.INVALID_TOKEN, + `decryptJson: JWE has ${recipientList.length} recipients, over maxRecipients=${maxRecipients}.`, + ); + } + + const protectedHeader = assertHeaderObject(b64uDecodeJson(/** @type {string} */ (jwe.protected))); const encodedAad = jwe.aad === undefined ? undefined : /** @type {string} */ (jwe.aad); const aad = buildAad(/** @type {string} */ (jwe.protected), encodedAad); @@ -179,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( @@ -200,6 +248,7 @@ export async function decryptJson(jwe, key, options) { const encryptedKey = recipient.encrypted_key ? b64uDecode(recipient.encrypted_key) : Buffer.alloc(0); cek = unwrapCek(alg, enc, key, header, encryptedKey); matchedHeader = header; + matchedUnprotected = { ...sharedUnprotected, ...perRecipient }; matchedEnc = enc; break; } catch (err) { @@ -225,7 +274,26 @@ export async function decryptJson(jwe, key, options) { const payload = decodePlaintext(plaintext); enforceExpiry(payload, options); - return { protectedHeader: matchedHeader, payload }; + // `protectedHeader` is exactly what the tag authenticated; the + // unprotected members are reported separately, and `header` is the + // joint header the recipient was processed with. + return { protectedHeader, unprotectedHeader: matchedUnprotected, header: matchedHeader, payload }; +} + +/** + * @param {Record} 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 4a51d245..2362867a 100644 --- a/packages/jwe/tests/json.test.js +++ b/packages/jwe/tests/json.test.js @@ -97,3 +97,47 @@ test('decryptJson enforces the allowlist', async () => { isCode(ErrorCode.ALGORITHM_MISMATCH), ); }); + +test('decryptJson: recipient count and size are bounded before any unwrap', async () => { + const jwe = await encryptJson(PAYLOAD, [{ key: rsa.publicKey, alg: 'RSA-OAEP-256' }], { enc: 'A256GCM' }); + const opts = { alg: ['RSA-OAEP-256'], enc: ['A256GCM'] }; + const flood = { ...jwe, recipients: Array.from({ length: 9 }, () => jwe.recipients[0]) }; + await assert.rejects(decryptJson(flood, rsa.privateKey, opts), isCode(ErrorCode.INVALID_TOKEN)); + await assert.rejects( + decryptJson({ ...jwe, ciphertext: 'A'.repeat(70_000) }, rsa.privateKey, opts), + isCode(ErrorCode.TOKEN_TOO_LARGE), + ); +}); + +test('decryptJson: an unprotected header may not shadow a protected value', async () => { + const jwe = await encryptJson(PAYLOAD, [{ key: rsa.publicKey, alg: 'RSA-OAEP-256', kid: 'r1' }], { + enc: 'A256GCM', + header: { typ: 'secure+jwe' }, + }); + const opts = { alg: ['RSA-OAEP-256'], enc: ['A256GCM'] }; + const shadowed = { + ...jwe, + recipients: [{ ...jwe.recipients[0], header: { ...jwe.recipients[0].header, typ: 'evil' } }], + }; + await assert.rejects(decryptJson(shadowed, rsa.privateKey, opts), isCode(ErrorCode.INVALID_HEADER)); + await assert.rejects( + decryptJson({ ...jwe, unprotected: { crit: ['x'], x: 1 } }, rsa.privateKey, opts), + isCode(ErrorCode.INVALID_HEADER), + ); + + const ok = await decryptJson(jwe, rsa.privateKey, opts); + assert.equal(ok.protectedHeader.typ, 'secure+jwe'); + assert.equal(ok.protectedHeader.kid, undefined, 'unprotected members are not reported as protected'); + assert.equal(ok.unprotectedHeader.kid, 'r1'); + assert.equal(ok.header.kid, 'r1'); +}); + +test('encryptJson refuses a header set both shared and per recipient', async () => { + await assert.rejects( + encryptJson(PAYLOAD, [{ key: rsa.publicKey, alg: 'RSA-OAEP-256', kid: 'r1' }], { + enc: 'A256GCM', + header: { kid: 'shared' }, + }), + isCode(ErrorCode.INVALID_ARGUMENT), + ); +}); 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'); +}); 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/); +}); 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/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/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/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())); 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'); +}); 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/jwt/src/internal/keys.js b/packages/jwt/src/internal/keys.js index 56fc78f2..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) { @@ -69,6 +72,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/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/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'); +}); 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(); +}); 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' }); +}); 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/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/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/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/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/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/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/src/token-pair.js b/packages/paseto/src/token-pair.js index 2d874d65..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 @@ -103,7 +104,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, @@ -252,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'); } @@ -260,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) @@ -267,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 }>} */ @@ -285,7 +311,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/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); +}); 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..9cde55ce 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,46 @@ 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); +}); + +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' }); +}); + +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'); +}); 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', () => { diff --git a/packages/paseto/tests/token-pair.test.js b/packages/paseto/tests/token-pair.test.js index 5fc17034..b98d8074 100644 --- a/packages/paseto/tests/token-pair.test.js +++ b/packages/paseto/tests/token-pair.test.js @@ -141,3 +141,38 @@ 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(); +}); + +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(); +}); 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); }, }; } diff --git a/packages/shared/src/normalize-key.js b/packages/shared/src/normalize-key.js index 707089b9..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} @@ -151,6 +211,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, @@ -221,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); +}); 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. | 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