Skip to content

Encrypt DeploymentAccount.Credentials at rest#437

Merged
ppXD merged 2 commits into
mainfrom
feat/encrypt-account-credentials-at-rest
Jun 13, 2026
Merged

Encrypt DeploymentAccount.Credentials at rest#437
ppXD merged 2 commits into
mainfrom
feat/encrypt-account-credentials-at-rest

Conversation

@ppXD

@ppXD ppXD commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Encrypt the DeploymentAccount.Credentials column at rest — the system's most sensitive secret store (every account type's token / password / cloud secret / SSH private key), previously persisted as plaintext JSON.
  • Do it at the single seam every account read and write funnels through, DeploymentAccountDataProvider (deploy pipeline, OpenClaw paths, and account service all load via it), reusing the existing IVariableEncryptionService V2 envelope — mirroring how VariableDataProvider protects the Variable table.
  • Non-breaking / zero migration: read-both via the SQUID_ENCRYPTED_V2: prefix — a value lacking it is returned verbatim, so pre-existing plaintext rows keep working and upgrade lazily on next save. No schema change, no backfill. Encrypt-on-write is idempotent (IsValidEncryptedValue guard) so a re-save never double-wraps.
  • Reads use the repository's AsNoTracking query so decrypting the in-memory entity can never be flushed back as plaintext; GetAccountByIdAsync is switched from tracking FindAsync to an AsNoTracking query for the same reason. MapToDto builds the response summary from the plaintext credentials the service already holds (the entity carries ciphertext after a write).

Operational prerequisite: reuses the already-configured Security:VariableEncryption:MasterKey — no new key source, no hardcoded default. With an unset MasterKey the envelope is cosmetic (key derived from empty input); real protection requires an operator-set key + SQUID_MASTER_KEY_ENFORCEMENT=strict. Key-lifecycle hardening (forcing a non-empty key in strict mode, platform-rooted derivation) is tracked separately under the SOTA-audit P5.

Test plan

  • Unit (DeploymentAccountCredentialsEncryptionTests, 14): every account type's serialized credentials round-trips through the V2 envelope with the secret intact + never verbatim; legacy plaintext reads back verbatim (read-both); already-encrypted detection (no double-wrap); tampered ciphertext rejected by the GCM tag.
  • Integration (real Postgres, IntegrationAccountCredentialsAtRest, 3): a written account's RAW DB column carries the SQUID_ENCRYPTED_V2: prefix and never the cleartext secret; reads back decrypted through the provider; a hand-inserted plaintext row reads back verbatim (read-both); update re-encrypts.
  • Regression: 165 account-related unit tests + 19 integration tests green (incl. DeploymentAccountServiceTests, EndpointContextBuilderTests, PrepareTargetsPhaseTests, OpenClaw) — the MapToDto refactor + provider change are non-breaking.
  • CI: the existing K8s Pipeline E2E exercises account-credential consumption end-to-end through the same GetAccountByIdAsync decrypt seam.

The Credentials column is the most sensitive secret store in the system — every
account type's token / password / cloud secret / SSH private key — and was
persisted as plaintext JSON. Encrypt it at rest through DeploymentAccountDataProvider,
the single seam every account read and write funnels through (the deploy pipeline,
the OpenClaw paths, and the account service all load via it), mirroring how
VariableDataProvider protects the Variable table.

- Encrypt-on-write (Add/Update) via the existing IVariableEncryptionService V2
  envelope, guarded by IsValidEncryptedValue so a re-save never double-wraps.
- Decrypt-on-read on every load. Reads use the repository's AsNoTracking query so
  mutating the in-memory entity can never be flushed back as plaintext;
  GetAccountByIdAsync is switched from the tracking FindAsync to an AsNoTracking
  query for the same reason.
- Read-both / zero-migration: a value lacking the SQUID_ENCRYPTED_V2 prefix is
  returned verbatim, so pre-existing plaintext rows keep working and upgrade
  lazily on next save. No schema change, no backfill.
- MapToDto now builds the response summary from the plaintext credentials the
  service already holds, since the entity carries ciphertext after a write.

Reuses the already-configured Security:VariableEncryption:MasterKey — no new key
source and no hardcoded default. With an unset MasterKey the envelope is cosmetic;
real protection requires an operator-set key (SQUID_MASTER_KEY_ENFORCEMENT=strict).
@ppXD ppXD added this to the 1.9.1 milestone Jun 13, 2026
Pre-merge review caught a blocker: repository.Query<T>() TRACKS (the AsNoTracking
variant is the separate QueryNoTracking), so decrypting the loaded entity in place
marked it Modified — and the deploy pipeline's shared-scope SaveChanges would flush
the plaintext back, silently un-encrypting the account on the first deployment.

