Skip to content

Fix/sr 131 134 security hardening - #1412

Merged
GoodnessJohn merged 5 commits into
Haroldwonder:mainfrom
james2177:fix/sr-131-134-security-hardening
Aug 28, 2026
Merged

Fix/sr 131 134 security hardening#1412
GoodnessJohn merged 5 commits into
Haroldwonder:mainfrom
james2177:fix/sr-131-134-security-hardening

Conversation

@james2177

Copy link
Copy Markdown
Contributor

Summary

  1. e96e861 — Mounts privacyRouter at /api/v1/privacy, replaces the in-memory Map stores with real queries against user_consents/notification_preferences/kyc_uploads/user_kyc_status, fixes purge-expired to pass a real pg.Pool, and adds it to the nightly scheduler.
  2. eafbb5e — Removes the hardcoded DEFAULT_KEY_HEX, makes getMasterKey() fail closed outside test, routes ENCRYPTION_KEY through SecretsManager. Includes README-encryption-fail-closed.md since this diff was under 150 lines.
  3. 5128e3b — Implements real HMAC verification (timestamp window + nonce replay protection) for the SEP-12 KYC webhook, rejecting unsigned/mis-signed requests with 401 before any DB write.

Linked Issue

Closes #1285
Closes #1286
Closes #1287
Closes #1288

Type of Change

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • Chore / infra

Checklist

  • Tests added or updated for the change
  • Documentation updated (README, API docs, guides) where relevant
  • Changeset added (sdk/) if this touches a published package
  • Linked issue referenced above
  • This PR introduces a breaking change (if checked, describe migration steps below)

Breaking Change Notes

…vices (SR-131)

