fix(security): vault integration secrets instead of plaintext config (R1)

PayPal client_secret (and any secret-named integration field) was stored
plaintext in integrations.config, unlike Stripe/door which use the
AES-256-GCM vault. A DB read or backup leaked it in cleartext.

- lib/secrets/vault.ts: canonical generic storeSecret/readSecret +
  isSecretConfigField (the proven Stripe vault logic, generalized).
- saveIntegration: secret-named fields go to the vault, only a *_ref is
  kept in config; blank submit preserves the ref (write-only like
  Stripe); legacy plaintext auto-migrates into the vault on next save.
- paypal/sync reads client_secret from the vault, transitional fallback
  to legacy plaintext until the next save.
- Docs updated (API_REFERENCE, ARCHITECTURE 10.3). 502 tests.
This commit is contained in:
Moheeb Zara
2026-05-18 13:08:17 -07:00
parent 7844c262f8
commit 504586fdcd
6 changed files with 176 additions and 8 deletions
+1
View File
@@ -682,6 +682,7 @@ async function saveIntegration(
**Behavior**:
- Upserts on `(space_id, platform)`
- Stores config with `_set` indicators for each field
- Secret-named fields (`client_secret`, `*_secret`, `api_key`, `secret_key`) are NEVER stored in `integrations.config` in plaintext: they go to the AES-256-GCM secrets vault and only a `<field>_ref` id is kept (same vault Stripe/door use). A blank submit preserves the existing ref (write-only, like Stripe); any legacy plaintext secret already on file is auto-migrated into the vault on the next save. The PayPal sync route reads `client_secret` from the vault (with a transitional fallback to legacy plaintext until the next save).
---
+4 -4
View File
@@ -396,10 +396,10 @@ The admin client (`SUPABASE_SERVICE_ROLE_KEY`) is used ONLY for:
### 10.3 Secrets Storage
Integration credentials stored in `integrations.config` (JSONB):
- Values stored with `_set` indicators
- UI shows masked values
- Consider encryption at rest for production
Integration credentials:
- Secret-named fields (`client_secret`, `*_secret`, `api_key`, `secret_key`) are encrypted at rest in the AES-256-GCM secrets vault (`lib/secrets/vault.ts`, same vault Stripe + door use); only a `<field>_ref` id is kept in `integrations.config`. Server-side decrypt only, never returned to the client.
- Non-secret fields stored in `integrations.config` (JSONB) with `_set` indicators; UI shows connected state via the flags, never the value.
- Legacy plaintext secrets (pre-vault) are auto-migrated into the vault on the next `saveIntegration`.
---