Encrypt stored SSH deploy keys at rest#582
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces SSH key sealing at rest to prevent plaintext private keys from being stored on disk or transmitted over replication. It adds a sealSSHKey helper to encrypt keys using the cluster's secret custody, falling back to plaintext with a warning if custody is unavailable. Additionally, comprehensive unit tests are added to verify the sealing, replication, and degraded mode behaviors. The reviewer suggested using defensive optional chaining when retrieving the custody public key fingerprint to prevent potential runtime errors.
|
Reviewed; no blockers found. |
Chain optional access through getPublicKey() so a custody implementation returning undefined fails gracefully instead of throwing a TypeError, matching the existing optional-chaining intent on custody itself. Addresses gemini-code-assist review comment on PR #582.
|
Could Studio encrypt it before sending it, too? Like we do for secrets? |
`add_ssh_key` wrote the private key plaintext to `<rootDir>/ssh/<name>.key` and replicated the raw key in the operation body, so the key material transited replication and landed on every peer's disk. Log redaction only ever covered the log surface. The key is now sealed into an `enc:v1:` envelope (the same envelope encryption backing the hdb_secret store) before it touches disk or the replicated op body, closing both the at-rest and in-op-body exposures. A key that arrives already sealed — replicated from a peer, or cloned from the leader — is stored verbatim and never decrypted to forward it, so `get_ssh_key`/`cloneSSHKeys` carry ciphertext too. Core decrypts to a transient 0600 file only for the lifetime of a git invocation (`materializeGitSSH`). Degraded mode: with no secret custody registered there is no key to seal against, so the key is stored as plaintext — today's behavior — with a loud WARN. This follows core's `ingestRegistryAuth`, which likewise passes a literal credential through rather than failing the operation when custody is absent; SSH keys predate custody and must keep working on a node that has none. `update_ssh_key` rotation and `list_ssh_keys` (names only) are externally unchanged. Refs #581 Co-Authored-By: Claude Opus <noreply@anthropic.com>
Chain optional access through getPublicKey() so a custody implementation returning undefined fails gracefully instead of throwing a TypeError, matching the existing optional-chaining intent on custody itself. Addresses gemini-code-assist review comment on PR #582.
get_ssh_key now returns the sealed enc:v1: envelope by design (#581) — update the test's expectation to match instead of the stale plaintext assertion. The delete_ssh_key failure was a side effect: the prior assertion failure skipped that test's cleanup, leaving a stray key for the next test to trip over. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ca1693a to
868407a
Compare
dawsontoth
left a comment
There was a problem hiding this comment.
Looks reasonable to me. Crazy idea... could we support generating SSH keys within the cluster purely? And then we can hand out the public key to super users, who could go stick it in GitHub or wherever. Then the private key never leaves the cluster. Not proposing we do that here, just kicking the idea out of my head and into the ether.
Looks like you have addressed this with #594. Thank you! |
Chain optional access through getPublicKey() so a custody implementation returning undefined fails gracefully instead of throwing a TypeError, matching the existing optional-chaining intent on custody itself. Addresses gemini-code-assist review comment on PR #582.
`add_ssh_key generate=true` (with key omitted) mints an ed25519 keypair on the node (async ssh-keygen); the minted private key flows through the same seal-at-rest + replicate path (sealSSHKey) as a client-supplied key, returning public_key for the caller to register (e.g. a GitHub deploy key). The private key never travels from the client. generate and key are mutually exclusive; the origin strips generate before replicating so peers receive a plain (sealed) key add. ssh-keygen failure (incl. not on PATH) is wrapped in a ClientError (no key material). integrationTests cover generate=true (mints, returns public_key, sealed on disk), generate+key (rejected), and neither (client error). Rebased onto main to integrate with encrypt-at-rest (#582). Relates to HarperFast/harper#1778. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
add_ssh_keywrote the private key plaintext to<rootDir>/ssh/<name>.keyand replicated the raw key in the operation body — so the key material transited replication and landed on every peer's disk. Log redaction only ever covered the log surface, not storage or the wire.The key is now sealed into an
enc:v1:envelope (the same envelope encryption backing thehdb_secretstore) before it touches disk or the replicated op body, closing both exposures at once. Core decrypts it to a transient 0600 file only for the lifetime of a git invocation.sealSSHKey): plaintext in → envelope on disk, envelope in the replicated op body.kidis checked against this node's custody the wayset_secretvets an ingested envelope.get_ssh_keynow returns the key as stored (the envelope). This is a deliberate behavior change, called out below.update_ssh_keyrotation andlist_ssh_keys(names only) are externally unchanged.Fixes the storage half of #581. Requires the companion core PR (see below).
The issue and the dispatch both scoped this as harper-pro-only, on the reasonable assumption that the encryption primitives were the only core dependency. They are there (
utility/secretEnvelope.ts,resources/secretDecryptor.ts) — but the at-use site is also in core:getGitSSHCommand()incore/components/Application.tsis what buildsGIT_SSH_COMMAND, and it has no pro-side hook. There is no way to satisfy the issue's step 2 (decrypt-to-transient-file for the git operation) from pro alone, so this is a coordinated two-PR change:coresubmodule to it)I went ahead and built both rather than stopping to ask, so it's ready if you want it — but if you'd rather this land differently (e.g. a generic core hook that pro registers into), the core PR is cheap to abandon.
Degraded mode: plaintext + loud WARN (option (a))
The issue left this open. I found a clear precedent and followed it rather than inventing behavior:
setSecretthrows without custody — but that's a new op whose entire purpose is custody.ingestRegistryAuthpasses the literal credential through without custody, rather than failing the deploy.add_ssh_keyis theingestRegistryAuthcase: an existing feature being retrofitted, which must keep working on a node with no custody. So with no custody the key is stored plaintext (today's behavior) with a WARN naming the key and telling you to configuresecretCustody. Custody is present by default (the file tier generates a cluster keypair on first boot), so this is the exception, not the path. At use time the posture is the opposite and fail-closed: an encrypted key on a node that can't decrypt it is skipped and logged loudly, never treated as plaintext.Where to look
get_ssh_keyreturning the envelope instead of the plaintext key is the one observable API change, and the bit I'd most like a second opinion on. It's what makescloneSSHKeyscarry ciphertext (it doesget_ssh_keyon the leader →add_ssh_keylocally, which is another plaintext-over-the-wire path today), and it stopsget_ssh_keyfrom being a plaintext-key read for superusers. But if anything external depends onget_ssh_keyreturning usable key material, this breaks it. The issue only pinnedupdate_ssh_key/list_ssh_keysas must-not-change, so I took the more secure reading — say the word if you'd rather it decrypt-on-read.req.keyis mutated to the envelope beforereplicateOperation(req)in bothaddSSHKeyandupdateSSHKey— that mutation is what keeps the plaintext off the wire, so it's load-bearing rather than incidental.addSSHKey/updateSSHKeynow also passmode: 0o600to the initial write, closing the brief window where a freshly-created key file sat at the umask default beforechmod.Tests
unitTests/security/sshKeyOperations.test.mjs— 10 tests: sealing on add (disk and the replicated op body), config block, verbatim storage of an already-sealed key, rejection of a foreign-clusterkidand of a malformed envelope, rotation, names-only listing,get_ssh_keyreturning ciphertext, and both degraded-mode paths. Full pro unit suite: 364 passing.🤖 Generated by KrAIs (Claude Opus 4.8) with Claude Code