Skip to content

feat(lps): sealed password transport — agent seals to control X25519 key, gateway relays opaque#483

Merged
PaulDotterer merged 3 commits into
mainfrom
feat/lps-sealed-transport
Jul 2, 2026
Merged

feat(lps): sealed password transport — agent seals to control X25519 key, gateway relays opaque#483
PaulDotterer merged 3 commits into
mainfrom
feat/lps-sealed-transport

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Server side of the LPS wire-cleartext fix (manchtools/power-manage-agent#62, option C). Spec: docs 06-specs/18-lps-sealed-transport.md. Depends on SDK crypto.SealLpsPassword/OpenLpsPassword + verify.LpsPublicKeySignatureDomain (#263/#264, merged; SDK pin bumped to a314641).

Threat closed

LPS rotated passwords crossed agent→gateway→control as cleartext inside mTLS, so the gateway — the least-trusted server-side actor (no DB, no encryption key) — could read every rotated password. The agent now seals each password to a control-owned X25519 key; the gateway relays opaque bytes; control unseals at receipt then re-encrypts with the existing at-rest path. GetDeviceLpsPasswords recovery is unchanged.

Changes

  • migration 010: single-row lps_keypair table (infra state, not event-sourced). Private key stored ONLY enc:v2 (AAD-bound), never cleartext. New file (009 already applied in rc envs).
  • EnsureLpsKeypair (advisory-locked, first-writer-wins ON CONFLICT DO NOTHING) at control boot; replicas converge on one key via Postgres. Fatal on failure.
  • ProxySyncActions attaches the CA-signed public key (power-manage-lps-pubkey domain) so a relaying gateway cannot substitute its own key; agent verifies fail-closed.
  • ProxyStoreLpsPasswords: two-phase — unseal + re-encrypt + parse the whole batch, then append. Any unsealable entry → InvalidArgument, zero events (atomic; no inbox retry loop). Nil keypair fails closed.
  • gateway proxyLpsRotations: parses base64 sealed_password, DROPS legacy cleartext entries loudly (old-agent upgrade path) — never proxies/enqueues cleartext.

Tests

Keypair idempotency + concurrency + at-rest-encryption; signed-pubkey distribution + key-swap rejection; full seal→store→recover E2E with the real SDK primitive; unseal rejection (tampered/wrong-key/wrong-context) with no-event assertion; batch atomicity ([good,bad] → no events); nil-keypair fail-closed; gateway legacy-drop. Unseal-reject guard red-checked.

Local CodeRabbit: 3 findings, all fixed in the second commit (batch atomicity, pubkey validate+copy, username log redaction).

Full gate green under the pinned SDK: gofmt, vet, staticcheck, internal/api + internal/handler + cmd/control + internal/store test suites, archtest.

Summary by CodeRabbit

  • New Features

    • Added secure handling for local password rotations using sealed data and signed key distribution.
    • The system now initializes and shares the required key material during startup and sync.
  • Bug Fixes

    • Improved failure handling so invalid or tampered password rotations are rejected safely.
    • Batch rotation updates now apply atomically, preventing partial saves.
  • Documentation

    • Added design documentation for the new sealed password transport flow.

LPS rotated passwords crossed agent→gateway→control as cleartext inside
mTLS, so the gateway — the least-trusted server-side actor (no DB, no
encryption key) — could read every rotated password (manchtools/power-
manage-agent#62). This removes the gateway from the confidentiality
boundary: the agent seals each password to a control-owned X25519 key,
the gateway relays opaque bytes, and control unseals at receipt before
the existing at-rest encryption path. Recovery (GetDeviceLpsPasswords)
is unchanged. Spec: docs 06-specs/18-lps-sealed-transport.md.

- migration 010: single-row lps_keypair table (infra state, not event-
  sourced). Private key stored ONLY enc:v2 (AAD-bound), never cleartext.
- EnsureLpsKeypair (advisory-locked, first-writer-wins ON CONFLICT) runs
  at control boot; replicas converge on one key via Postgres. Fatal on
  failure — running without it would silently disable LPS rotation.
- ProxySyncActions attaches the CA-signed public key (new lps-pubkey
  domain) so a relaying gateway cannot substitute its own key; the agent
  verifies fail-closed against its enrollment CA.
- ProxyStoreLpsPasswords unseals SealedPassword (bound to
  device|action|username) then re-encrypts at rest. An unsealable blob
  is InvalidArgument (permanent — no inbox retry loop) with NO event
  appended; a nil keypair fails closed. Blobs/plaintext never logged.
- gateway proxyLpsRotations parses base64 sealed_password and DROPS any
  legacy cleartext entry loudly (never proxies or enqueues cleartext) —
  the old-agent + new-server upgrade path.

Tests: keypair idempotency + concurrency + at-rest-encryption, signed-
pubkey distribution + key-swap rejection, full seal→store→recover E2E
with the real SDK primitive, unseal-rejection (tampered/wrong-key/wrong-
context) with no-event assertion, nil-keypair fail-closed, gateway
legacy-drop. Unseal-reject guard red-checked.

Closes #481 was separate; refs manchtools/power-manage-agent#62
Address local review of the sealed-LPS handler:

- ProxyStoreLpsPasswords is now two-phase: unseal + re-encrypt + parse
  the ENTIRE batch (staging) before appending any event. A later
  entry's unseal failure previously returned InvalidArgument AFTER
  earlier entries were already appended, leaving a partial batch the
  permanent error would not retry. Staging makes it all-or-nothing.
- BuildSignedLpsPublicKey parses the key before signing (rejects a
  nil/malformed key rather than distributing a CA-signed unusable one)
  and signs the parsed key's own bytes, so a later mutation of the
  caller's slice cannot desync the bytes from the signature.
- Drop rotated usernames from the gateway drop-path and handler
  unseal/encrypt error logs — device_id + action_id locate the failure
  without persisting an account identifier in error logs.

New test TestProxyStoreLpsPasswords_BatchIsAtomic pins the [good, bad]
batch → InvalidArgument + zero events appended.
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements sealed LPS password transport: agents seal rotated passwords to a control-owned X25519 public key, the gateway relays opaque sealed bytes, and control unseals/re-encrypts them at rest. Adds keypair bootstrap, signing, storage, staged batch persistence, updated agent parsing, migration, queries, tests, and an ADR.

Changes

Sealed LPS Password Transport

Layer / File(s) Summary
Keypair storage schema and queries
internal/store/migrations/010_lps_keypair.sql, internal/store/queries/lps_keypair.sql
Adds lps_keypair table for a single global X25519 keypair (raw public key, encrypted private key) and GetLpsKeypair/InsertLpsKeypair SQLC queries with conflict-safe insert.
Keypair generation, decoding, signing
internal/api/lps_keypair.go
Adds EnsureLpsKeypair (advisory-lock bootstrap/persist/converge), decodeLpsKeypair/mustPublic (safe reconstruction with mismatch detection), and BuildSignedLpsPublicKey (CA-signed public key for distribution).
Handler wiring and startup init
internal/api/internal_handler.go, cmd/control/main.go
Adds SetLpsKeypair and key fields to InternalHandler, includes signed public key in SYNC responses, and initializes the keypair at control startup, exiting on failure.
Rotation unsealing and staged persistence
internal/api/internal_handler.go
ProxyStoreLpsPasswords fails closed without a keypair, unseals SealedPassword via OpenLpsPassword, re-encrypts, and persists rotations in a staged two-phase batch tracking partial failures.
Agent-side sealed rotation forwarding
internal/handler/agent.go
proxyLpsRotations parses sealed_password, base64-decodes into SealedPassword, drops legacy cleartext-only entries, and strips metadata when all entries are dropped.
Tests for sealed transport and handler behavior
internal/api/internal_handler_test.go, internal/api/internal_handler_gateway_binding_test.go, internal/api/lps_sealed_transport_test.go, internal/handler/agent_action_result_test.go, internal/handler/control_proxy_test.go
Covers keypair idempotency, signed public key delivery, sealed round-trip, rejection of tampered/unsealable input, batch atomicity, fail-closed behavior, and legacy-cleartext dropping.
ADR documentation and dependency bump
docs/adr/0028-lps-sealed-password-transport.md, go.mod
Documents the sealed transport design decision and updates the power-manage-sdk pinned version.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Agent
  participant Gateway
  participant ControlProxy as InternalHandler
  participant Encryptor
  participant Store

  Agent->>Agent: seal password with control public key
  Agent->>Gateway: sealed_password (base64)
  Gateway->>ControlProxy: ProxyStoreLpsPasswords(SealedPassword)
  ControlProxy->>ControlProxy: OpenLpsPassword(SealedPassword)
  alt unseal fails
    ControlProxy-->>Gateway: InvalidArgument
  else unseal succeeds
    ControlProxy->>Encryptor: EncryptWithContext(plaintext)
    ControlProxy->>Store: append lps_password event
    Store-->>ControlProxy: persisted / error
    ControlProxy-->>Gateway: success or Internal (retry)
  end
Loading
sequenceDiagram
  participant Main as cmd/control/main.go
  participant Api as EnsureLpsKeypair/BuildSignedLpsPublicKey
  participant Store
  participant Handler as InternalHandler

  Main->>Api: EnsureLpsKeypair(store, encryptor)
  Api->>Store: load or insert keypair
  Store-->>Api: private key, public key
  Main->>Api: BuildSignedLpsPublicKey(publicKey, signer)
  Api-->>Main: signed LpsPublicKey
  Main->>Handler: SetLpsKeypair(priv, signedPublicKey)
  Handler-->>Agent: LpsPublicKey (via SYNC response)
Loading

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: security, database, needs-migration

Suggested reviewers: (based on ownership of internal/api and internal/handler, and store migrations)

🐰 A rabbit seals a secret tight,
X25519 keys take flight,
Gateway sees but bytes obscure,
Control unlocks what's kept secure,
Passwords rotate, safe from sight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: sealed LPS password transport with agent sealing to a control-owned X25519 key and opaque relay through the gateway.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/lps-sealed-transport

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@PaulDotterer PaulDotterer merged commit 503d894 into main Jul 2, 2026
10 of 11 checks passed
@PaulDotterer PaulDotterer deleted the feat/lps-sealed-transport branch July 2, 2026 06:38

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/store/migrations/010_lps_keypair.sql (1)

15-23: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider a length CHECK on public_key.

public_key is documented as a fixed 32-byte X25519 key but has no length constraint. decodeLpsKeypair (internal/api/lps_keypair.go) already validates this at read time, so this is purely a defense-in-depth suggestion for data integrity at the DB layer.

♻️ Optional constraint
     public_key      BYTEA NOT NULL,
+    -- Defense-in-depth: application already validates, but a DB-level
+    -- constraint catches a malformed row before it reaches decode time.
+    CHECK (octet_length(public_key) = 32),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/migrations/010_lps_keypair.sql` around lines 15 - 23, Add a
defensive length constraint for the lps_keypair.public_key column in the CREATE
TABLE statement so the database enforces the documented fixed 32-byte X25519
size. Update the migration in 010_lps_keypair.sql by adding a CHECK on
public_key, and keep the existing read-time validation in decodeLpsKeypair
unchanged since this is only extra data integrity protection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/store/migrations/010_lps_keypair.sql`:
- Around line 15-23: Add a defensive length constraint for the
lps_keypair.public_key column in the CREATE TABLE statement so the database
enforces the documented fixed 32-byte X25519 size. Update the migration in
010_lps_keypair.sql by adding a CHECK on public_key, and keep the existing
read-time validation in decodeLpsKeypair unchanged since this is only extra data
integrity protection.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9907fb4f-4a95-4a7d-8fb8-22e7b146ebff

📥 Commits

Reviewing files that changed from the base of the PR and between efd3acb and 57ced46.

⛔ Files ignored due to path filters (3)
  • go.sum is excluded by !**/*.sum
  • internal/store/generated/lps_keypair.sql.go is excluded by !**/generated/**
  • internal/store/generated/models.go is excluded by !**/generated/**
📒 Files selected for processing (13)
  • cmd/control/main.go
  • docs/adr/0028-lps-sealed-password-transport.md
  • go.mod
  • internal/api/internal_handler.go
  • internal/api/internal_handler_gateway_binding_test.go
  • internal/api/internal_handler_test.go
  • internal/api/lps_keypair.go
  • internal/api/lps_sealed_transport_test.go
  • internal/handler/agent.go
  • internal/handler/agent_action_result_test.go
  • internal/handler/control_proxy_test.go
  • internal/store/migrations/010_lps_keypair.sql
  • internal/store/queries/lps_keypair.sql

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