Skip to content

Encrypt SSH proxy password at rest#446

Merged
ppXD merged 2 commits into
mainfrom
fix/p2-atrest-ssh-proxy-password
Jun 15, 2026
Merged

Encrypt SSH proxy password at rest#446
ppXD merged 2 commits into
mainfrom
fix/p2-atrest-ssh-proxy-password

Conversation

@ppXD

@ppXD ppXD commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • The SSH proxy password lived as plaintext inside the machine endpoint JSON column. Encrypt it at rest via the shared AtRestSecretProtector — at the two write seams (MachineRegistrationService register + MachineService.MergeSsh update) and decrypt at the two read seams (SshEndpointVariableContributor deploy path + SshHealthCheckStrategy connection builder).
  • The read seams are synchronous (IEndpointVariableContributor.ContributeVariables), and decrypt is pure CPU work, so add a synchronous Decrypt to IVariableEncryptionService (the already-sync core of DecryptAsync, which now delegates to it) and a synchronous Unprotect to IAtRestSecretProtector. This keeps the contributor interface sync — no async ripple across every transport's contributor.

Second P2 at-rest finisher (after AtRestSecretProtector extraction #445). Remaining: Tentacle private key (agent-side, separate mechanism).

Non-breaking

  • No new plaintext exposure: the proxy password ships inside the opaque Machine.Endpoint JSON returned by machine GET/list — previously as plaintext, now as the encrypted envelope (a net improvement). No consumer round-trips it (SquidWeb has zero ProxyPassword references; updates send changed fields only).
  • Read-both: a legacy plaintext proxy password in an existing machine's endpoint passes through Unprotect verbatim — no migration.
  • The protector is an optional ctor param on all four seams; DI injects the real one in production, and any test that constructs them without it degrades to the pre-feature plaintext path. Encrypt is idempotent, so a re-save (or the unchanged value carried through an update merge) never double-wraps.

Test plan

  • Unit (SshProxyPasswordAtRestTests): contributor decrypts an envelope before contributing the SSH connection variable; legacy plaintext passes through; no-protector leaves the value untouched.
  • Integration (real Postgres + real DI + real AES-256-GCM, IntegrationSshProxyPasswordAtRest): a proxy password encrypted via the real protector and stored in a machine's endpoint jsonb is decrypted by the DI-resolved contributor (proves the protector is wired into the read seam, raw column carries the SQUID_ENCRYPTED_V2: envelope not cleartext); legacy plaintext reads back verbatim.
  • Full unit suite green (6048).

The SSH proxy password lived as plaintext inside the machine endpoint JSON
column. Encrypt it at rest via the shared AtRestSecretProtector, at the two
write seams (register + update) and decrypt at the two read seams (the deploy
variable contributor + the health-check connection builder).

The read seams are synchronous (IEndpointVariableContributor.ContributeVariables
is sync), and decrypt is pure CPU work, so add a synchronous Decrypt to
IVariableEncryptionService (the already-sync core of DecryptAsync, which now
delegates to it) and a synchronous Unprotect to IAtRestSecretProtector. This
keeps the contributor interface sync — no async ripple across every transport.

Non-breaking:
- ProxyPassword is write-only from the API (no GET/response DTO returns it), so
  encrypting at rest changes nothing a client observes.
- Read-both: a legacy plaintext proxy password in an existing machine's endpoint
  passes through Unprotect verbatim — no migration.
- The protector is an optional ctor param on all four seams (register, update,
  contributor, health check); DI injects the real one in production, and any
  test that constructs them without it degrades to the pre-feature plaintext
  path. Encrypt is idempotent so a re-save (or the unchanged value carried
  through an update merge) never double-wraps.

Tests:
- Unit: contributor decrypts an envelope before contributing the SSH connection
  variable; legacy plaintext passes through; no-protector leaves the value
  untouched.
- Integration (real Postgres + real DI + real AES-256-GCM): a proxy password
  encrypted via the real protector and stored in a machine's endpoint jsonb is
  decrypted by the DI-resolved contributor (proves the protector is wired into
  the read seam); legacy plaintext reads back verbatim.
- Full unit suite (6048) green.
@ppXD ppXD added this to the 1.9.3 milestone Jun 15, 2026
…w blockers)

Adversarial review found the write-encrypt seams and the health-check decrypt
seam had zero test coverage — a regression dropping Protect()/Unprotect() would
silently leak plaintext (or break proxy auth) with a green suite. Production
code was verified correct; these close the coverage gaps:

- Register: RegisterSshAsync with the real protector persists an encrypted
  envelope (not plaintext) for the proxy password.
- Update (MergeSsh): a new plaintext password is encrypted; an unrelated edit
  does NOT re-wrap an already-encrypted value (idempotency — a double-wrap would
  break the single read-side Unprotect); a legacy plaintext is opportunistically
  migrated to encrypted on an unrelated edit.
- Health check: the connection builder decrypts the at-rest proxy password
  before building the SSH connection.

All drive the real AtRestSecretProtector (the default-ctor services have no
protector, so these are the only tests exercising the encrypt/decrypt branches).
Full unit suite (6053) green.
@ppXD
ppXD merged commit 445346e into main Jun 15, 2026
15 checks passed
@ppXD
ppXD deleted the fix/p2-atrest-ssh-proxy-password branch June 15, 2026 02:34
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