diff --git a/apps/api/src/auth/__tests__/auth-failure-contract.spec.ts b/apps/api/src/auth/__tests__/auth-failure-contract.spec.ts new file mode 100644 index 00000000..7b620f62 --- /dev/null +++ b/apps/api/src/auth/__tests__/auth-failure-contract.spec.ts @@ -0,0 +1,126 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { JwtAuthGuard } from "../guards/jwt-auth.guard"; +import { RolesGuard } from "../guards/roles.guard"; +import { ROLES_KEY } from "../decorators/roles.decorator"; + +/** + * Contract: every auth failure is a 401/403 with a JSON body of + * `{ error, message, timestamp }` (ISO-8601), and never echoes credentials. + */ +describe("API authentication failure contract", () => { + const reflector = { getAllAndOverride: jest.fn() } as unknown as Reflector; + const jwtGuard = new JwtAuthGuard(reflector); + const rolesGuard = new RolesGuard(reflector); + + const contextWith = (user?: unknown): ExecutionContext => + ({ + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: () => ({ getRequest: () => ({ user }) }), + }) as unknown as ExecutionContext; + + const capture = (fn: () => unknown) => { + try { + fn(); + } catch (e) { + return e as UnauthorizedException | ForbiddenException; + } + throw new Error("expected the call to throw"); + }; + + const expectContract = ( + e: UnauthorizedException | ForbiddenException, + status: number, + error: string, + ) => { + expect(e.getStatus()).toBe(status); + const body = e.getResponse() as Record; + expect(Object.keys(body).sort()).toEqual(["error", "message", "timestamp"]); + expect(body.error).toBe(error); + expect(typeof body.message).toBe("string"); + expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp); + return body; + }; + + describe("401 Unauthorized (JwtAuthGuard)", () => { + it.each([ + ["missing token", null, { message: "No auth token" }, "No auth token"], + ["expired token", null, { name: "TokenExpiredError" }, "JWT access token has expired."], + ["malformed token", null, { name: "JsonWebTokenError" }, "Invalid JWT access token format."], + ["no failure detail", null, undefined, "Invalid or expired JWT access token."], + ])("%s", (_name, user, info, message) => { + const body = expectContract( + capture(() => jwtGuard.handleRequest(null, user, info)), + 401, + "Unauthorized", + ); + expect(body.message).toBe(message); + }); + + it("rejects when the strategy errors even if a user is present", () => { + expectContract( + capture(() => jwtGuard.handleRequest(new Error("boom"), { userId: "u" }, undefined)), + 401, + "Unauthorized", + ); + }); + + it("does not leak the underlying error text or token", () => { + const body = expectContract( + capture(() => + jwtGuard.handleRequest(new Error("secret-token-abc"), null, undefined), + ), + 401, + "Unauthorized", + ); + expect(JSON.stringify(body)).not.toContain("secret-token-abc"); + }); + + it("passes an authenticated user through unchanged", () => { + const user = { userId: "u1", roles: [], permissions: [] }; + expect(jwtGuard.handleRequest(null, user, undefined)).toBe(user); + }); + }); + + describe("403 Forbidden (RolesGuard)", () => { + beforeEach(() => { + (reflector.getAllAndOverride as jest.Mock).mockImplementation((key: string) => + key === ROLES_KEY ? ["admin"] : undefined, + ); + }); + + it("unauthenticated request to a role-protected route", () => { + expectContract(capture(() => rolesGuard.canActivate(contextWith())), 403, "Forbidden"); + }); + + it("authenticated user without the role", () => { + const body = expectContract( + capture(() => + rolesGuard.canActivate(contextWith({ userId: "u", roles: ["viewer"], permissions: [] })), + ), + 403, + "Forbidden", + ); + expect(body.message).toContain("admin"); + }); + + it("user with no roles property", () => { + expectContract( + capture(() => rolesGuard.canActivate(contextWith({ userId: "u" }))), + 403, + "Forbidden", + ); + }); + + it("allows a user with the role", () => { + expect( + rolesGuard.canActivate(contextWith({ userId: "u", roles: ["admin"], permissions: [] })), + ).toBe(true); + }); + }); +}); diff --git a/docs/WEBHOOK_SIGNATURE_ROTATION.md b/docs/WEBHOOK_SIGNATURE_ROTATION.md new file mode 100644 index 00000000..17d72987 --- /dev/null +++ b/docs/WEBHOOK_SIGNATURE_ROTATION.md @@ -0,0 +1,28 @@ +# Webhook Signature Rotation + +`WebhookSignatureRotation` (`src/integrations/webhooks`) signs payloads with the +current secret and keeps the previous secret valid for verification during a grace +window, so receivers can roll over without dropping deliveries. + +```typescript +const rotation = new WebhookSignatureRotation(secret, { graceMs: 24 * 60 * 60 * 1000 }); +const header = rotation.sign(body); // "sha256=" +rotation.rotate(); // new random secret; old one valid until grace ends +rotation.verify(body, header); // { valid, matched: 'current' | 'previous', reason? } +``` + +## Configuration + +| Option | Default | Notes | +| --------- | ------- | ---------------------------------- | +| `graceMs` | 24h | Must be a non-negative number | +| `now` | `Date.now` | Clock injection for tests | + +## Failure semantics + +- Secrets shorter than 16 characters, or a rotation to the same secret, throw and leave state unchanged. +- `verify` never throws: malformed signatures return `reason: 'malformed'`, wrong ones `reason: 'mismatch'`. +- Only one previous secret is retained; a second rotation drops the oldest. +- The previous secret is rejected from the grace deadline onward. +- Comparison is timing-safe. State is in-memory and per instance, so tenants must use separate instances. +- Backward compatible: the header format (`sha256=`) matches the existing `X-GasGuard-Signature`. diff --git a/src/integrations/github/README.md b/src/integrations/github/README.md index b073a1a9..d337f552 100644 --- a/src/integrations/github/README.md +++ b/src/integrations/github/README.md @@ -479,3 +479,29 @@ if (!result.valid) { console.log('Remediation:', result.details); } ``` + +## GitHub Enterprise Server + +Set `baseUrl` on the auth config to target a GHES instance. Both the host URL and +the API URL are accepted and normalized to `/api/v3`; omitting it keeps +`https://api.github.com`. + +```typescript +const client = new GitHubClient({ + token: process.env.GHES_TOKEN, + baseUrl: 'https://ghe.example.com', +}); +``` + +Failure semantics: a non-https URL, embedded credentials, a query/fragment, or a +path other than `/api/v3` throws at construction. Existing configs without +`baseUrl` are unchanged. + +## Token expiration diagnostics + +`client.getTokenExpiration(warnDays = 7)` reads the +`github-authentication-token-expiration` header and returns +`{ status, expiresAt?, msRemaining?, message }` where `status` is one of +`no_expiry`, `valid`, `expiring_soon`, `expired`, `invalid`. `diagnoseTokenExpiration` +is exported for use on a header value directly. Tokens are never included in +messages. Request errors (e.g. 401) propagate from the client unchanged. diff --git a/src/integrations/github/__tests__/enterprise-config.spec.ts b/src/integrations/github/__tests__/enterprise-config.spec.ts new file mode 100644 index 00000000..058ddb4c --- /dev/null +++ b/src/integrations/github/__tests__/enterprise-config.spec.ts @@ -0,0 +1,63 @@ +import axios from 'axios'; +import { resolveGitHubApiBaseUrl, GITHUB_COM_API_URL } from '../enterprise-config'; +import { GitHubClient } from '../github-client'; + +jest.mock('axios'); +jest.mock('../../../security/dns', () => ({ createSafeAgent: jest.fn() })); + +describe('resolveGitHubApiBaseUrl', () => { + it('defaults to github.com', () => { + expect(resolveGitHubApiBaseUrl()).toBe(GITHUB_COM_API_URL); + expect(resolveGitHubApiBaseUrl('')).toBe(GITHUB_COM_API_URL); + expect(resolveGitHubApiBaseUrl('https://api.github.com')).toBe(GITHUB_COM_API_URL); + }); + + it('appends /api/v3 to a GHES host URL', () => { + expect(resolveGitHubApiBaseUrl('https://ghe.example.com')).toBe('https://ghe.example.com/api/v3'); + expect(resolveGitHubApiBaseUrl('https://ghe.example.com/')).toBe('https://ghe.example.com/api/v3'); + expect(resolveGitHubApiBaseUrl('https://ghe.example.com:8443')).toBe( + 'https://ghe.example.com:8443/api/v3', + ); + }); + + it('accepts an explicit API URL', () => { + expect(resolveGitHubApiBaseUrl('https://ghe.example.com/api/v3')).toBe( + 'https://ghe.example.com/api/v3', + ); + expect(resolveGitHubApiBaseUrl('https://ghe.example.com/api/v3/')).toBe( + 'https://ghe.example.com/api/v3', + ); + }); + + it('rejects unsupported input', () => { + expect(() => resolveGitHubApiBaseUrl('not a url')).toThrow(/Invalid/); + expect(() => resolveGitHubApiBaseUrl('http://ghe.example.com')).toThrow(/https/); + expect(() => resolveGitHubApiBaseUrl('https://user:pw@ghe.example.com')).toThrow(/credentials/); + expect(() => resolveGitHubApiBaseUrl('https://ghe.example.com?x=1')).toThrow(/query/); + expect(() => resolveGitHubApiBaseUrl('https://ghe.example.com/api/v4')).toThrow(/path/); + }); +}); + +describe('GitHubClient base URL', () => { + beforeEach(() => { + (axios.create as jest.Mock).mockReturnValue({ + interceptors: { response: { use: jest.fn() } }, + }); + }); + + it('keeps github.com when baseUrl is not set', () => { + new GitHubClient({ token: 't' }); + expect((axios.create as jest.Mock).mock.calls.pop()[0].baseURL).toBe(GITHUB_COM_API_URL); + }); + + it('uses the configured GHES API URL', () => { + new GitHubClient({ token: 't', baseUrl: 'https://ghe.example.com' }); + expect((axios.create as jest.Mock).mock.calls.pop()[0].baseURL).toBe( + 'https://ghe.example.com/api/v3', + ); + }); + + it('fails fast on an invalid baseUrl', () => { + expect(() => new GitHubClient({ baseUrl: 'http://ghe.example.com' })).toThrow(/https/); + }); +}); diff --git a/src/integrations/github/__tests__/token-expiration.spec.ts b/src/integrations/github/__tests__/token-expiration.spec.ts new file mode 100644 index 00000000..6d70734d --- /dev/null +++ b/src/integrations/github/__tests__/token-expiration.spec.ts @@ -0,0 +1,75 @@ +import { + diagnoseTokenExpiration, + parseTokenExpiration, + DEFAULT_EXPIRY_WARNING_DAYS, +} from '../token-expiration'; + +describe('token expiration diagnostics', () => { + const now = new Date('2030-01-01T00:00:00Z'); + const DAY = 24 * 60 * 60 * 1000; + + describe('parseTokenExpiration', () => { + it('parses UTC and offset formats', () => { + expect(parseTokenExpiration('2030-01-10 12:00:00 UTC')?.toISOString()).toBe( + '2030-01-10T12:00:00.000Z', + ); + expect(parseTokenExpiration('2030-01-10 12:00:00 -0500')?.toISOString()).toBe( + '2030-01-10T17:00:00.000Z', + ); + expect(parseTokenExpiration('2030-01-10 12:00:00 +0530')?.toISOString()).toBe( + '2030-01-10T06:30:00.000Z', + ); + }); + + it('returns undefined for unsupported formats', () => { + expect(parseTokenExpiration('tomorrow')).toBeUndefined(); + expect(parseTokenExpiration('2030-01-10')).toBeUndefined(); + expect(parseTokenExpiration('2030-13-45 99:00:00 UTC')).toBeUndefined(); + }); + }); + + describe('diagnoseTokenExpiration', () => { + it('reports no_expiry when the header is absent or blank', () => { + for (const value of [undefined, null, '', ' ']) { + expect(diagnoseTokenExpiration(value, now).status).toBe('no_expiry'); + } + }); + + it('reports valid when expiry is beyond the warning window', () => { + const result = diagnoseTokenExpiration('2030-02-01 00:00:00 UTC', now); + expect(result.status).toBe('valid'); + expect(result.msRemaining).toBe(31 * DAY); + }); + + it('reports expiring_soon inside the warning window, including the boundary', () => { + expect(diagnoseTokenExpiration('2030-01-03 00:00:00 UTC', now).status).toBe('expiring_soon'); + expect( + diagnoseTokenExpiration('2030-01-08 00:00:00 UTC', now, DEFAULT_EXPIRY_WARNING_DAYS).status, + ).toBe('expiring_soon'); + expect(diagnoseTokenExpiration('2030-01-08 00:00:01 UTC', now).status).toBe('valid'); + }); + + it('reports expired at and after the expiry instant', () => { + expect(diagnoseTokenExpiration('2030-01-01 00:00:00 UTC', now).status).toBe('expired'); + const past = diagnoseTokenExpiration('2029-12-31 00:00:00 UTC', now); + expect(past.status).toBe('expired'); + expect(past.msRemaining).toBe(-DAY); + }); + + it('honours a custom warning window', () => { + expect(diagnoseTokenExpiration('2030-01-20 00:00:00 UTC', now, 30).status).toBe('expiring_soon'); + expect(diagnoseTokenExpiration('2030-01-02 00:00:00 UTC', now, 0).status).toBe('valid'); + }); + + it('reports invalid for an unparseable header without echoing it', () => { + const result = diagnoseTokenExpiration('ghp_secret-looking-value', now); + expect(result.status).toBe('invalid'); + expect(result.message).not.toContain('ghp_'); + }); + + it('rejects an invalid warning window', () => { + expect(() => diagnoseTokenExpiration('2030-02-01 00:00:00 UTC', now, -1)).toThrow(/warnDays/); + expect(() => diagnoseTokenExpiration('2030-02-01 00:00:00 UTC', now, NaN)).toThrow(/warnDays/); + }); + }); +}); diff --git a/src/integrations/github/enterprise-config.ts b/src/integrations/github/enterprise-config.ts new file mode 100644 index 00000000..876c5837 --- /dev/null +++ b/src/integrations/github/enterprise-config.ts @@ -0,0 +1,45 @@ +/** + * GitHub Enterprise Server Configuration + * + * Resolves the REST API base URL for github.com or a GitHub Enterprise + * Server (GHES) instance. + */ + +export const GITHUB_COM_API_URL = 'https://api.github.com'; +export const GHES_API_PATH = '/api/v3'; + +/** + * Accepts a GHES host URL (`https://ghe.example.com`) or its API URL + * (`https://ghe.example.com/api/v3`) and returns the normalized API URL. + * Omitting the value keeps github.com. Only https is accepted. + */ +export function resolveGitHubApiBaseUrl(baseUrl?: string): string { + if (baseUrl === undefined || baseUrl === '') { + return GITHUB_COM_API_URL; + } + + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error('Invalid GitHub Enterprise Server URL'); + } + + if (url.protocol !== 'https:') { + throw new Error('GitHub Enterprise Server URL must use https'); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('GitHub Enterprise Server URL must not include credentials, query or fragment'); + } + + const path = url.pathname.replace(/\/+$/, ''); + if (path !== '' && path !== GHES_API_PATH) { + throw new Error(`GitHub Enterprise Server URL path must be empty or ${GHES_API_PATH}`); + } + + // api.github.com has no /api/v3 prefix. + if (url.hostname === 'api.github.com' && path === '') { + return GITHUB_COM_API_URL; + } + return `${url.origin}${GHES_API_PATH}`; +} diff --git a/src/integrations/github/github-client.ts b/src/integrations/github/github-client.ts index d5201450..1d8546c5 100644 --- a/src/integrations/github/github-client.ts +++ b/src/integrations/github/github-client.ts @@ -8,6 +8,12 @@ import axios, { AxiosInstance, AxiosError } from 'axios'; import { GitHubAuthConfig, GitHubRepository, AccessDiagnosticResult } from './types'; import { createSafeAgent } from '../../security/dns'; +import { resolveGitHubApiBaseUrl } from './enterprise-config'; +import { + TOKEN_EXPIRATION_HEADER, + TokenExpirationDiagnostic, + diagnoseTokenExpiration, +} from './token-expiration'; export class GitHubClient { private client: AxiosInstance; @@ -16,7 +22,7 @@ export class GitHubClient { constructor(auth: GitHubAuthConfig = {}) { this.auth = auth; this.client = axios.create({ - baseURL: 'https://api.github.com', + baseURL: resolveGitHubApiBaseUrl(auth.baseUrl), httpAgent: createSafeAgent('http'), httpsAgent: createSafeAgent('https'), headers: { @@ -217,6 +223,18 @@ export class GitHubClient { }; } + /** + * Report when the configured token expires + */ + async getTokenExpiration(warnDays?: number): Promise { + const response = await this.client.get('/user'); + return diagnoseTokenExpiration( + response.headers[TOKEN_EXPIRATION_HEADER], + new Date(), + warnDays, + ); + } + /** * Get pull request changed files (#1043) */ diff --git a/src/integrations/github/index.ts b/src/integrations/github/index.ts index f3d09fd6..af677f0d 100644 --- a/src/integrations/github/index.ts +++ b/src/integrations/github/index.ts @@ -20,6 +20,8 @@ export * from './language-detection'; export * from './pull-request-discovery'; export * from './organization-allowlist'; export * from './installation-validator'; +export * from './enterprise-config'; +export * from './token-expiration'; import { GitHubClient } from './github-client'; import { MonorepoBoundaryDetector } from './monorepo-boundaries'; diff --git a/src/integrations/github/token-expiration.ts b/src/integrations/github/token-expiration.ts new file mode 100644 index 00000000..e11e7e3a --- /dev/null +++ b/src/integrations/github/token-expiration.ts @@ -0,0 +1,74 @@ +/** + * Integration Token Expiration Diagnostics + * + * Interprets the `github-authentication-token-expiration` response header + * (e.g. `2030-01-01 12:00:00 UTC` or `2030-01-01 12:00:00 -0500`). + */ + +export const TOKEN_EXPIRATION_HEADER = 'github-authentication-token-expiration'; +export const DEFAULT_EXPIRY_WARNING_DAYS = 7; + +export type TokenExpirationStatus = 'no_expiry' | 'valid' | 'expiring_soon' | 'expired' | 'invalid'; + +export interface TokenExpirationDiagnostic { + status: TokenExpirationStatus; + expiresAt?: Date; + msRemaining?: number; + message: string; +} + +const HEADER_FORMAT = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) (UTC|[+-]\d{4})$/; +const DAY_MS = 24 * 60 * 60 * 1000; + +export function parseTokenExpiration(value: string): Date | undefined { + const match = HEADER_FORMAT.exec(value.trim()); + if (!match) { + return undefined; + } + const [, y, mo, d, h, mi, s, zone] = match; + const offset = zone === 'UTC' ? 'Z' : `${zone.slice(0, 3)}:${zone.slice(3)}`; + const date = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}${offset}`); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +export function diagnoseTokenExpiration( + headerValue: string | undefined | null, + now: Date = new Date(), + warnDays: number = DEFAULT_EXPIRY_WARNING_DAYS, +): TokenExpirationDiagnostic { + if (!Number.isFinite(warnDays) || warnDays < 0) { + throw new Error('warnDays must be a non-negative number'); + } + if (headerValue === undefined || headerValue === null || headerValue.trim() === '') { + return { status: 'no_expiry', message: 'Token does not report an expiration date.' }; + } + + const expiresAt = parseTokenExpiration(headerValue); + if (!expiresAt) { + return { status: 'invalid', message: 'Token expiration header could not be parsed.' }; + } + + const msRemaining = expiresAt.getTime() - now.getTime(); + if (msRemaining <= 0) { + return { + status: 'expired', + expiresAt, + msRemaining, + message: `Token expired at ${expiresAt.toISOString()}.`, + }; + } + if (msRemaining <= warnDays * DAY_MS) { + return { + status: 'expiring_soon', + expiresAt, + msRemaining, + message: `Token expires at ${expiresAt.toISOString()} (within ${warnDays} days).`, + }; + } + return { + status: 'valid', + expiresAt, + msRemaining, + message: `Token is valid until ${expiresAt.toISOString()}.`, + }; +} diff --git a/src/integrations/github/types.ts b/src/integrations/github/types.ts index 5dfb2074..ba7942ee 100644 --- a/src/integrations/github/types.ts +++ b/src/integrations/github/types.ts @@ -17,6 +17,8 @@ export interface GitHubAuthConfig { appId?: string; installationId?: string; privateKey?: string; + /** GitHub Enterprise Server URL; defaults to github.com */ + baseUrl?: string; } export interface BranchProtectionRule { diff --git a/src/integrations/index.ts b/src/integrations/index.ts index 148e673d..0841fa56 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -6,3 +6,4 @@ export * from './wallets/stellar'; export * from './github'; +export * from './webhooks'; diff --git a/src/integrations/webhooks/__tests__/signature-rotation.spec.ts b/src/integrations/webhooks/__tests__/signature-rotation.spec.ts new file mode 100644 index 00000000..47a62e56 --- /dev/null +++ b/src/integrations/webhooks/__tests__/signature-rotation.spec.ts @@ -0,0 +1,104 @@ +import { + WebhookSignatureRotation, + DEFAULT_ROTATION_GRACE_MS, +} from '../signature-rotation'; + +describe('WebhookSignatureRotation', () => { + const secretA = 'a'.repeat(32); + const secretB = 'b'.repeat(32); + const body = JSON.stringify({ event: 'scan.completed' }); + let clock: number; + const now = () => clock; + + beforeEach(() => { + clock = 1_000_000; + }); + + it('signs and verifies with the current secret', () => { + const rotation = new WebhookSignatureRotation(secretA, { now }); + const sig = rotation.sign(body); + expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/); + expect(rotation.verify(body, sig)).toEqual({ valid: true, matched: 'current' }); + }); + + it('rejects a tampered body', () => { + const rotation = new WebhookSignatureRotation(secretA, { now }); + expect(rotation.verify(body + ' ', rotation.sign(body))).toEqual({ + valid: false, + reason: 'mismatch', + }); + }); + + it('signs with the new secret after rotation', () => { + const rotation = new WebhookSignatureRotation(secretA, { now }); + const oldSig = rotation.sign(body); + rotation.rotate(secretB); + expect(rotation.sign(body)).not.toBe(oldSig); + expect(rotation.verify(body, rotation.sign(body)).matched).toBe('current'); + }); + + it('accepts the previous secret during the grace window', () => { + const rotation = new WebhookSignatureRotation(secretA, { now, graceMs: 1000 }); + const oldSig = rotation.sign(body); + const { previousExpiresAt } = rotation.rotate(secretB); + expect(previousExpiresAt).toBe(clock + 1000); + + clock += 999; + expect(rotation.verify(body, oldSig)).toEqual({ valid: true, matched: 'previous' }); + expect(rotation.getStatus()).toEqual({ hasPrevious: true, previousExpiresAt }); + }); + + it('rejects the previous secret at and after the grace boundary', () => { + const rotation = new WebhookSignatureRotation(secretA, { now, graceMs: 1000 }); + const oldSig = rotation.sign(body); + rotation.rotate(secretB); + + clock += 1000; + expect(rotation.verify(body, oldSig)).toEqual({ valid: false, reason: 'mismatch' }); + expect(rotation.getStatus()).toEqual({ hasPrevious: false }); + }); + + it('only keeps one previous secret', () => { + const rotation = new WebhookSignatureRotation(secretA, { now }); + const sigA = rotation.sign(body); + rotation.rotate(secretB); + rotation.rotate('c'.repeat(32)); + expect(rotation.verify(body, sigA).valid).toBe(false); + }); + + it('generates a secret when none is supplied', () => { + const rotation = new WebhookSignatureRotation(secretA, { now }); + const { secret } = rotation.rotate(); + expect(secret).toMatch(/^[0-9a-f]{64}$/); + }); + + it('uses a 24h grace window by default', () => { + const rotation = new WebhookSignatureRotation(secretA, { now }); + expect(rotation.rotate(secretB).previousExpiresAt).toBe(clock + DEFAULT_ROTATION_GRACE_MS); + }); + + it('flags malformed signatures', () => { + const rotation = new WebhookSignatureRotation(secretA, { now }); + const bad = ['', 'sha256=', 'sha256=zz', 'md5=' + 'a'.repeat(64), 'a'.repeat(64)]; + for (const signature of bad) { + expect(rotation.verify(body, signature)).toEqual({ valid: false, reason: 'malformed' }); + } + expect(rotation.verify(body, undefined as any).reason).toBe('malformed'); + }); + + it('rejects invalid secrets and options', () => { + expect(() => new WebhookSignatureRotation('short')).toThrow(/at least 16/); + expect(() => new WebhookSignatureRotation(secretA, { graceMs: -1 })).toThrow(/graceMs/); + const rotation = new WebhookSignatureRotation(secretA, { now }); + expect(() => rotation.rotate('short')).toThrow(/at least 16/); + expect(() => rotation.rotate(secretA)).toThrow(/differ/); + }); + + it('does not change state when rotation fails', () => { + const rotation = new WebhookSignatureRotation(secretA, { now }); + const sig = rotation.sign(body); + expect(() => rotation.rotate('short')).toThrow(); + expect(rotation.sign(body)).toBe(sig); + expect(rotation.getStatus()).toEqual({ hasPrevious: false }); + }); +}); diff --git a/src/integrations/webhooks/index.ts b/src/integrations/webhooks/index.ts new file mode 100644 index 00000000..afb4f2dd --- /dev/null +++ b/src/integrations/webhooks/index.ts @@ -0,0 +1 @@ +export * from './signature-rotation'; diff --git a/src/integrations/webhooks/signature-rotation.ts b/src/integrations/webhooks/signature-rotation.ts new file mode 100644 index 00000000..8d1cfd77 --- /dev/null +++ b/src/integrations/webhooks/signature-rotation.ts @@ -0,0 +1,108 @@ +/** + * Webhook Signature Rotation + * + * Signs with the current secret and keeps the previous secret valid for a + * grace window so receivers can roll over without dropping deliveries. + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; + +export const WEBHOOK_SIGNATURE_ALGO = 'sha256'; +export const MIN_WEBHOOK_SECRET_LENGTH = 16; +export const DEFAULT_ROTATION_GRACE_MS = 24 * 60 * 60 * 1000; + +export interface SignatureRotationOptions { + graceMs?: number; + now?: () => number; +} + +export type SignatureMatch = 'current' | 'previous'; + +export interface SignatureVerification { + valid: boolean; + matched?: SignatureMatch; + reason?: 'malformed' | 'mismatch'; +} + +export class WebhookSignatureRotation { + private current: string; + private previous?: string; + private previousExpiresAt = 0; + private readonly graceMs: number; + private readonly now: () => number; + + constructor(secret: string, options: SignatureRotationOptions = {}) { + this.graceMs = options.graceMs ?? DEFAULT_ROTATION_GRACE_MS; + if (!Number.isFinite(this.graceMs) || this.graceMs < 0) { + throw new Error('graceMs must be a non-negative number'); + } + this.now = options.now ?? Date.now; + this.current = WebhookSignatureRotation.assertSecret(secret); + } + + /** + * Promote a new secret (generated when omitted). The old secret stays + * valid for verification until the grace window ends. + */ + rotate(newSecret?: string): { secret: string; previousExpiresAt: number } { + const secret = WebhookSignatureRotation.assertSecret( + newSecret ?? randomBytes(32).toString('hex'), + ); + if (secret === this.current) { + throw new Error('New webhook secret must differ from the current secret'); + } + this.previous = this.current; + this.previousExpiresAt = this.now() + this.graceMs; + this.current = secret; + return { secret, previousExpiresAt: this.previousExpiresAt }; + } + + /** Header value for the current secret: `sha256=`. */ + sign(body: string): string { + return `${WEBHOOK_SIGNATURE_ALGO}=${this.digest(body, this.current)}`; + } + + verify(body: string, signature: string): SignatureVerification { + if (typeof signature !== 'string' || !/^sha256=[0-9a-f]{64}$/i.test(signature)) { + return { valid: false, reason: 'malformed' }; + } + const incoming = Buffer.from(signature.slice('sha256='.length), 'hex'); + + if (this.matches(body, this.current, incoming)) { + return { valid: true, matched: 'current' }; + } + if (this.previousActive() && this.matches(body, this.previous as string, incoming)) { + return { valid: true, matched: 'previous' }; + } + return { valid: false, reason: 'mismatch' }; + } + + getStatus(): { hasPrevious: boolean; previousExpiresAt?: number } { + const hasPrevious = this.previousActive(); + return hasPrevious + ? { hasPrevious, previousExpiresAt: this.previousExpiresAt } + : { hasPrevious }; + } + + private previousActive(): boolean { + return this.previous !== undefined && this.now() < this.previousExpiresAt; + } + + private matches(body: string, secret: string, incoming: Buffer): boolean { + const expected = Buffer.from(this.digest(body, secret), 'hex'); + return expected.length === incoming.length && timingSafeEqual(expected, incoming); + } + + private digest(body: string, secret: string): string { + return createHmac(WEBHOOK_SIGNATURE_ALGO, secret).update(body).digest('hex'); + } + + private static assertSecret(secret: string): string { + if (typeof secret !== 'string' || secret.length < MIN_WEBHOOK_SECRET_LENGTH) { + throw new Error( + `Webhook secret must be at least ${MIN_WEBHOOK_SECRET_LENGTH} characters`, + ); + } + return secret; + } +}