diff --git a/httpsig/README.md b/httpsig/README.md index 82f6a89..6ce7eaa 100644 --- a/httpsig/README.md +++ b/httpsig/README.md @@ -262,7 +262,7 @@ interface VerifyRequest { ```typescript interface VerifyOptions { // Timestamp validation - maxClockSkew?: number // Max clock skew in seconds (default: 60) + maxClockSkew?: number // Window for created / jkt-jwt iat (default: 60) // JWKS caching jwksCacheTtl?: number // JWKS cache TTL in ms (default: 3600000) @@ -704,6 +704,17 @@ answers `revoked_jwt` (added in 2.5.0). The check is the caller's, after code because nothing about the assertion is malformed or timed out, so a client told only `invalid_jwt` would have no reason not to present it again. +A signature whose `created`, or a `jkt-jwt` whose `iat`, is further ahead +of the verifier's clock than `maxClockSkew` answers `clock_skew` (added in +2.6.0). Nothing is malformed or timed out — two clocks disagree — and a +fresh signature or assertion from the same clock carries the same skew, so +a client reading `clock_skew` waits the difference out rather than +refreshing; the response `Date` header is the verifier's clock. A `created` +older than the window is a stale or replayed signature and stays +`invalid_signature`. `exp` on a `jkt-jwt` is judged with no tolerance +(changed in 2.6.0; it used to allow `maxClockSkew`): a sender refreshes +before expiry, the verifier does not allow for it. + > **Changed in 2.4.0.** Earlier versions checked `exp` and `iat` at this > layer and returned `expired_jwt` for a stale one. Since the payload is > not authenticated here, that check bounded honest callers only: anyone @@ -854,7 +865,7 @@ dependencies by design. See `src/vendor/structured-headers/README.md`. ### Timestamp Validation - Signatures must have a `created` timestamp -- Timestamp must be within ±60 seconds (configurable via `maxClockSkew`) +- `created` must be within 60 seconds of the verifier's clock (configurable via `maxClockSkew`): older is `invalid_signature`, further ahead is `clock_skew` - Prevents replay attacks ### JWT Handling diff --git a/httpsig/package.json b/httpsig/package.json index d338291..5671b5c 100644 --- a/httpsig/package.json +++ b/httpsig/package.json @@ -1,6 +1,6 @@ { "name": "@hellocoop/httpsig", - "version": "2.5.0", + "version": "2.6.0", "description": "HTTP Message Signatures (RFC 9421) with Signature-Key header support", "repository": { "type": "git", diff --git a/httpsig/src/errors.ts b/httpsig/src/errors.ts index ef5c7ae..4dcaeac 100644 --- a/httpsig/src/errors.ts +++ b/httpsig/src/errors.ts @@ -84,6 +84,17 @@ export function expiredJwt(message: string): SignatureVerificationError { return new SignatureVerificationError('expired_jwt', message) } +/** + * The signature's `created`, or the assertion's `iat`, is further ahead of + * this verifier's clock than it allows. Nothing is malformed or timed out: + * two clocks disagree. A fresh signature or assertion from the same clock + * carries the same skew, so the sender waits rather than refreshes; the + * response `Date` header is this verifier's clock. + */ +export function clockSkew(message: string): SignatureVerificationError { + return new SignatureVerificationError('clock_skew', message) +} + /** The covered components are missing something the verifier requires. */ export function invalidInput( message: string, diff --git a/httpsig/src/types.ts b/httpsig/src/types.ts index 3202843..f70647e 100644 --- a/httpsig/src/types.ts +++ b/httpsig/src/types.ts @@ -124,7 +124,11 @@ export interface VerifyRequest { export interface VerifyOptions { // Timestamp validation - maxClockSkew?: number // Max clock skew in seconds (default: 60) + // How far `created` (and a jkt-jwt `iat`) may be ahead of this + // verifier's clock, and how old `created` may be, in seconds (default: + // 60). Ahead of the clock by more than this is `clock_skew`; older is + // `invalid_signature`. `exp` gets no tolerance. + maxClockSkew?: number // JWKS caching jwksCacheTtl?: number // JWKS cache TTL in ms (default: 3600000) @@ -269,6 +273,7 @@ export type SignatureErrorCode = | 'invalid_jwt' | 'expired_jwt' | 'revoked_jwt' + | 'clock_skew' | 'issuer_missing' | 'issuer_mismatch' diff --git a/httpsig/src/utils/signature.ts b/httpsig/src/utils/signature.ts index ad00e2f..d3cc66f 100644 --- a/httpsig/src/utils/signature.ts +++ b/httpsig/src/utils/signature.ts @@ -504,6 +504,7 @@ export function parseSignatureError(header: string): SignatureError { 'invalid_jwt', 'expired_jwt', 'revoked_jwt', + 'clock_skew', 'issuer_missing', 'issuer_mismatch', ] diff --git a/httpsig/src/verify.ts b/httpsig/src/verify.ts index ebfd33b..055b230 100644 --- a/httpsig/src/verify.ts +++ b/httpsig/src/verify.ts @@ -33,6 +33,7 @@ import { unsupportedAlgorithm, issuerMissing, issuerMismatch, + clockSkew, } from './errors.js' // JWKS cache. Bounded: the cache key is a URL derived from the request being @@ -392,19 +393,26 @@ async function verifyJktJwt( if (!payload.exp || typeof payload.exp !== 'number') { throw new Error('jkt-jwt: JWT missing exp claim') } - if (payload.exp + maxClockSkew < now) { + if (payload.exp < now) { // Raised structurally rather than as a string the caller pattern // matches. This one is an authenticated statement: the signature over // this assertion was checked at step 6, above, before any claim in it - // was read. + // was read. Judged against this verifier's clock with no tolerance: + // the sender is expected to refresh before expiry, not the verifier + // to allow for it. throw expiredJwt('jkt-jwt: JWT expired') } if (!payload.iat || typeof payload.iat !== 'number') { throw new Error('jkt-jwt: JWT missing iat claim') } - if (payload.iat - maxClockSkew > now) { - throw new Error('jkt-jwt: JWT iat is in the future') + if (payload.iat > now + maxClockSkew) { + // The issuer's clock is ahead of ours by more than the window. Not a + // defect in the assertion: clock_skew, so the sender knows a fresh + // assertion would not help and can wait the difference out instead. + throw clockSkew( + `jkt-jwt: JWT iat is ${payload.iat - now}s ahead of the verifier's clock (window ${maxClockSkew}s)`, + ) } // 8. Extract ephemeral key from cnf.jwk @@ -485,13 +493,19 @@ export async function verify( ]) } - // Validate timestamp + // Validate timestamp. A `created` older than the window is a stale + // or replayed signature (invalid_signature); one further ahead of + // our clock than the window is two clocks disagreeing (clock_skew), + // which signing again would not fix. const now = Math.floor(Date.now() / 1000) - const skew = Math.abs(now - params.created) - - if (skew > maxClockSkew) { + if (params.created > now + maxClockSkew) { + throw clockSkew( + `Signature created is ${params.created - now}s ahead of the verifier's clock (window ${maxClockSkew}s)`, + ) + } + if (now - params.created > maxClockSkew) { throw new Error( - `Signature timestamp out of acceptable range (skew: ${skew}s)`, + `Signature timestamp out of acceptable range (skew: ${now - params.created}s)`, ) } diff --git a/httpsig/tests/test-jkt-jwt.ts b/httpsig/tests/test-jkt-jwt.ts index 8ec318f..e71e071 100644 --- a/httpsig/tests/test-jkt-jwt.ts +++ b/httpsig/tests/test-jkt-jwt.ts @@ -376,9 +376,119 @@ test('jkt-jwt: Should fail with future iat', async () => { assert.strictEqual(verifyResult.verified, false) assert.ok( - verifyResult.error?.includes('future'), - `Expected future error, got: ${verifyResult.error}`, + verifyResult.error?.includes('ahead of the verifier'), + `Expected clock skew error, got: ${verifyResult.error}`, ) + // Two clocks disagreeing, not a defect in the assertion: the sender + // waits the difference out rather than refreshing (signature-key + // draft, clock_skew). + assert.strictEqual(verifyResult.signatureError?.error, 'clock_skew') +}) + +test('jkt-jwt: iat within the window is not skew', async () => { + const identity = await generateEd25519KeyPair() + const ephemeral = await generateEd25519KeyPair() + const jwt = await createJktJwt({ + identityPrivateJwk: identity.privateJwk, + identityPublicJwk: identity.publicJwk, + ephemeralPublicJwk: ephemeral.publicJwk, + iatOffset: 30, // inside the default 60s window + expOffset: 3600, + }) + const result = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: ephemeral.privateJwk, + signatureKey: { type: 'jkt_jwt', jwt }, + dryRun: true, + })) as { headers: Headers } + const verifyResult = await verify({ + method: 'GET', + path: '/data', + authority: 'api.example.com', + headers: result.headers, + }) + assert.strictEqual(verifyResult.verified, true, verifyResult.error) +}) + +test('jkt-jwt: exp is judged with no tolerance', async () => { + const identity = await generateEd25519KeyPair() + const ephemeral = await generateEd25519KeyPair() + const jwt = await createJktJwt({ + identityPrivateJwk: identity.privateJwk, + identityPublicJwk: identity.publicJwk, + ephemeralPublicJwk: ephemeral.publicJwk, + iatOffset: -3600, + expOffset: -5, // five seconds past — would have passed a 60s tolerance + }) + const result = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: ephemeral.privateJwk, + signatureKey: { type: 'jkt_jwt', jwt }, + dryRun: true, + })) as { headers: Headers } + const verifyResult = await verify({ + method: 'GET', + path: '/data', + authority: 'api.example.com', + headers: result.headers, + }) + assert.strictEqual(verifyResult.verified, false) + assert.strictEqual(verifyResult.signatureError?.error, 'expired_jwt') +}) + +test('created ahead of the verifier clock by more than the window is clock_skew', async () => { + const ephemeral = await generateEd25519KeyPair() + // Sign with a clock 5 minutes fast, verify with the real one. + const realNow = Date.now + Date.now = () => realNow() + 300_000 + let result: { headers: Headers } + try { + result = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: ephemeral.privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + } finally { + Date.now = realNow + } + const verifyResult = await verify({ + method: 'GET', + path: '/data', + authority: 'api.example.com', + headers: result.headers, + }) + assert.strictEqual(verifyResult.verified, false) + assert.strictEqual(verifyResult.signatureError?.error, 'clock_skew') + assert.ok( + verifyResult.error?.includes('ahead of the verifier'), + verifyResult.error, + ) +}) + +test('created older than the window is invalid_signature, not clock_skew', async () => { + const ephemeral = await generateEd25519KeyPair() + const realNow = Date.now + Date.now = () => realNow() - 300_000 + let result: { headers: Headers } + try { + result = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: ephemeral.privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + } finally { + Date.now = realNow + } + const verifyResult = await verify({ + method: 'GET', + path: '/data', + authority: 'api.example.com', + headers: result.headers, + }) + assert.strictEqual(verifyResult.verified, false) + assert.strictEqual(verifyResult.signatureError?.error, 'invalid_signature') }) test('jkt-jwt: Should fail with tampered iss (wrong thumbprint)', async () => { diff --git a/httpsig/tests/test-signature-error.ts b/httpsig/tests/test-signature-error.ts index a6128cf..9b87c8d 100644 --- a/httpsig/tests/test-signature-error.ts +++ b/httpsig/tests/test-signature-error.ts @@ -37,6 +37,19 @@ test('Signature-Error: generate revoked_jwt', () => { }) }) +test('Signature-Error: generate clock_skew', () => { + // created or iat further ahead of the verifier's clock than its window: + // nothing malformed or timed out, so neither invalid_signature nor + // expired_jwt fits — those say sign or refresh again, which would carry + // the same skew. The sender waits the difference out instead. + const error: SignatureError = { error: 'clock_skew' } + const header = generateSignatureErrorHeader(error) + assert.strictEqual(header, 'error=clock_skew') + assert.deepStrictEqual(parseSignatureError(header), { + error: 'clock_skew', + }) +}) + test('Signature-Error: generate invalid_signature', () => { const error: SignatureError = { error: 'invalid_signature' } const header = generateSignatureErrorHeader(error) @@ -68,6 +81,7 @@ test('Signature-Error: generate all simple error codes', () => { 'unknown_key', 'invalid_jwt', 'expired_jwt', + 'clock_skew', ] as const for (const code of codes) { @@ -133,6 +147,7 @@ test('Signature-Error: roundtrip all error types', () => { { error: 'unknown_key' }, { error: 'invalid_jwt' }, { error: 'expired_jwt' }, + { error: 'clock_skew' }, ] for (const error of errors) { diff --git a/package-lock.json b/package-lock.json index 74f7f98..83e167c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -241,7 +241,7 @@ }, "httpsig": { "name": "@hellocoop/httpsig", - "version": "2.5.0", + "version": "2.6.0", "license": "MIT", "devDependencies": { "@tsconfig/node18": "^18.2.7",