Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions httpsig/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion httpsig/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 11 additions & 0 deletions httpsig/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion httpsig/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -269,6 +273,7 @@ export type SignatureErrorCode =
| 'invalid_jwt'
| 'expired_jwt'
| 'revoked_jwt'
| 'clock_skew'
| 'issuer_missing'
| 'issuer_mismatch'

Expand Down
1 change: 1 addition & 0 deletions httpsig/src/utils/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ export function parseSignatureError(header: string): SignatureError {
'invalid_jwt',
'expired_jwt',
'revoked_jwt',
'clock_skew',
'issuer_missing',
'issuer_mismatch',
]
Expand Down
32 changes: 23 additions & 9 deletions httpsig/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)`,
)
}

Expand Down
114 changes: 112 additions & 2 deletions httpsig/tests/test-jkt-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
15 changes: 15 additions & 0 deletions httpsig/tests/test-signature-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading