import { describe, it, expect, afterEach, vi } from 'vitest'; import { createHmac } from 'node:crypto'; import type { IncomingHttpHeaders } from 'node:http'; import { verifySignature } from '../lib/signatures.js'; import { providers } from '../config/providers.js'; import type { ProviderSlug } from '../types/integration.js'; const SECRET = 'top-secret-signing-key'; const RAW_BODY = Buffer.from(JSON.stringify({ action: 'create', id: 'c1' }), 'utf8'); const OTHER_BODY = Buffer.from(JSON.stringify({ action: 'remove', id: 'c1' }), 'utf8'); const hmacHex = (payload: string | Buffer, secret = SECRET): string => createHmac('sha256', secret).update(payload).digest('hex'); const verify = ( provider: ProviderSlug, headers: IncomingHttpHeaders, rawBody: Buffer = RAW_BODY, toleranceSeconds?: number ) => verifySignature({ provider, rawBody, headers, secret: SECRET, toleranceSeconds }); const nowSeconds = (): string => Math.floor(Date.now() / 1000).toString(); const slackHeaders = (rawBody: Buffer, timestamp: string): IncomingHttpHeaders => ({ 'x-slack-request-timestamp': timestamp, 'x-slack-signature': `v0=${hmacHex(`v0:${timestamp}:${rawBody.toString('utf8')}`)}`, }); describe('verifySignature: linear-sha256', () => { it('should accept a correct bare hex digest of the raw body', () => { expect(verify('linear', { 'linear-signature': hmacHex(RAW_BODY) })).toEqual({ ok: true }); }); it('should read the first value when the header arrives as an array', () => { expect(verify('linear', { 'linear-signature': [hmacHex(RAW_BODY)] })).toEqual({ ok: true }); }); it('should reject a signature computed over a different body', () => { expect(verify('linear', { 'linear-signature': hmacHex(OTHER_BODY) })).toEqual({ ok: false, reason: 'mismatch', }); }); it('should reject a signature computed with a different secret', () => { expect(verify('linear', { 'linear-signature': hmacHex(RAW_BODY, 'wrong') })).toEqual({ ok: false, reason: 'mismatch', }); }); it('should report a missing header', () => { expect(verify('linear', {})).toEqual({ ok: false, reason: 'missing' }); expect(verify('linear', { 'linear-signature': '' })).toEqual({ ok: false, reason: 'missing', }); }); it('should report a malformed header', () => { expect(verify('linear', { 'linear-signature': `sha256=${hmacHex(RAW_BODY)}` })).toEqual({ ok: false, reason: 'malformed', }); expect(verify('linear', { 'linear-signature': 'deadbeef' })).toEqual({ ok: false, reason: 'malformed', }); expect(verify('linear', { 'linear-signature': hmacHex(RAW_BODY).toUpperCase() })).toEqual({ ok: false, reason: 'malformed', }); }); it('should be case sensitive about the header name only', () => { expect(verify('linear', { 'LINEAR-SIGNATURE': hmacHex(RAW_BODY) })).toEqual({ ok: false, reason: 'missing', }); }); }); describe('verifySignature: slack-v0', () => { afterEach(() => { vi.useRealTimers(); }); it('should accept a correct v0 signature over the timestamped base string', () => { const timestamp = nowSeconds(); expect(verify('slack', slackHeaders(RAW_BODY, timestamp))).toEqual({ ok: true }); }); it('should accept a signature generated against a frozen clock', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z')); const headers = slackHeaders(RAW_BODY, nowSeconds()); expect(verify('slack', headers)).toEqual({ ok: true }); }); it('should reject a signature computed over a different body', () => { const timestamp = nowSeconds(); expect(verify('slack', slackHeaders(OTHER_BODY, timestamp))).toEqual({ ok: false, reason: 'mismatch', }); }); it('should reject a signature bound to a different timestamp', () => { const timestamp = nowSeconds(); const headers = { ...slackHeaders(RAW_BODY, timestamp), 'x-slack-request-timestamp': (Number(timestamp) - 1).toString(), }; expect(verify('slack', headers)).toEqual({ ok: false, reason: 'mismatch' }); }); it('should report a missing signature header', () => { expect(verify('slack', { 'x-slack-request-timestamp': nowSeconds() })).toEqual({ ok: false, reason: 'missing', }); }); it('should report a missing timestamp header', () => { const { 'x-slack-signature': signature } = slackHeaders(RAW_BODY, nowSeconds()); expect(verify('slack', { 'x-slack-signature': signature })).toEqual({ ok: false, reason: 'missing', }); }); it('should report a malformed signature header', () => { const timestamp = nowSeconds(); expect( verify('slack', { ...slackHeaders(RAW_BODY, timestamp), 'x-slack-signature': 'v1=abc' }) ).toEqual({ ok: false, reason: 'malformed' }); expect( verify('slack', { ...slackHeaders(RAW_BODY, timestamp), 'x-slack-signature': hmacHex(RAW_BODY), }) ).toEqual({ ok: false, reason: 'malformed' }); }); it('should reject a timestamp older than the tolerance window', () => { const stale = (Math.floor(Date.now() / 1000) - 301).toString(); expect(verify('slack', slackHeaders(RAW_BODY, stale))).toEqual({ ok: false, reason: 'stale', }); }); it('should reject a timestamp too far in the future', () => { const future = (Math.floor(Date.now() / 1000) + 301).toString(); expect(verify('slack', slackHeaders(RAW_BODY, future))).toEqual({ ok: false, reason: 'stale', }); }); it('should reject a request that goes stale while the clock advances', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z')); const headers = slackHeaders(RAW_BODY, nowSeconds()); expect(verify('slack', headers)).toEqual({ ok: true }); vi.advanceTimersByTime(301_000); expect(verify('slack', headers)).toEqual({ ok: false, reason: 'stale' }); }); it('should honor an explicit tolerance', () => { const old = (Math.floor(Date.now() / 1000) - 600).toString(); expect(verify('slack', slackHeaders(RAW_BODY, old), RAW_BODY, 900)).toEqual({ ok: true }); expect(verify('slack', slackHeaders(RAW_BODY, old), RAW_BODY, 60)).toEqual({ ok: false, reason: 'stale', }); }); it('should reject a non-numeric timestamp as stale', () => { expect( verify('slack', { 'x-slack-request-timestamp': 'yesterday', 'x-slack-signature': `v0=${hmacHex('v0:yesterday:x')}`, }) ).toEqual({ ok: false, reason: 'stale' }); }); }); describe('verifySignature: github-sha256', () => { it('should accept a correct sha256-prefixed signature', () => { expect(verify('github', { 'x-hub-signature-256': `sha256=${hmacHex(RAW_BODY)}` })).toEqual({ ok: true, }); }); it('should reject a signature computed over a different body', () => { expect(verify('github', { 'x-hub-signature-256': `sha256=${hmacHex(OTHER_BODY)}` })).toEqual({ ok: false, reason: 'mismatch', }); }); it('should report a missing header', () => { expect(verify('github', {})).toEqual({ ok: false, reason: 'missing' }); }); it('should report a malformed header', () => { expect(verify('github', { 'x-hub-signature-256': hmacHex(RAW_BODY) })).toEqual({ ok: false, reason: 'malformed', }); expect(verify('github', { 'x-hub-signature-256': `sha1=${hmacHex(RAW_BODY)}` })).toEqual({ ok: false, reason: 'malformed', }); }); it('should reject a truncated but correctly prefixed signature', () => { expect( verify('github', { 'x-hub-signature-256': `sha256=${hmacHex(RAW_BODY).slice(0, 32)}` }) ).toEqual({ ok: false, reason: 'mismatch' }); }); }); describe('verifySignature: none', () => { it('should accept rest and jira without any header', () => { expect(verify('rest', {})).toEqual({ ok: true }); expect(verify('jira', {})).toEqual({ ok: true }); }); it('should accept the none scheme even when a garbage header is present', () => { expect(verify('rest', { 'linear-signature': 'nonsense' })).toEqual({ ok: true }); }); it('should cover every configured provider with a known scheme', () => { const schemes = Object.values(providers).map((config) => config.signatureScheme); expect(new Set(schemes)).toEqual( new Set(['none', 'linear-sha256', 'slack-v0', 'github-sha256']) ); }); }); describe('verifySignature: hostile input', () => { const garbage: readonly string[] = [ '', ' ', 'v0=', 'sha256=', ':::', 'v0=zzzz', '%%%%', '0'.repeat(10_000), '\u0000\u0000', 'null', ]; it('should never throw for any provider and any garbage header value', () => { const slugs = Object.keys(providers) as ProviderSlug[]; for (const slug of slugs) { for (const value of garbage) { const headers: IncomingHttpHeaders = { 'linear-signature': value, 'x-hub-signature-256': value, 'x-slack-signature': value, 'x-slack-request-timestamp': value, }; expect(() => verify(slug, headers)).not.toThrow(); expect(typeof verify(slug, headers).ok).toBe('boolean'); } } }); it('should never throw for an empty body or empty header set', () => { const slugs = Object.keys(providers) as ProviderSlug[]; for (const slug of slugs) { expect(() => verify(slug, {}, Buffer.alloc(0))).not.toThrow(); } }); it('should tolerate array-valued and duplicated headers', () => { expect(() => verify('slack', { 'x-slack-signature': ['v0=abc', 'v0=def'], 'x-slack-request-timestamp': [nowSeconds(), 'garbage'], }) ).not.toThrow(); }); });