GoFundNode — Security Overview (GA readiness)
Purpose. The practical security/readiness reference a real customer (and our own on-call) needs before GA: which controls we actually have, where they live in code, what they guarantee, and — explicitly — which are gated / not yet implemented (so nobody assumes a control that isn't there). This is a documentation lane (L-SEC): it describes and, where cheap, points at the tests that pin the controls. It changes no
server/**behavior.Status: GA-readiness security baseline. Last updated: 2026-06-08. Controlling sources (read these, don't infer):
CLAUDE.md(§2 laws, §8 settlement, §9 gates),docs/V1-API.md(§1 auth, §3 webhooks, §7 idempotency),docs/ADR/0046-fiat-resource-contribution-model.md(operator-payment sovereignty),docs/architecture/DECISIONS_LOCKED.md(D-LOCK-012/013),docs/BLOCKED_WORK_REGISTER.md(BW-23 / BW-24 and all gated items).How to read the status tags: ✅ implemented + (where noted) test-pinned · ⚠️ implemented with a documented limitation · ⛔ NOT implemented / gated (cited to its blocking item). Do not treat a ⛔ as present.
0. TL;DR — what is and isn't protected at GA-candidate
| Area | Status | One-line |
|---|---|---|
| Inbound tenant request authn (HMAC) | ✅ | HMAC-SHA256 over ${ts}.${rawBody}, ≤300s replay window, constant-time compare, fail-closed 401. |
| Inbound replay / idempotency | ✅ | Idempotency key mandatory + tenant-unique; replay returns 200, conflict 409; race-safe via unique constraint. |
| Outbound webhook signing | ✅ | Same HMAC scheme, separate per-tenant webhook_secret; raw-body signed; X-GFN-Delivery stable per-row dedupe key. |
| Inbound vs webhook secret separation | ✅ | Two distinct DB columns (hmac_secret ≠ webhook_secret); compromise of one does not forge the other. |
| Tenant isolation / opacity | ✅ (gates) | Two AST arch-gates forbid any tenant route/webhook from reading operator-network/comp state. |
| Operator-leakage prevention | ✅ (gates) | Operator identity, payout, binding, lease, node-topology never reach a tenant; only op-anon-* is permitted. |
| Secrets rotation | 📄 runbook | See SECRETS_ROTATION.md. 3 transcript-exposed creds must be rotated pre-GA (founder/ops action). |
| Applicant-PII envelope | ⛔ BW-24 | applicantProfile flows plaintext submission→operator. Three-layer envelope NOT implemented. Gates real PII. |
| Positive-evidence settlement | ⛔ BW-23 | /result trusts the operator-reported outcome; no independent adjudication. Gates production-grade success settlement. |
| Dependency hygiene | ✅ | No exploitable request-path vuln in the GA-candidate config; safe in-major updates already absorbed; see §9 + the findings note. |
| Incident response | 📄 checklist | See INCIDENT_RESPONSE_CHECKLIST.md. |
The two hard pre-GA gaps are BW-23 (positive-evidence) and BW-24 (PII envelope). Both are tracked, both are out of this lane's authority to build (verifier/scheduler substrate + KMS/schema), and both gate real external applicant exposure — not the private-beta canary, which uses only synthetic data and SMOKE_SKIP_SETTLEMENT.
1. HMAC signing + replay/idempotency stance (inbound)
Where: server/_core/hmac.ts (primitive), server/api/v1/middleware/hmac-verify.ts (inbound gate), docs/V1-API.md §1.
1.1 Signing scheme (✅)
- Canonical signed string is
${timestamp}.${rawBody}— the raw request body bytes, not a re-serialized parse.hmac-verify.tsreadsreq.rawBody(populated by theraw-bodymiddleware) and verifies against it, so a body that round-trips differently through JSON cannot bypass the signature. X-GFN-Signature = hex(hmac_sha256(tenant.hmac_secret, "${ts}.${rawBody}")),X-GFN-Timestamp = unix seconds. Both headers are required; absence →401 hmac_verify_failedreasonmissing_headers.- Signature comparison is constant-time (
crypto.timingSafeEqualover the hex buffers, with a length pre-check) —hmac.ts:constEq. No early-exit string compare.
1.2 Replay window (✅)
REPLAY_WINDOW_SECONDS = 300(≤5 min), enforced symmetrically:- drift
> 300s→stale_timestamp - drift
< -300s→future_timestamp(rejects forward-dated clock-skew / pre-signing)
- drift
- A request older or newer than the window is rejected before the signature is even trusted for action, bounding the replay surface to 10 minutes total.
1.3 Idempotency (✅)
idempotencyKeyis mandatory, 1–128 chars, tenant-unique (docs/V1-API.md§2.1; enforced inserver/api/v1/routes/submissions.ts).- Replay semantics (
submissions.ts):- Same
(tenantId, idempotencyKey)+ same logical body →200idempotent replay, returns the existing submission, no new row / no re-charge. - Same key + different body → deterministic
409 idempotency_key_conflict(returns the originalsubmissionId; does not leak other state). - Concurrent duplicate POSTs are race-safe: the
gfn_submissions_tenant_idempotency_uniqueconstraint lets exactly one insert win; the loser re-reads and replays the winner's row (200 or 409 as appropriate).
- Same
- A scoped, retention-aware idempotency store (
server/api/v1/idempotency-store.ts, G5 / ADR-028) backs replay even after the primary row ages out.
Replay vs idempotency — the stance. The HMAC
±300swindow bounds signature replay (a captured signed request cannot be re-fired indefinitely). The idempotency key bounds application replay (even a perfectly-signed retry within the window does not double-submit). The two layers are independent and both required.
Tests that pin this: server/api/v1/__tests__/submissions-idempotency-race.test.ts (9), …/submissions-error-taxonomy.test.ts (6). Both run in pnpm test:beta.
2. Webhook verification + replay protection (outbound)
Where: server/api/v1/webhooks/delivery.ts (worker), …/webhooks/signer.ts (signer), docs/V1-API.md §3.
- Signed identically to inbound but with the tenant's
webhook_secret(see §3):X-GFN-Timestamp+X-GFN-Signatureover the raw JSON body string the worker sends (delivery.tssignsbodyStr = JSON.stringify(envelope)— the exact bytes on the wire). Receivers verify with the same${ts}.${rawBody}canonicalization. - Per-delivery dedupe key (✅):
X-GFN-Deliveryis a stable, per-row UUID derived deterministically from the delivery row id (deliveryUuid(rowId), a pinned v5-shaped UUID). It is identical across every retry of the same delivery, so a receiver that dedupes on it before applying side effects will not double-apply a 5xx-then-retry. Receivers MUST dedupe onX-GFN-Delivery(docs/V1-API.md§3.3 + §7) — this is the at-least-once → effectively-once contract. - Event identity:
eventId = evt_<rowId>is the logical event id (stable per event), distinct from the per-attempt delivery dedupe key.occurredAtis the enqueue time (created_at), not the per-attempt send time, so it too is stable across retries. - Retry classification (
delivery.ts): 2xx → delivered; 4xx (except 408/429) → permanent fail, no retry; 5xx/408/429/timeout → exponential backoff (min(2^attempts, 3600) + jitter(0..30)s) untilGFN_WEBHOOK_MAX_RETRIES. - No-secret fail-closed: if a tenant has a webhook URL but no
webhook_secret, the worker does not send unsigned — it marks the delivery permanently failed (webhook_no_secret_giving_up). A misconfigured tenant never receives an unsigned payload.
Test that pins this: server/api/v1/__tests__/webhook-delivery.test.ts (13; covers envelope shape, X-GFN-Delivery stability, retry classification, backoff). Runs in pnpm test:beta.
3. Inbound vs webhook secret separation (✅)
Where: server/billing/schema.ts (tenants table).
- Two physically separate columns:
hmac_secret(varchar(128), NOT NULL) — signs/verifies inbound tenant requests.webhook_secret(varchar(128), nullable) — signs outbound webhooks; the schema comment states it explicitly: "separate from inbound HMAC."
- Why it matters: the inbound secret is held by the tenant's caller (their backend signing requests to us); the webhook secret is held by the tenant's receiver (their endpoint verifying our callbacks). Splitting them means a leak of one does not let an attacker forge the other direction, and either can be rotated independently (see
SECRETS_ROTATION.md). - The signer wrapper (
webhooks/signer.ts) reaches the same_core/hmac.signprimitive but is always passed the webhook secret by the delivery worker (delivery.tshydratestenants.webhookSecret, neverhmacSecret).
4. Tenant isolation + opacity guarantees (✅, gate-enforced)
Architecture law: CLAUDE.md §2 (Tenant-architectural baseline, D-LOCK-013) — a tenant is a customer, never part of the architecture; no shared mutable operator-state is tenant-readable. ADR-046 operator-payment sovereignty: "customers have no access to operator payout state."
Two complementary AST-level arch-gates (ts-morph, not text-grep, so prose/comments cannot trip or disable them) enforce the boundary in pnpm arch:gates:
4.1 no-operator-comp-tenant-read (read boundary)
scripts/architecture-gates/no-operator-comp-tenant-read.ts. Scans the v1 route layer. Fails if any route not on the explicit operator-authed allowlist either (a) imports an operator-network drizzle symbol (operatorRewards, gfnOperatorPayouts, operators, taskBundles, userNodeAffinity, …) or (b) names an operator-network table in a SQL literal. The operator-authed allowlist is explicit and reviewable — a new operator route is a deliberate edit there.
- Known limitation (by design): route-layer only; does not trace the call graph. A tenant route reaching operator-comp transitively through a helper would not be caught. No such path exists today; the durable fix is the physical carve-out (issue #27 / BW-3). Helper modules imported by tenant routes must be reviewed against this boundary.
4.2 no-operator-comp-webhook-egress (egress boundary)
scripts/architecture-gates/no-operator-comp-webhook-egress.ts. Broader scope (server/**) — reaches every enqueueWebhook(...) call site, including the closer / billing workers the read-gate never sees. For each tenant-facing enqueueWebhook payload object-literal it fails if any forbidden field name appears at any nesting depth, as a property key or as a <obj>.<field> value read (catches the key-rename smuggle { x: settlement.operatorPayoutCredits } and the raw-value smuggle { ref: submission.assignedOperatorId }). Forbidden = operator-compensation (operatorPayoutCredits, platformShareCredits, rebateAccruedCredits, operatorRewardId) and operator-binding/routing/settlement identifiers (operatorId/assignedOperatorId/nodeId/leaseId/bindingEpoch/operatorIpHash/ payoutLegId/… in both camel and snake case).
- Why a sibling gate, not an extension: different scope (route-layer vs all of
server/) and different signal (imports/SQL vs payload field names). This gate exists because PR #82 leakedoperatorPayoutCreditsinto the tenantsubmission.completedwebhook fromcloser/worker.ts, which the read-gate structurally could not catch. - Known limitation (by design): the payload must be an inline object literal (how every current call site is written). A payload passed by reference is not traced into — same call-graph boundary as §4.1. A field smuggled under a non-forbidden key whose value is not a forbidden
<obj>.<field>read is out of literal scope.
Net opacity guarantee. A tenant sees only its own submissions, credits, and outcome. It never sees operator identity, operator payout, GoFundNode's platform margin, node→operator topology, lease/binding internals, or which operator was tried. The only permitted operator handle in a tenant surface is the anonymized
op-anon-*token onGET /:id(never via a webhook, never the raw id).
Runtime regression also pins it: server/api/v1/__tests__/tenant-webhook-no-operator-comp.test.ts (the runtime contract mirroring the egress gate's forbidden-field set).
5. Operator-leakage prevention (✅)
Covered structurally by §4.2 (the egress gate) and operator-route ownership (§6 below). The complete documented tenant-facing submission.completed set is {submissionId, status, outcome, failureReason, ledger, screenshotUrl, actualCredits, refundedCredits} (docs/V1-API.md §3.2) — none of the operator/binding/payout identifiers legitimately appear in it, which is what makes forbidding their raw keys/values unambiguous. The presenter invariant (O5 §1.2): the only operator projection a tenant may receive is anonOperatorRef = op-anon-*, under a distinctly-named key, on GET /:id.
6. Operator-route auth + ownership (✅ auth; ⚠️ one ownership gap, gated)
Where: docs/security/W3-4-operator-route-auth-findings.md (full route sweep); server/api/v1/routes/operator-result.ts.
- Every operator-authenticated route requires
X-OPERATOR-API-KEY; missing/unknown key →401, no DB write / meter / settlement / webhook. Cross-operator action on an already-bound submission →404 submission_not_assigned_to_operator, no side effects. Pinned byserver/api/v1/__tests__/operator-result-auth.test.ts(5) and…/operator-consent-auth.test.ts(8) — both inpnpm test:beta. - ⚠️ Ownership gap (documented, not an auth bypass): on the legacy / default path (
GFN_OPERATOR_BINDING_ENABLED=false, i.e. today),operator-result.tsonly rejects a cross-operator result on an already-bound submission; an unbound submission (assignedOperatorId = NULL) is self-asserted to the calling operator and settled. Any authenticated operator could thus claim+settle a dispatched-but-unbound submission. The fix is to persist the operator binding at lease issuance (scheduler/ dispatch authority), then requireassignedOperatorId === operator.id. The flag-ON branch already implements that fail-closed verify (rejects unbound/mismatch before any write), but is boot-gated onGFN_SCHEDULER_ENABLED(BW-11) so it is unreachable in prod. This is the operator-binding work (D-BIND-1 / PRs 4-8), out of this lane.
7. Applicant-PII envelope — ⛔ CURRENT LIMITATION (BW-24)
Status: NOT implemented. This is a hard pre-GA gap for real applicant PII.
- What flows today (plaintext, end-to-end):
payload.applicantProfilein the tenantPOST /v1/submissionsrequest → stored ingfn_submissions.payload(jsonb) → copied tooperator_tasks.available_fields(text) → returned in the operator claim response. No encryption envelope at any hop. (Confirmed againstserver/dispatch/bridge.ts,server/billing/job-types.ts,packages/vendor-client/src/types.ts,docs/V1-API.md.) - What CLAUDE.md §2 (Three-layer key envelope) requires and we do NOT have: tenant KMS master key → per-task data key bound to manifest hash + lease id → sealed node projection. None of that, nor the schema to carry sealed payloads, exists.
- Why it isn't open-hole-at-beta but IS a GA gate: the private-beta canary uses only the synthetic
canary@apply.funprofile (no real PII). The envelope gates any real applicant PII and coordinates with the counsel-gated substrate (BW-12) and the DPA (BW-18). - The path: BW-24 — an envelope design + KMS/per-task-key decision + sealed-payload schema, then implementation before any real applicant PII is carried. Owner: vault/crypto + schema. Out of L-SEC authority (KMS + schema + migrations).
Operational rule until BW-24 lands: do not route real applicant PII through
POST /v1/submissions. The plaintext jsonb path is acceptable only for synthetic beta data.
8. Positive-evidence settlement adjudication — ⛔ CURRENT LIMITATION (BW-23)
Status: NOT implemented. The /result boundary trusts the operator.
- What happens today:
POST /v1/internal/submissions/:id/resulttakes the operator-reportedoutcomeand maps it directly to terminal status (success → submitted,failure → failed, elsecancelled) and settles. It re-meters the ledger server-side (clamps credits via the meter), but it performs no independent confirmation of the claimed outcome — there is no verification lease, no redundant consensus, no manifestpositive_signal_kindsenforcement. (Verified inserver/api/v1/routes/operator-result.ts~:185-200.) The Phase-A canary settledoutcome:"success"purely on operator self-report. - What CLAUDE.md §8 (Positive evidence settlement / Central evidence adjudication) requires: success settlement needs manifest-defined positive evidence; Engine-C settlement needs independent confirmation or redundant consensus, because the node that executed cannot be the sole authority for success. Independent confirmation is a separate residential verification lease, adjudicated centrally — never a datacenter fetch of the protected target.
- Why it isn't a beta-canary blocker but IS a GA gate: Phase-A is explicitly a plumbing+billing proof, not a verified-application proof. BW-23 gates production-grade success settlement (i.e. paying operators / charging customers on trusted-correct outcomes, not self-reported ones).
- The path: BW-23 — a verification-lease / adjudication design + ADR + manifest
positive_signal_kindsenforcement wired into the settlement path; coordinates with the (gated) scheduler/verifier substrate. Owner: settlement/verifier. Out of L-SEC authority.
Trust note for customers: until BW-23, a
submission.completed{outcome:"success"}reflects the operator's report that the application was submitted, not an independently verified confirmation. Do not represent it as adjudicated success.
9. Dependency-hygiene posture (✅ — detail in the findings note)
Full audit: docs/findings/dependency-security-hygiene.md (W4-8) and the L-SEC update appended there. Summary for GA:
- No confirmed exploitable request-path vulnerability in the GA-candidate config. The one genuine request-path advisory (drizzle-orm SQLi via dynamic identifiers) is unreachable — the codebase uses no
sql.identifier/sql.raw/.dynamic(). The unpatchable HIGH (bigint-buffer) and the moderateuuidarrive only via the@solana/*cluster, whichserver/_core/env.tsrefuses to boot in production (ADR-046). - All safe in-major updates are already absorbed by the existing caret ranges (lockfile already resolves
postgres 3.4.9,pino 9.14.0,zod 3.25.76,undici 6.26.0,pino-pretty 11.3.0,dotenv 16.6.1). There is no safe in-major bump left to apply topackage.json. - Supply-chain controls are strong (positive finding):
.npmrc+package.json#pnpmenforceminimum-release-age=30(blocks freshly-published/ compromised versions),ignore-scripts=true(cuts install-time RCE), and a documented exact pin of@nodable/entities@2.1.0(D-LOCK-002). - Config is fail-loud + safe-by-default: zod boot-validation; captcha-solver, Solana, and non-
nonepayout rails throw in production;OPERATOR_AUTH_TOKENunset ⇒ operator endpoints reject; credit-share-sum=1.0 enforced at boot; signing keys annotated "NEVER log." - Recommended (not applied) bumps — see the findings note §L-SEC for the rationale and the empirical drizzle test result.
10. Residual GA security risks (the honest list)
| # | Risk | Status / blocker | Owner |
|---|---|---|---|
| R1 | Applicant PII is plaintext end-to-end (no three-layer envelope). | ⛔ BW-24 — gates real PII. | vault/crypto + schema |
| R2 | Success settlement trusts operator self-report (no positive-evidence adjudication). | ⛔ BW-23 — gates production-grade settlement. | settlement/verifier |
| R3 | Unbound-submission ownership self-assert on the legacy /result path. | ⚠️ Closed by binding-at-issuance (D-BIND-1); fix is flag-gated + boot-gated (BW-11). | scheduler-core |
| R4 | 3 transcript-exposed credentials (Neon pw, manus SSH key, GitHub token) need rotation. | 📄 SECRETS_ROTATION.md — founder/ops action, pre-GA. | founder/ops |
| R5 | fastify v4 is EOL (2025-06-30); future advisories unpatchable on the v4 line. | RECOMMENDATION — v4→v5 serialized migration, post-beta. | deps owner / founder |
| R6 | drizzle-orm is 9 minor versions stale (0.36.4) and carries the (unreachable) SQLi advisory. | RECOMMENDATION — 0.45.2 bump validated green in-lane but deferred (kit-pairing/migration path). | deps owner / founder |
| R7 | @solana/* non-GA cluster sits in prod dependencies (drags the unpatchable bigint-buffer). | RECOMMENDATION — gate/remove post-beta; ADR-046/047 founder call. | founder/architecture |
| R8 | Arch-gates do not trace the call graph (route imports/SQL + inline webhook payloads only). | ⚠️ Documented gate limitation; durable fix = physical carve-out (issue #27 / BW-3). | architecture |
| R9 | Counsel-gated GA legal substrate (sanctions live, TOS/DPA, 1099, treasury). | ⛔ BW-13…BW-19. | founder/counsel |
Bottom line. The request-path security controls (HMAC authn, replay/idempotency, webhook signing + dedupe, secret separation, tenant opacity, operator-route auth) are implemented and test-pinned. The two controls a security-conscious customer would most expect for real applicant traffic — PII-at-rest/in-transit encryption (R1/BW-24) and independently-verified success settlement (R2/BW-23) — are not yet built and are the headline GA gates. Everything else above is a recommendation or a counsel/founder decision, not an open hole in the beta configuration.