diff --git a/bun.lock b/bun.lock index 1069866..c34c5bf 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "@corbits/granola", + "dependencies": { + "arktype": "^2.1.29", + }, "devDependencies": { "@types/bun": "1.1.14", "@types/node": "22.10.5", @@ -12,12 +15,20 @@ }, }, "packages": { + "@ark/schema": ["@ark/schema@0.56.2", "", { "dependencies": { "@ark/util": "0.56.2" } }, "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg=="], + + "@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], + "@types/bun": ["@types/bun@1.1.14", "", { "dependencies": { "bun-types": "1.1.37" } }, "sha512-opVYiFGtO2af0dnWBdZWlioLBoxSdDO5qokaazLhq8XQtGZbY4pY3/JxY8Zdf/hEwGubbp7ErZXoN1+h2yesxA=="], "@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="], "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "arkregex": ["arkregex@0.0.8", "", { "dependencies": { "@ark/util": "0.56.2" } }, "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ=="], + + "arktype": ["arktype@2.2.3", "", { "dependencies": { "@ark/schema": "0.56.2", "@ark/util": "0.56.2", "arkregex": "0.0.8" } }, "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg=="], + "bun-types": ["bun-types@1.1.37", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-C65lv6eBr3LPJWFZ2gswyrGZ82ljnH8flVE03xeXxKhi2ZGtFiO4isRKTKnitbSqtRAcaqYSR6djt1whI66AbA=="], "typescript": ["typescript@5.7.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg=="], diff --git a/package.json b/package.json index 29120a7..7e5486e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,9 @@ "test": "bun test src", "test:coverage": "bun test --coverage src" }, + "dependencies": { + "arktype": "^2.1.29" + }, "devDependencies": { "@types/bun": "1.1.14", "@types/node": "22.10.5", diff --git a/src/ingress/index.test.ts b/src/ingress/index.test.ts deleted file mode 100644 index 0eb90f5..0000000 --- a/src/ingress/index.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { verifyGranolaSignature } from "./index.js"; - -describe("verifyGranolaSignature", () => { - test("is not implemented yet", () => { - expect(() => verifyGranolaSignature("payload", "sig", "secret")).toThrow("not implemented"); - }); -}); diff --git a/src/ingress/index.ts b/src/ingress/index.ts index fa47930..d645095 100644 --- a/src/ingress/index.ts +++ b/src/ingress/index.ts @@ -3,6 +3,19 @@ // Depends on @corbits/granola (the tools); the tools must never depend on // this. See ARCHITECTURE.md for the dependency direction and how it is // structurally enforced. -export { verifyGranolaSignature } from "./webhook.js"; -export type { GranolaIngressOptions, GranolaWebhookHandler } from "./webhook.js"; +export { + verifyGranolaSignature, + decodeSigningSecret, + signGranolaPayload, + parseGranolaPayload, + MIN_SIGNING_KEY_BYTES, + KNOWN_GRANOLA_EVENT_TYPES, +} from "./webhook.js"; +export type { + GranolaWebhookPayload, + GranolaWebhookHeaders, + VerifyGranolaSignatureArgs, + SignatureVerificationResult, + GranolaEventType, +} from "./webhook.js"; export type { GranolaBindingsPort, GranolaBindingsLoadResult } from "./bindings-port.js"; diff --git a/src/ingress/webhook.test.ts b/src/ingress/webhook.test.ts new file mode 100644 index 0000000..efe3ca9 --- /dev/null +++ b/src/ingress/webhook.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from "bun:test"; + +import { + decodeSigningSecret, + parseGranolaPayload, + signGranolaPayload, + verifyGranolaSignature, +} from "./webhook"; + +const SECRET = "whsec_c3VwZXJzZWNyZXR2YWx1ZQ=="; +const EVENT_ID = "evt_123"; + +function samplePayload(): string { + return JSON.stringify({ + event_id: EVENT_ID, + event_type: "note.generated", + note_id: "note_1", + occurred_at: "2026-07-31T00:00:00Z", + }); +} + +describe("verifyGranolaSignature", () => { + test("accepts a correctly signed payload", () => { + const rawBody = samplePayload(); + const nowSeconds = Math.floor(Date.now() / 1000); + const webhookTimestamp = String(nowSeconds); + const signature = signGranolaPayload({ + secret: SECRET, + webhookId: EVENT_ID, + webhookTimestamp, + rawBody, + }); + + const result = verifyGranolaSignature({ + secret: SECRET, + rawBody, + headers: { + "webhook-id": EVENT_ID, + "webhook-timestamp": webhookTimestamp, + "webhook-signature": signature, + }, + }); + + expect(result).toEqual({ ok: true }); + }); + + test("accepts a bare (non whsec_-prefixed) secret the same way", () => { + const bareSecret = SECRET.slice("whsec_".length); + const rawBody = samplePayload(); + const webhookTimestamp = String(Math.floor(Date.now() / 1000)); + const signature = signGranolaPayload({ + secret: bareSecret, + webhookId: EVENT_ID, + webhookTimestamp, + rawBody, + }); + + const result = verifyGranolaSignature({ + secret: bareSecret, + rawBody, + headers: { + "webhook-id": EVENT_ID, + "webhook-timestamp": webhookTimestamp, + "webhook-signature": signature, + }, + }); + + expect(result).toEqual({ ok: true }); + }); + + test("rejects a tampered body", () => { + const rawBody = samplePayload(); + const webhookTimestamp = String(Math.floor(Date.now() / 1000)); + const signature = signGranolaPayload({ + secret: SECRET, + webhookId: EVENT_ID, + webhookTimestamp, + rawBody, + }); + + const tamperedBody = samplePayload().replace("note_1", "note_evil"); + const result = verifyGranolaSignature({ + secret: SECRET, + rawBody: tamperedBody, + headers: { + "webhook-id": EVENT_ID, + "webhook-timestamp": webhookTimestamp, + "webhook-signature": signature, + }, + }); + + expect(result).toEqual({ ok: false, reason: "bad_signature" }); + }); + + test("rejects a stale timestamp", () => { + const rawBody = samplePayload(); + const staleTimestamp = String(Math.floor(Date.now() / 1000) - 10 * 60); + const signature = signGranolaPayload({ + secret: SECRET, + webhookId: EVENT_ID, + webhookTimestamp: staleTimestamp, + rawBody, + }); + + const result = verifyGranolaSignature({ + secret: SECRET, + rawBody, + headers: { + "webhook-id": EVENT_ID, + "webhook-timestamp": staleTimestamp, + "webhook-signature": signature, + }, + }); + + expect(result).toEqual({ ok: false, reason: "stale_timestamp" }); + }); + + test("rejects a future timestamp beyond tolerance", () => { + const rawBody = samplePayload(); + const futureTimestamp = String(Math.floor(Date.now() / 1000) + 10 * 60); + const signature = signGranolaPayload({ + secret: SECRET, + webhookId: EVENT_ID, + webhookTimestamp: futureTimestamp, + rawBody, + }); + + const result = verifyGranolaSignature({ + secret: SECRET, + rawBody, + headers: { + "webhook-id": EVENT_ID, + "webhook-timestamp": futureTimestamp, + "webhook-signature": signature, + }, + }); + + expect(result).toEqual({ ok: false, reason: "stale_timestamp" }); + }); + + test("rejects missing headers", () => { + const result = verifyGranolaSignature({ + secret: SECRET, + rawBody: samplePayload(), + headers: {}, + }); + + expect(result).toEqual({ ok: false, reason: "missing_headers" }); + }); + + test("reports invalid_timestamp (not missing_headers) for a present but non-numeric webhook-timestamp", () => { + const result = verifyGranolaSignature({ + secret: SECRET, + rawBody: samplePayload(), + headers: { + "webhook-id": EVENT_ID, + "webhook-timestamp": "not-a-number", + "webhook-signature": "v1,doesnotmatter", + }, + }); + + expect(result).toEqual({ ok: false, reason: "invalid_timestamp" }); + }); + + test("fails closed for a secret that base64-decodes to zero bytes", () => { + const rawBody = samplePayload(); + const webhookTimestamp = String(Math.floor(Date.now() / 1000)); + + const result = verifyGranolaSignature({ + secret: "whsec_", + rawBody, + headers: { + "webhook-id": EVENT_ID, + "webhook-timestamp": webhookTimestamp, + // An empty-string-keyed HMAC of the exact signed content, base64-encoded. + // Even a signature an attacker could trivially compute (since the "key" is + // empty and thus known) must still be rejected. + "webhook-signature": "v1,anything", + }, + }); + + expect(result).toEqual({ ok: false, reason: "bad_signature" }); + }); + + test("fails closed for a secret decoding to fewer than 16 bytes", () => { + const rawBody = samplePayload(); + const webhookTimestamp = String(Math.floor(Date.now() / 1000)); + + const result = verifyGranolaSignature({ + secret: "whsec_@@@@", + rawBody, + headers: { + "webhook-id": EVENT_ID, + "webhook-timestamp": webhookTimestamp, + "webhook-signature": "v1,anything", + }, + }); + + expect(result).toEqual({ ok: false, reason: "bad_signature" }); + }); +}); + +describe("decodeSigningSecret", () => { + test("accepts a secret decoding to at least 16 bytes", () => { + const key = decodeSigningSecret(SECRET); + expect(key instanceof Error).toBe(false); + }); + + test("rejects a secret that decodes to zero bytes", () => { + const key = decodeSigningSecret("whsec_"); + expect(key instanceof Error).toBe(true); + }); + + test("rejects a secret shorter than the 16-byte minimum", () => { + const key = decodeSigningSecret("whsec_c2hvcnQ="); + expect(key instanceof Error).toBe(true); + }); + + test("rejects a secret containing characters outside the base64/base64url alphabet", () => { + const key = decodeSigningSecret("whsec_@@@@"); + expect(key instanceof Error).toBe(true); + }); +}); + +describe("parseGranolaPayload", () => { + test("parses a valid note.generated payload", () => { + const parsed = parseGranolaPayload(samplePayload()); + expect(parsed instanceof Error).toBe(false); + if (!(parsed instanceof Error)) { + expect(parsed.event_id).toBe(EVENT_ID); + expect(parsed.event_type).toBe("note.generated"); + } + }); + + test("parses a note.edited payload with changed_fields", () => { + const rawBody = JSON.stringify({ + event_id: EVENT_ID, + event_type: "note.edited", + note_id: "note_1", + occurred_at: "2026-07-31T00:00:00Z", + data: { changed_fields: ["title", "summary"] }, + }); + + const parsed = parseGranolaPayload(rawBody); + expect(parsed instanceof Error).toBe(false); + if (!(parsed instanceof Error)) { + expect(parsed.event_type).toBe("note.edited"); + expect(parsed.data?.changed_fields).toEqual(["title", "summary"]); + } + }); + + test("rejects malformed JSON", () => { + const parsed = parseGranolaPayload("{not json"); + expect(parsed instanceof Error).toBe(true); + }); + + test("rejects a payload missing required fields", () => { + const parsed = parseGranolaPayload(JSON.stringify({ event_id: EVENT_ID })); + expect(parsed instanceof Error).toBe(true); + }); + + test("accepts an unknown event_type — downstream processing is event-type-agnostic", () => { + const rawBody = JSON.stringify({ + event_id: EVENT_ID, + event_type: "note.deleted", + note_id: "note_1", + occurred_at: "2026-07-31T00:00:00Z", + }); + const parsed = parseGranolaPayload(rawBody); + expect(parsed instanceof Error).toBe(false); + if (!(parsed instanceof Error)) { + expect(parsed.event_type).toBe("note.deleted"); + } + }); +}); diff --git a/src/ingress/webhook.ts b/src/ingress/webhook.ts index 3dd0888..8a1f888 100644 --- a/src/ingress/webhook.ts +++ b/src/ingress/webhook.ts @@ -1,20 +1,189 @@ -// Webhook extension: receives Granola webhooks, verifies signatures, and -// dispatches notes to handlers. Depends on the tools (src/tools) for the -// client; the tools must never depend on this. Skeleton only — no dispatch -// logic implemented yet. -import { GranolaClient } from "../tools/index.js"; - -export interface GranolaWebhookHandler { - (event: unknown): Promise; +/** + * Granola webhook verification and payload codec. + * + * Pure, unit-testable functions — no Hono, no network, no side effects. + * `hub/src/mounts/granola.ts` wires these into the actual route. + * + * Standard Webhooks (docs.granola.ai/webhooks): the signed content is + * `{webhook-id}.{webhook-timestamp}.{raw body}`, HMAC-SHA256 keyed with the + * signing secret. The secret is transported base64-encoded, commonly + * prefixed `whsec_`; the key used for HMAC is the base64-DECODED bytes. + * `webhook-signature` can carry multiple space-separated `v1,` + * values (for secret rotation) — any one matching is a valid signature. + */ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { type } from "arktype"; + +const SIGNATURE_TOLERANCE_SECONDS = 5 * 60; +const WHSEC_PREFIX = "whsec_"; +/** HMAC-SHA256 keyed on fewer bytes than this is forgeable; reject at both decode and mount time. */ +export const MIN_SIGNING_KEY_BYTES = 16; +const BASE64_ALPHABET_PATTERN = /^[A-Za-z0-9+/_-]*=*$/; + +/** + * Known Granola note-lifecycle event names. `GranolaWebhookPayload.event_type` + * itself accepts any string — downstream processing (`ingest.ts`) is + * event-type-agnostic and idempotent, so an event type Granola adds in the + * future should still process rather than fail closed-union validation. + */ +export const KNOWN_GRANOLA_EVENT_TYPES = [ + "note.generated", + "note.regenerated", + "note.edited", + "note.access_granted", +] as const; +export type GranolaEventType = (typeof KNOWN_GRANOLA_EVENT_TYPES)[number]; + +export const GranolaWebhookPayload = type({ + event_id: "string", + event_type: "string", + note_id: "string", + occurred_at: "string", + "data?": { + "changed_fields?": "string[]", + }, +}); +export type GranolaWebhookPayload = typeof GranolaWebhookPayload.infer; + +export type GranolaWebhookHeaders = { + "webhook-id"?: string | undefined; + "webhook-timestamp"?: string | undefined; + "webhook-signature"?: string | undefined; +}; + +export type VerifyGranolaSignatureArgs = { + secret: string; + headers: GranolaWebhookHeaders; + rawBody: string; + /** Injectable for deterministic tests; defaults to the real clock. */ + now?: () => number; +}; + +export type SignatureVerificationResult = + | { ok: true } + | { + ok: false; + reason: "missing_headers" | "invalid_timestamp" | "stale_timestamp" | "bad_signature"; + }; + +/** + * Strips an optional `whsec_` prefix and base64-decodes to the raw HMAC key. + * Returns an `Error` (never throws) when the secret contains characters + * outside the base64/base64url alphabet, or when the decoded key is shorter + * than `MIN_SIGNING_KEY_BYTES` — an HMAC keyed on a near-empty string is + * forgeable, so callers must fail closed rather than proceed. + */ +export function decodeSigningSecret(secret: string): Buffer | Error { + const stripped = secret.startsWith(WHSEC_PREFIX) + ? secret.slice(WHSEC_PREFIX.length) + : secret; + + if (!BASE64_ALPHABET_PATTERN.test(stripped)) { + return new Error( + "Granola webhook secret contains characters outside the base64/base64url alphabet", + ); + } + + const key = Buffer.from(stripped, "base64"); + if (key.length < MIN_SIGNING_KEY_BYTES) { + return new Error( + `Granola webhook secret decodes to ${String(key.length)} bytes, fewer than the required ${String(MIN_SIGNING_KEY_BYTES)}`, + ); + } + return key; +} + +function computeSignature(key: Buffer, signedContent: string): string { + return createHmac("sha256", key).update(signedContent).digest("base64"); +} + +function isTimestampFresh(timestampSeconds: number, nowSeconds: number): boolean { + return Math.abs(nowSeconds - timestampSeconds) <= SIGNATURE_TOLERANCE_SECONDS; +} + +/** Constant-time compare of two base64 signature strings of possibly-differing length. */ +function signaturesMatch(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + +/** + * Verifies a Granola Standard-Webhooks signature over the raw request body. + * Must run BEFORE JSON parsing — the signature covers the exact bytes sent. + */ +export function verifyGranolaSignature( + args: VerifyGranolaSignatureArgs, +): SignatureVerificationResult { + const { secret, headers, rawBody } = args; + const now = args.now ?? (() => Date.now()); + + const webhookId = headers["webhook-id"]; + const webhookTimestamp = headers["webhook-timestamp"]; + const webhookSignature = headers["webhook-signature"]; + if (!webhookId || !webhookTimestamp || !webhookSignature) { + return { ok: false, reason: "missing_headers" }; + } + + const timestampSeconds = Number(webhookTimestamp); + if (!Number.isFinite(timestampSeconds)) { + return { ok: false, reason: "invalid_timestamp" }; + } + if (!isTimestampFresh(timestampSeconds, Math.floor(now() / 1000))) { + return { ok: false, reason: "stale_timestamp" }; + } + + const key = decodeSigningSecret(secret); + if (key instanceof Error) { + return { ok: false, reason: "bad_signature" }; + } + const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`; + const expected = computeSignature(key, signedContent); + + const presented = webhookSignature + .split(" ") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + for (const entry of presented) { + const [scheme, value] = entry.split(",", 2); + if (scheme !== "v1" || value === undefined) continue; + if (signaturesMatch(value, expected)) { + return { ok: true }; + } + } + return { ok: false, reason: "bad_signature" }; } -export interface GranolaIngressOptions { - client: GranolaClient; - webhookSecret: string; - onNote: GranolaWebhookHandler; +/** + * Signs a Granola-shaped payload the same way Granola would, for use by the + * local sim harness and by signature-verification tests. + */ +export function signGranolaPayload(args: { + secret: string; + webhookId: string; + webhookTimestamp: string; + rawBody: string; +}): string { + const key = decodeSigningSecret(args.secret); + if (key instanceof Error) { + throw new Error("Cannot sign a test payload with an invalid secret", { cause: key }); + } + const signedContent = `${args.webhookId}.${args.webhookTimestamp}.${args.rawBody}`; + return `v1,${computeSignature(key, signedContent)}`; } -/** Verify a Granola webhook signature. Placeholder — implementation lands separately. */ -export function verifyGranolaSignature(_payload: string, _signature: string, _secret: string): boolean { - throw new Error("not implemented"); +export function parseGranolaPayload(rawBody: string): GranolaWebhookPayload | Error { + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch (cause) { + return new Error("Granola webhook body is not valid JSON", { cause }); + } + const validated = GranolaWebhookPayload(parsed); + if (validated instanceof type.errors) { + return new Error(`Granola webhook payload failed validation: ${validated.summary}`); + } + return validated; }