Skip to content

Seal user secrets under each user's passphrase (no server master key) - #399

Merged
sh1pt-actions-fleet[bot] merged 1 commit into
masterfrom
feat/sealed-social-secrets
Oct 28, 2026
Merged

sh1pt-actions-fleet[bot] merged 1 commit into
masterfrom
feat/sealed-social-secrets

Conversation

@sh1pt-actions-fleet

Copy link
Copy Markdown
Contributor

Replaces the single SOCIAL_VAULT_KEY that opened every user's social tokens, app passwords, bot tokens, cookies, outreach SMTP passwords and seed logins.

How it works

  • /dashboard/keys: the user sets a key passphrase in the browser (PBKDF2-SHA256 600k + HKDF, random salt, AES-256-GCM, recovery key shown once). The server stores only the seal (wrapped DEK, key check, public key, sealed private key).
  • Secrets are sealed to the owner's public key (ECDH P-256 + HKDF + AES-GCM, AAD = owner + field). Writes work while locked; reads need the owner's unlock.
  • Unlock sends the DEK, never the passphrase. Next and the worker hold the private key in memory only (TTL default 8h, max 7d; Lock; restart = locked).
  • When an owner is locked, their scheduled work waits: feed autopost, promote, browser posts, cookie refresh, reply scans, seed logins. It shows a locked: status and sends one email per lock episode. Autoblog is unaffected.
  • Operator keys COINPAY_API_KEY (payouts) and X402_PRIVATE_KEY go through lib/sealed/operator.ts. An admin seals the current env values with POST /api/keys/operator (a button on the Keys page). After that, env is not read for them. While locked, payouts stay queued and crawls don't pay; one ALERT crawlproof: operator keys locked, unlock to resume log line is written.
  • Migration: legacy rows are re-sealed in the owner's own setup/unlock request, and plaintext SMTP columns are nulled. LEGACY_KEY_MIGRATION=off is the cutoff. select * from sealed_keys_status shows what is left.
  • CLI: crawlproof keys status|unlock|lock.

Not changed: sp_mastodon_app.enc_client_secret (our own Mastodon app registrations, an operator app credential like META_APP_SECRET) stays on the legacy key. Checkout creation still uses env COINPAY_API_KEY (same key as payouts). Splitting it needs a checkout-only key from CoinPay.

Tests: tests/sealed-keys.test.ts covers seal/unseal, AAD, rewrap, TTL/lock/restart, no env fallback, and migration (including the off switch). Full suite 2915 passed, typecheck clean, next build ok.

🤖 Generated with Claude Code

Comment thread lib/sealed/migrate.ts
smtp_user: "enc_smtp_user",
smtp_pass: "enc_smtp_pass",
api_key: "enc_api_key",
auth_token: "enc_auth_token",
Comment thread tests/sealed-keys.test.ts Fixed
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

ThreatCrush Security Scan

47 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 29 | LOW: 16

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:48
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:71
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:99
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:63
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/pit-footer.tsx:17
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:16
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM js-dynamic-code-execution lib/crawl-limits.ts:67
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:100
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sh-predictable-temp-path ops/selfhost/server/setup-supabase.sh:218
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:14
MEDIUM js-dynamic-code-execution scripts/test-crawl-limits.mjs:24
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:156
LOW secret-generic-credential lib/sealed/migrate.ts:38
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:20
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:24
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:25
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:26
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:31
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:35
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

Comment thread lib/paid-fetch.ts Dismissed
Comment thread lib/paid-fetch.ts Dismissed
Comment thread worker/index.ts
const r = await unlockWithDataKey(payload.owner_id, payload.dek ?? "", payload.ttl_seconds);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, expires_at: r.expiresAt }));
console.log(`[worker] sealed keys unlocked for ${payload.owner_id} until ${r.expiresAt}`);
…nlocked

One SOCIAL_VAULT_KEY in app.env used to open every user's social OAuth
tokens, app passwords, Discord/Telegram bot tokens, cookies, outreach SMTP
passwords and seed logins. Root on the box = all of them.

Now (seal v2, lib/sealed):
- Each user sets a key passphrase at /dashboard/keys (browser WebCrypto:
  PBKDF2-SHA256 600k + HKDF -> KEK wraps a random DEK; recovery key shown
  once). The DEK seals an ECDH P-256 private key; the public key is stored.
- Every secret is sealed to the owner's PUBLIC key (ECDH + HKDF + AES-256-GCM,
  AAD = owner + field), so OAuth callbacks and token refreshes can write while
  the owner is locked, but nothing can read without their unlock.
- Unlock sends the DEK (never the passphrase); the Next server and the worker
  each keep the opened private key in process memory only, TTL default 8h,
  max 7d, Lock button/endpoint/CLI. Restart = locked.
- Locked owners' scheduled work waits (feed autopost, promote campaigns,
  browser posts, cookie refresh, reply scans) with a visible "locked:" status
  and one email nudge per lock episode. Autoblog is untouched.
- Org secrets are sealed to organizations.owner_id.
- Operator keys: COINPAY_API_KEY (payouts) and X402_PRIVATE_KEY are read via
  lib/sealed/operator; an admin seals the env values under their own key, after
  which env is never read for them; locked = payouts stay queued / crawls do
  not pay, plus one ALERT log line (and smshub call if OPERATOR_ALERT_PHONE).
- Migration: legacy v1 rows are re-sealed in the owner's own setup/unlock
  request; LEGACY_KEY_MIGRATION=off is the cutoff switch; view
  public.sealed_keys_status counts what is left.
- API: GET /api/keys, POST /api/keys/{setup,unlock,lock,passphrase,operator};
  CLI: crawlproof keys status|unlock|lock.
- UI copy that said "the key lives in the application environment" fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sh1pt-actions-fleet
sh1pt-actions-fleet Bot force-pushed the feat/sealed-social-secrets branch from e56fc75 to 2451e75 Compare October 28, 2026 20:21
@sh1pt-actions-fleet
sh1pt-actions-fleet Bot merged commit 4138a45 into master Oct 28, 2026
8 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants