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}     # польз.-скоуп (≈ .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.