Documents the secrets sync cross-node protocol in architecture.md and secrets.md, with a reliability backstop test covering the cross-node sync behavior. Fusion-Task-Id: FN-4882
8.5 KiB
Secrets
Overview
Fusion's secrets subsystem provides encrypted-at-rest secret storage with project scope (.fusion/fusion.db) and global scope (~/.fusion/fusion-central.db).
Current shipped behavior in this branch includes:
- AES-256-GCM encryption primitives (
packages/core/src/secrets-crypto.ts) - CRUD + reveal APIs via
SecretsStore(packages/core/src/secrets-store.ts) - Per-secret access policy metadata (
auto/prompt/deny) - Schema-backed read metadata (
last_read_at,last_read_by)
Threat-model baseline:
- Secret plaintext is not stored in SQLite.
- Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal.
- Secret values must never be logged.
See also: Storage, Multi-project, Architecture, Settings reference.
Architecture
Fusion stores secrets in two SQLite tables:
- Project scope:
secretsin.fusion/fusion.db - Global scope:
secrets_globalin~/.fusion/fusion-central.db
Both tables share the same column contract:
| Column | Type | Notes |
|---|---|---|
id |
TEXT |
Primary key UUID. |
key |
TEXT |
Unique secret key (idxSecretsKey / idxSecretsGlobalKey). |
value_ciphertext |
BLOB |
AES-GCM ciphertext payload (includes auth tag). |
nonce |
BLOB |
Per-row random nonce. |
description |
TEXT |
Optional metadata. |
access_policy |
TEXT |
CHECK constrained to auto, prompt, deny. |
env_exportable |
INTEGER |
0/1 flag for env-materialization intent metadata. |
env_export_key |
TEXT |
Optional env variable key metadata. |
created_at |
TEXT |
ISO timestamp. |
updated_at |
TEXT |
ISO timestamp. |
last_read_at |
TEXT |
Last reveal timestamp. |
last_read_by |
TEXT |
Agent/user identifier recorded on reveal. |
For broader database inventory, see docs/storage.md.
Encryption
Secret crypto uses AES-256-GCM with:
- 32-byte master key
- 12-byte random nonce per encrypt operation
- 16-byte auth tag appended to ciphertext
Implementation reference: packages/core/src/secrets-crypto.ts.
Master Key Resolution
The current implementation exposes a MasterKeyProvider abstraction consumed by createSecretCipher / SecretsStore.
- Required contract: async provider that returns a 32-byte key.
- Validation failures return non-sensitive
SecretCryptoErrorcodes.
Runtime keychain/filesystem resolution is wired via MasterKeyProvider and consumed by SecretsStore; rotation UX remains follow-up work.
Access Policies
Per-secret policy values are:
autopromptdeny
Resolution helper (resolveSecretAccessPolicy) uses:
- Row-level secret policy (if set)
- Global settings default
secretsAccessPolicy(if set) - Fallback:
prompt
Implementation references:
packages/core/src/secret-access-policy.tspackages/core/src/types.ts(GlobalSettings.secretsAccessPolicy)
Approval integration is active through fn_secret_get policy handling (packages/cli/src/extension.ts:1581-1611) and approvals lifecycle APIs.
Dashboard CRUD
Secret persistence primitives are shipped at the store/API layer; a dedicated end-user SecretsView remains separate product work.
Agent Access (fn_secret_get)
fn_secret_get is shipped in packages/cli/src/extension.ts:1542-1629.
Tool contract:
- Params:
key(required),scope(projectorglobal, optional). - Resolution: key lookup in requested scope; missing key returns not-found result without plaintext.
- Policy outcomes:
auto→ reveals and returns plaintext value (secret:readaudit atextension.ts:1615).prompt→ creates approval request and returnsdetails.outcome: "pending_approval"(extension.ts:1607-1611).deny→ immediate refusal andsecret:approval-deniedaudit (extension.ts:1581-1583).
.env Auto-write into Worktrees
Fusion can materialize env-exportable secrets into each acquired task worktree when project settings enable it (secretsEnv.enabled=true).
- Supported settings:
enabled,filename(default.env, validated as local filename only),overwritePolicy(skip/merge/replace),keyPrefix,requireGitignored(defaulttrue). - Safety guard: when
requireGitignoredis enabled, Fusion runsgit check-ignore -- <filename>and refuses writes unless the file is ignored. - Write contract: managed content is canonicalized and written atomically with mode
0o600; audit metadata includes keys and counts, never values. - Fingerprint sidecar: successful writes persist
.fusion-secrets-env.fingerprintcontaining<sha256>\n<filename>\n(mode0o600) so teardown can verify file integrity before deletion. - Teardown cleanup: when a worktree is removed, Fusion deletes the managed env file only when the on-disk fingerprint still matches; edited files are preserved and only the sidecar is removed.
Settings shape is project-scoped in ProjectSettings (packages/core/src/types.ts:2599-2609): secretsEnv (env materialization config) and secretsSyncPassphrase (ciphertext already wrapped under local master key by caller; see types.ts:2602-2604).
Cross-node Sync
Fusion now exposes four secrets sync endpoints:
POST /api/nodes/:id/secrets/push— wraps local secrets into a passphrase-protected envelope and sends it to a remote node.POST /api/nodes/:id/secrets/pull— fetches a remote envelope fromGET /api/secrets/sync-exportand applies it locally.POST /api/secrets/sync-receive— inbound apply endpoint (BearerapiKeyrequired).GET /api/secrets/sync-export— inbound export endpoint (BearerapiKeyrequired).
Envelope format is WrappedSecretsBundle from packages/core/src/secrets-sync.ts:33-38: { version, ciphertext, salt, nonce, kdf, kdfParams } plus transport metadata (sourceNodeId, exportedAt). Wrapping uses scrypt (N=32768, r=8, p=1, keyLen=32, secrets-sync.ts:17-22) and AES-256-GCM with base64 ciphertext/salt/nonce (secrets-sync.ts:68-78).
Sync passphrase storage is local-only: reserved key __sync_passphrase__ in secrets_global with access_policy="deny" and env_exportable=false, encrypted under the local master key. The passphrase is never transmitted and never returned by HTTP endpoints.
Error mapping:
SecretsSyncErrorcodes (wrong-passphrase,version-mismatch,malformed) return HTTP400with{ "error": <code> }.- Missing passphrase returns HTTP
400with{ "error": "passphrase-not-configured" }. - Bearer auth failures return HTTP
401.
Inbound auth contract is enforced in route code (packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts:99-114, :181-196): missing/invalid Bearer Authorization or mismatched local apiKey returns 401.
Audit payloads exclude plaintext values, passphrases, and envelope crypto material (ciphertext, salt, nonce).
Audit Events
Filesystem-domain secret audit taxonomy:
secret:readsecret:createsecret:updatesecret:deletesecret:approval-requestedsecret:approval-grantedsecret:approval-deniedsecret:sync-pushsecret:sync-pullsecret:env-writesecret:env-write-skippedsecret:env-cleanupsecret:env-cleanup-skipped
All listed events are enumerated in packages/engine/src/run-audit.ts:261-274 (union at run-audit.ts:325). Route/tool emitters include: secret:sync-push (packages/dashboard/src/routes/register-secrets-sync-routes.ts:92), secret:sync-pull (register-secrets-sync-routes.ts:180, register-secrets-sync-inbound-routes.ts:158-164), secret:read + approval events (packages/cli/src/extension.ts:1581-1615), env materialization/cleanup (packages/engine/src/secrets-env-writer.ts:99-217).
Track follow-up: FN-5031 (missing packages/core/src/__tests__/secrets-env.test.ts contract file).
Track follow-up: FN-5032 (docs/settings-reference.md still marks shipped secrets settings as planned in some rows).
Plaintext prohibition: audit payload metadata must never include plaintext, decrypted values, ciphertext, or nonce fields. Use assertNoSecretPlaintext(...) as the canonical enforcement helper before emitting secret audit events.
Operational Notes
- Backups: preserve both SQLite data and master-key material/provider source used by deployment.
- If master key material is lost, encrypted secret values become unrecoverable.
- Pending advanced capabilities:
- Full rotation UX and key lifecycle tooling
- TTL/rotation automation, env-set profiles, KMS/Vault backends, per-node asymmetric sync