- Switch all four reads (GetAccountById/ByIds/BySpaceId/Paging) to
  repository.QueryNoTracking<DeploymentAccount> so the decrypt mutates a DETACHED
  entity that is never flushed (matching DeploymentCheckpointService's idiom). The
  Update flow still persists correctly: UpdateAccountAsync re-encrypts before
  _dbContext.Update re-attaches the detached entity as Modified. Also eliminates the
  spurious DocumentModified audit event the false-Modified state would have emitted.
- Regression test (IntegrationAccountCredentialsAtRest): read+decrypt an account then
  an unrelated SaveChanges in the SAME scope → assert the raw column still carries the
  SQUID_ENCRYPTED_V2 envelope. Fails on the tracking code, passes after the fix.
- E2E: route K8sTestDataSeeder account creation through the encrypting
  AddAccountAsync (was raw InsertAsync → plaintext), so every K8s pipeline E2E now
  exercises the encrypted-at-rest credential path end to end (+ a 3-arg E2E fixture
  Run overload to resolve the provider).
@ppXD
ppXD merged commit 7b6fa4a into main Jun 13, 2026
15 checks passed
@ppXD
ppXD deleted the feat/encrypt-account-credentials-at-rest branch June 13, 2026 15:06
@ppXD ppXD mentioned this pull request Jun 13, 2026
4 tasks
ppXD added a commit that referenced this pull request Jun 13, 2026
The package-feed credential was persisted in cleartext. Encrypt it at rest in
ExternalFeedDataProvider — the single seam every feed read/write funnels through
(deploy-pipeline package fetch, Helm/K8s registry auth, the feed service all load
via GetFeedByIdAsync / GetExternalFeedsByIdsAsync / paging) — reusing the
IVariableEncryptionService V2 envelope, same pattern as DeploymentAccount (#437).

- Encrypt-on-write (Add/Update, idempotent IsValidEncryptedValue guard).
- Decrypt-on-read via repository.QueryNoTracking (detached entities) so the in-place
  decrypt is never flushed back as plaintext by a later shared-scope SaveChanges —
  the exact hazard caught + fixed in #437, applied here from the start.
- Read-both: an unprefixed value is returned verbatim, so pre-existing plaintext
  rows still load and upgrade lazily. No schema change, no migration.

No service-layer change: ExternalFeedDto exposes only PasswordHasValue (a non-empty
ciphertext still reports true), so the response stays correct with the entity
holding ciphertext.

Reuses the existing Security:VariableEncryption:MasterKey (no new key, no hardcoded
default). Integration tests (real Postgres) cover encrypted-at-rest column,
decrypt-on-read, read-both on a legacy plaintext row, update re-encrypt, and the
same-scope flush regression.
ppXD added a commit that referenced this pull request Jun 13, 2026
A certificate's PFX password AND the PFX/PEM blob (private-key material) were both
persisted in cleartext — so the PFX password protection was moot. Encrypt both at
rest in CertificateDataProvider, the single seam every certificate read/write
funnels through (deploy-pipeline client-cert auth via EndpointContextBuilder, the
cert-variable expander, the certificate service), reusing the
IVariableEncryptionService V2 envelope. Same pattern as DeploymentAccount (#437) /
ExternalFeed (#438). Read-both (unprefixed plaintext passthrough) = non-breaking,
no migration. No service change (CertificateDto is HasValue-only). Makes the
long-stale Certificate.cs 'encrypted at rest' comment finally true.

Reads use a load-tracked-then-Detach-then-decrypt approach (new IRepository.Detach)
rather than QueryNoTracking. Detaching gives the same flush-safety guarantee as
#437/#438 (the in-place decrypt is never flushed back as plaintext by a later
shared-scope SaveChanges) AND, unlike a second AsNoTracking instance, frees the
identity map — so the existing single-scope create-then-update/delete CRUD tests
do not hit a duplicate-tracking conflict. (QueryNoTracking is production-safe for
account/feed because each mediator request is a fresh scope; the certificate CRUD
tests exercise multiple ops in one scope, which detach handles cleanly.)

Tests: integration (real Postgres) covers both columns encrypted-at-rest,
decrypt-on-read, read-both on a legacy plaintext row, the same-scope flush
regression, and a metadata-only update preserving + re-encrypting the secrets. All
34 existing Certificate integration tests + 99 unit tests stay green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant