Skip to content

Agent secrets

Where an agent's credentials live and how they're encrypted. Owned by the agent/secret slice behind an ISecretGateway (so the backend is swappable).

Where: encrypted in Postgres (MVP)

We store secrets in Postgres, encrypted at the application layer — not in a separate secrets vault. Rationale: cheapest, co-located with the rest of the data (Hetzner-centric), no per-secret fee, no cross-cloud latency. ISecretGateway hides the backend, so we can move to AWS Secrets Manager / Vault later without touching callers.

Not pgcrypto: encrypt in api before the INSERT, so the DB never sees plaintext or the key. A DB dump then contains ciphertext only.

Scope & key scheme

Secrets are team-, agent-, or user-scoped (the runtime keeps per-user secret files). The namespace:

agentfy/{teamId}/shared                              # team-level connectors
agentfy/{teamId}/agents/{agentId}                    # agent-level
agentfy/{teamId}/agents/{agentId}/users/{userId}     # user-scoped (≈ .agent/data/secrets/<userId>.json)

teamId isn't needed for uniqueness (agentId is unique) but it scopes IAM/namespace, tenant isolation, bulk-delete on offboarding, and cost/audit. The value is a JSON blob{"service:key": value} (e.g. gmail:app_password) — one encrypted record per scope, not one per key (mirrors .agent, avoids blow-up).

Encryption: app-layer envelope + AEAD

  • AEAD cipher: AES-256-GCM (or XChaCha20-Poly1305) — confidentiality + integrity.
  • Envelope (KEK → DEK): a random per-record DEK encrypts the blob; the DEK is wrapped by a KEK. Rotation = re-wrap DEKs (cheap), not re-encrypt every value.
  • Random IV per encryption (12 bytes for GCM; never reused with a key).
  • AAD binds context: teamId | agentId | scope | userId? is fed as additional authenticated data → a ciphertext can't be moved to another agent/row (decrypt fails on mismatch).
  • Stored per record: { ciphertext, iv, authTag, wrappedDek, kekVersion } (this is agent/secret.valueEnc).
ts
// encrypt in api, before INSERT
const dek = randomBytes(32), iv = randomBytes(12)
const c = createCipheriv('aes-256-gcm', dek, iv)
c.setAAD(Buffer.from(`${teamId}|${agentId}|${scope}|${userId ?? ''}`))
const ct = Buffer.concat([c.update(jsonBlob), c.final()]); const tag = c.getAuthTag()
const wrappedDek = wrapWithKEK(dek)            // KEK from k8s Secret (MVP) → KMS later
// store { ct, iv, tag, wrappedDek, kekVersion }

Where the KEK lives

  • MVP: KEK in a k8s Secret / env, injected only into api. Simple, pure-Hetzner.
  • Upgrade: wrap DEKs via a KMS (AWS KMS GenerateDataKey/Decrypt, or Vault Transit) — values stay in Postgres, KMS only handles tiny DEK blobs (cheap, managed rotation/audit). Swap behind ISecretGateway; the key scheme is unchanged.

Operational rules

  • Just-in-time: api decrypts only when injecting into the worker as a short-lived projected k8s Secret; plaintext lives in memory for seconds.
  • KEK only in api — never in worker, app, or admin.
  • Never log plaintext (redaction in the logger).
  • Rotation: kekVersion per record → rotate by re-wrapping DEKs in the background.
  • The worker holds no store credentials — it only gets the resolved, scoped secrets for its session (see Worker on Kubernetes).

What this does NOT do

  • No endpoint returns a value — ever. The list answers names, scope and context only; there is no "read this credential" route and none is planned. A value can be written and replaced, never read back. That is also why merging a set of keys cannot be done in the client: it would have to read the stored values first.
  • A write MERGES into its scope. Keys already stored in that scope survive; keys sent again are overwritten. An empty value is refused rather than meaning "remove it" — deleting one key is its own request, and purging an agent's credentials is another.
  • A shared-scope secret belongs to the team, not to an agent. Its declaration therefore shows on every agent of that team, including agents that were never part of the import that declared it, because the credential really is in force for all of them.
  • A concierge has no secrets — its type does not grant them.

Compatibility with .agent

agent/secret ↔ runtime .agent/data/secrets/<userId>.json, names only (AGNT2-74). A portable package carries the service:key names and their scope and no values at all: export never decrypts, and import records the names as pendingKeys — declared, unfilled — rather than creating blank credentials. Values are entered by hand at the destination; until they are, the secrets screen lists them as "не заполнен". No server-side gate keys on that: nothing reads a credential value today, so the warning belongs on the screen, and a per-tool check belongs at the tool that will actually need the key. A pre-AGNT2-74 package that still holds entries is accepted, but its values are discarded and only its names taken. Indexes/derived data are never carried.

Where it lives

apiagent/secret slice (ISecretGateway + envelope crypto helper). KEK backend is config: k8s Secret (MVP) or KMS.