Skip to content

Секрети агента

Де живуть креди агента і як вони шифруються. Власник — слайс agent/secret за ISecretGateway (бекенд змінюваний).

Де: зашифровані в Postgres (MVP)

Зберігаємо секрети в Postgres, шифруючи на рівні застосунку — без окремого secrets-vault. Чому: дешево, co-located з рештою даних (Hetzner-centric), без плати за секрет, без cross-cloud латентності. ISecretGateway ховає бекенд → пізніше переїдемо на AWS Secrets Manager / Vault, не чіпаючи виклики.

Не pgcrypto: шифруємо в api до INSERT, щоб БД ніколи не бачила плейнтекст чи ключ. Дамп БД тоді містить лише шифротекст.

Scope і схема ключів

Секрети бувають командні, агентські або користувацькі (рантайм тримає файли секретів за юзером). Namespace:

agentfy/{teamId}/shared                              # командні конектори
agentfy/{teamId}/agents/{agentId}                    # агентські
agentfy/{teamId}/agents/{agentId}/users/{userId}     # user-скоуп (≈ .agent/data/secrets/<userId>.json)

teamId не потрібен для унікальності (agentId унікальний), але дає IAM/namespace-скоуп, ізоляцію тенантів, bulk-delete при оффбордингу, аудит/витрати. Значення — JSON-blob {"service:key": value} (напр. gmail:app_password) — один зашифрований запис на scope, а не на кожен ключ (як .agent, без вибуху).

Шифрування: app-layer envelope + AEAD

  • AEAD-шифр: AES-256-GCM (або XChaCha20-Poly1305) — конфіденційність + цілісність.
  • Envelope (KEK → DEK): випадковий пер-записний DEK шифрує blob; DEK загортається KEK. Ротація = перегортання DEK (дешево), а не перешифрування всіх значень.
  • Випадковий IV на кожне шифрування (12 байт для GCM; не перевикористовувати з ключем).
  • AAD прив'язує контекст: teamId | agentId | scope | userId? йде як дод. автентифіковані дані → шифротекст не можна перенести в інший рядок/агента (розшифрування впаде).
  • У записі: { ciphertext, iv, authTag, wrappedDek, kekVersion } (це agent/secret.valueEnc).
ts
// шифруємо в api, до 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 з k8s Secret (MVP) → KMS пізніше
// зберігаємо { ct, iv, tag, wrappedDek, kekVersion }

Де живе KEK

  • MVP: KEK у k8s Secret / env, інжектиться лише в api. Просто, pure-Hetzner.
  • Апгрейд: загортати DEK через KMS (AWS KMS GenerateDataKey/Decrypt або Vault Transit) — значення лишаються в Postgres, KMS ганяє лише крихітні DEK-блоби (дешево, managed-ротація/аудит). Міняється за ISecretGateway; схема ключів не чіпається.

Операційні правила

  • Just-in-time: api розшифровує лише при інжекті в worker як короткоживучий projected k8s-Secret; плейнтекст живе в пам'яті секунди.
  • KEK лише в api — ніколи в worker, app, admin.
  • Ніколи не логувати плейнтекст (redaction у логері).
  • Ротація: kekVersion у кожному записі → ротація перегортанням DEK у фоні.
  • worker не тримає креди стора — отримує лише дозволені секрети на свою сесію (див. Worker на Kubernetes).

Чого це НЕ робить

  • Жоден ендпоінт ніколи не віддає значення. Список повертає лише імена, scope і контекст; маршруту «прочитати цей кред» немає і не планується. Значення можна записати й замінити, але не прочитати назад. Саме тому злиття набору ключів неможливе на клієнті: йому довелося б спершу прочитати збережені значення.
  • Запис ЗЛИВАЄТЬСЯ у свій scope. Уже збережені в цьому scope ключі виживають; надіслані повторно перезаписуються. Порожнє значення відхиляється, а не означає «видали» — видалення одного ключа це окремий запит, а повне очищення кредів агента — ще один.
  • Секрет у shared-scope належить команді, а не агенту. Тому його оголошення видно у кожного агента команди, включно з тими, хто не брав участі в імпорті, що його завів: кред справді чинний для всіх них.
  • У concierge секретів немає — його тип їх не дає.

Сумісність з .agent

agent/secret ↔ рантайм .agent/data/secrets/<userId>.json, лише імена (AGNT2-74). Переносний пакет несе імена service:key та їхній scope і ЖОДНОГО значення: експорт ніколи не розшифровує, а імпорт записує імена як pendingKeys — оголошені, але не заповнені, — замість того щоб заводити порожні креди. Значення вводяться руками на місці призначення; доки цього не зробили, екран секретів показує їх як «не заповнений». Пакет старіший за AGNT2-74, що ще несе entries, приймається, але його значення відкидаються і беруться лише імена. Індекси/похідне не переносяться ніколи.

Де живе

api → слайс agent/secret (ISecretGateway + helper envelope-крипти). KEK-бекенд — конфіг: k8s Secret (MVP) або KMS.