scopedApiKeyMiddleware and initApiKeyMiddleware existed fully implemented in
api-key-rate-limit.ts but were never wired into api.ts, leaving
/api/admin/*, /api/aml/*, /api/compliance/* and /api/devices/* protected only
by a flat 20 req/min rate limiter with no identity check — GET
/api/compliance/report served up to 10,000 joined remittance/transaction rows
(sender_address, amounts, currency) to any caller.

- Mount scopedApiKeyMiddleware ahead of every router and call
  initApiKeyMiddleware(pool) at startup; the middleware now rejects
  unauthenticated requests to any scope-mapped route with 401 instead of
  silently letting them through.
- Extend ROUTE_SCOPES to cover /api/aml, /api/compliance and /api/devices,
  which previously had no entry so requiredScopeForRoute silently returned
  null for them.
- Derive audit-log/officer attribution (logAdminAction in api.ts/admin.ts,
  requireOfficer in aml.ts, requiredActor in compliance.ts) from the verified
  API-key owner instead of the unverified x-user-id/x-officer-id headers;
  compliance.ts now rejects with 401 instead of defaulting to 'anonymous'.
- Add backend/AUTH_MATRIX.md and a drift test asserting each of the three
  route groups rejects unauthenticated requests, mirroring api/AUTH_MATRIX.md.
…rasure/purge (SR-131)

privacy.ts implemented consent/erasure/retention entirely against in-process
Map objects that no other request or the database ever saw, and privacyRouter
was never app.use()'d anywhere — every SAR/erasure/rectify/consent endpoint
was unreachable. Separately, POST /purge-expired called
purgeExpiredPersonalData() with no pool argument, which returns an all-zero
report immediately, so the endpoint always reported success while deleting
nothing and was not on any scheduler.

- Mount privacyRouter at /api/v1/privacy in api.ts; every handler enforces
  ownership (the request's own identity or an admin:* scoped key) before
  acting on a given user_id.
- Replace the Map-backed stores with queries against user_consents,
  notification_preferences, user_kyc_status, kyc_uploads and
  privacy_requests, using encryptColumn/decryptObject for email/phone and
  IP columns at rest.
- Pass the real pg.Pool into purgeExpiredPersonalData(pool) from the route
  handler (admin:* only) and add the same purge to the nightly scheduler
  (03:45 UTC) alongside the existing AML RetentionService job, so audit-log
  IP anonymization, transient KYC purge and revoked-consent purge actually
  run automatically.
- Add a regression test asserting the purge report's counts match the
  rowCount each underlying query reports, and that per-category failures
  don't abort the remaining purges.
… (SR-131)

encryption.ts's getMasterKey() silently fell back to
DEFAULT_KEY_HEX — a literal, publicly-visible constant committed to the
repo — whenever ENCRYPTION_KEY was unset, in every environment including
production, with no guard at all. encryptColumn/decryptColumn/encryptObject/
decryptObject back the GDPR privacy routes' "encryption" of full_name,
email, phone_number and address; a misconfigured deploy would produce
ciphertext anyone who had read this source file already had the key for,
while application code and logs kept treating it as protected. This also
bypassed the shared SecretsManager that already centralizes sourcing/
rotation checks for the JWT secret, DB URL and admin key.

- Remove DEFAULT_KEY_HEX. getMasterKey() now throws immediately outside
  NODE_ENV=test when ENCRYPTION_KEY is absent, mirroring env-guard.ts's
  fail-fast treatment of other required secrets.
- Add getEncryptionKey() to the shared SecretsManager (optional secret,
  same rotation/validation posture as FX_API_KEY) and resolve it in
  backend/src/index.ts's loadSecrets(), writing it into process.env so the
  synchronous encryption module keeps working without an invasive
  async refactor of every call site.
- Add a test asserting encryptColumn/decryptColumn throw rather than
  silently encrypting with a known key when ENCRYPTION_KEY is unset outside
  test, and that a configured key still works in production.
…webhook (SR-131)

verifyAnchorSignature existed but was never called anywhere, and even if it
had been, it only checked the caller-controlled anchor_id URL parameter
against an allowlist — no HMAC/signature validation at all. Anyone on the
internet could POST {"user_id": "...", "status": "APPROVED"} to
/webhooks/kyc/:anchor_id and flip a user's KYC status, which feeds
setKycApprovedOnChain (stellar-kyc.ts): an unauthenticated path to on-chain
KYC approval. SR-045 hardened ramp-webhook-handler.ts but never touched this
SEP-12 path.

- Rewrite verifyAnchorSignature to compute HMAC-SHA256 over
  "${timestamp}.${rawBody}" using a per-anchor WEBHOOK_SECRET_{ANCHOR_ID}
  secret (already documented in .env.example but unused), compared with
  crypto.timingSafeEqual, mirroring ramp-provider.ts's verifyWebhook.
- Enforce a 5-minute timestamp window and an in-memory nonce replay cache
  (bounded, self-pruning), consistent with SR-045's treatment of ramp
  provider webhooks.
- handleKycWebhook now verifies the signature before doing anything else and
  rejects with 401 (with a machine-readable reason) on any failure —
  missing/invalid signature, missing/stale timestamp, wrong anchor secret,
  or replayed nonce — never reaching saveUserKycStatus.
- Add a `verify` callback to the global express.json() in api.ts so
  handlers can sign/verify against the exact request bytes rather than a
  re-serialization of the parsed body.
- Rewrite the webhook handler test suite: valid-signature happy path plus
  regression tests asserting unsigned, mis-signed, wrong-anchor, and replayed
  requests are all rejected with 401 and never call saveUserKycStatus.
- Update .env.example: document the WEBHOOK_SECRET_<ANCHOR_ID> requirement
  and remove the now-dead TRUSTED_ANCHOR_IDS-only guidance.
@drips-wave

drips-wave Bot commented Aug 28, 2026

Copy link
Copy Markdown

@james2177 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

@james2177 is attempting to deploy a commit to the Harold's projects Team on Vercel.

A member of the Team first needs to authorize it.

@GoodnessJohn
GoodnessJohn merged commit e31caa2 into Haroldwonder:main Aug 28, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants