82 lines
2.9 KiB
TypeScript
82 lines
2.9 KiB
TypeScript
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
import type { IncomingHttpHeaders } from 'node:http';
|
|
import { getProvider } from '../config/providers.js';
|
|
import type { ProviderSlug, SignatureResult } from '../types/integration.js';
|
|
|
|
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
|
|
const OK: SignatureResult = { ok: true };
|
|
|
|
const fail = (reason: Exclude<SignatureResult, { ok: true }>['reason']): SignatureResult => ({
|
|
ok: false,
|
|
reason,
|
|
});
|
|
|
|
const header = (headers: IncomingHttpHeaders, name: string): string | undefined => {
|
|
const value = headers[name.toLowerCase()];
|
|
if (Array.isArray(value)) return value[0];
|
|
return value;
|
|
};
|
|
|
|
const hmacHex = (secret: string, payload: string | Buffer): string =>
|
|
createHmac('sha256', secret).update(payload).digest('hex');
|
|
|
|
const constantTimeEquals = (a: string, b: string): boolean => {
|
|
const bufA = Buffer.from(a, 'utf8');
|
|
const bufB = Buffer.from(b, 'utf8');
|
|
if (bufA.length !== bufB.length) return false;
|
|
return timingSafeEqual(bufA, bufB);
|
|
};
|
|
|
|
const withinTolerance = (timestamp: string, toleranceSeconds: number): boolean => {
|
|
const sent = Number(timestamp);
|
|
if (!Number.isFinite(sent)) return false;
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
return Math.abs(nowSeconds - sent) <= toleranceSeconds;
|
|
};
|
|
|
|
/**
|
|
* Never throws. A malformed header from an anonymous caller must be an
|
|
* ordinary negative result, not an exception reachable from the edge.
|
|
*/
|
|
export const verifySignature = (input: {
|
|
provider: ProviderSlug;
|
|
rawBody: Buffer;
|
|
headers: IncomingHttpHeaders;
|
|
secret: string;
|
|
toleranceSeconds?: number;
|
|
}): SignatureResult => {
|
|
const config = getProvider(input.provider);
|
|
const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
|
|
if (config.signatureScheme === 'none') return OK;
|
|
|
|
const presented = header(input.headers, config.signatureHeader);
|
|
if (!presented) return fail('missing');
|
|
|
|
if (config.signatureScheme === 'slack-v0') {
|
|
const timestamp = config.timestampHeader
|
|
? header(input.headers, config.timestampHeader)
|
|
: undefined;
|
|
if (!timestamp) return fail('missing');
|
|
if (!withinTolerance(timestamp, tolerance)) return fail('stale');
|
|
|
|
if (!presented.startsWith('v0=')) return fail('malformed');
|
|
|
|
const base = `v0:${timestamp}:${input.rawBody.toString('utf8')}`;
|
|
const expected = `v0=${hmacHex(input.secret, base)}`;
|
|
return constantTimeEquals(presented, expected) ? OK : fail('mismatch');
|
|
}
|
|
|
|
if (config.signatureScheme === 'github-sha256') {
|
|
if (!presented.startsWith('sha256=')) return fail('malformed');
|
|
const expected = `sha256=${hmacHex(input.secret, input.rawBody)}`;
|
|
return constantTimeEquals(presented, expected) ? OK : fail('mismatch');
|
|
}
|
|
|
|
// linear-sha256: bare lowercase hex digest of the raw body.
|
|
if (!/^[0-9a-f]{64}$/.test(presented)) return fail('malformed');
|
|
const expected = hmacHex(input.secret, input.rawBody);
|
|
return constantTimeEquals(presented, expected) ? OK : fail('mismatch');
|
|
};
|