Infrastructure build to support third-party app integrations
This commit is contained in:
81
backend/lib/signatures.ts
Normal file
81
backend/lib/signatures.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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');
|
||||
};
|
||||
Reference in New Issue
Block a user