Skip to content

feat(server): encrypt the secret store with a key kept in the OS keyring - #26648

Draft
juliusmarminge wants to merge 1 commit into
mainfrom
t3code/secret-store-keyring
Draft

juliusmarminge wants to merge 1 commit into
mainfrom
t3code/secret-store-keyring

Conversation

@juliusmarminge

Copy link
Copy Markdown
Member

Secrets in <home>/userdata/secrets are plaintext files (mode 0600). A backup, a copied ~/.t3, or any process running as the user, agents included, can read relay credentials, signing keys and provider API keys.

How

  • ServerSecretStore can encrypt each .bin with AES-256-GCM under one random 32-byte key kept in the OS keyring (@napi-rs/keyring, already bundled). The secret name is bound as associated data.
  • The file layout doesn't change. Atomic create, mtime-based replay-marker pruning, directory and the secret-request sweeps keep working, and no call site changes. Using one keyring item instead of one per secret avoids ~1,400 churning replay markers in the Keychain.
  • Backend choice: --secret-store keyring|file / T3CODE_SECRET_STORE. Desktop defaults to keyring; standalone servers keep files unless they opt in.
  • Persistence: the choice is recorded in a keyring-id file in the secrets directory. CLI commands that share a home (t3 pair, t3 connect, …) pass no backend and follow it.
  • Enabling: the first keyring start stores the key, publishes keyring-id atomically with link, so the first of two concurrent enablers wins, then encrypts existing plaintext files.
  • Disabling: --secret-store file decrypts everything, removes keyring-id and deletes the keyring entry.
  • No keyring available (headless Linux, no Secret Service): a warning is logged and secrets stay as plaintext files.
  • Isolation: the keyring account includes a hash of the secrets directory, so a copied secrets directory can't read the original's key, or delete it on disable.
  • Lost key: a missing or wrong-size key fails startup with SecretStoreKeyringError instead of silently creating new signing keys.

Open questions

  • macOS: a server run with npx t3 owns its Keychain item through the node binary, so a Node upgrade brings back an access prompt. That's why the CLI is opt-in.
  • There's no UI to show which backend is in use, or to reset after a lost key; the error message names the folder to remove.
  • Migration runs once, when encryption is first turned on. A plaintext file that something else writes concurrently in that window can be overwritten with its older value.

Verification

  • ServerSecretStore.test.ts: enable → migrate → follow from another process → disable; fallback when the keyring is unavailable; startup failure when the key is lost. Config tests cover the env var and the desktop default.
  • Real macOS Keychain smoke run in a temp home: files encrypted on disk, read back after reopening, and after disabling, plaintext was restored and the Keychain item and keyring-id were gone. It also caught that AsyncEntry.getSecret() returns a number[] despite its Uint8Array typing; the store now normalizes it.
  • vp run check:changed passes.

Model and harness: Claude Opus 5.5 in Claude Code (T3 Code).

🤖 Generated with Claude Code

Secrets in <home>/userdata/secrets were plaintext files. The store can now
encrypt them with AES-256-GCM under one random key held in the OS keyring
(@napi-rs/keyring). Desktop turns it on by default; standalone servers opt
in with --secret-store keyring or T3CODE_SECRET_STORE. The choice persists
through a keyring-id file, so CLI commands sharing the home follow it, and
--secret-store file decrypts everything and removes the keyring entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 21.6 KiB 21.6 KiB 0 B (0.0%) 24.4 KiB ✅
Codex Live turn messages 2 2 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.1 KiB +6 B (+0.1%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.3 KiB 1.3 KiB +6 B (+0.5%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.9 KiB 21.9 KiB 0 B (0.0%) 24.4 KiB ✅
Claude Live turn messages 2 2 0 (0.0%) 8 ✅

Baseline: 9826dbb · PR result: 5d2c22a · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant