Секреты агента
Где живут креды агента и как они шифруются. Владелец — слайс 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).
// шифруем в 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.