feat(security): add_ssh_key generate=true — server-side ed25519 keygen - #594
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds support for generating an ed25519 SSH keypair on the server side when generate: true is specified, keeping the private key secure within the cluster. Feedback on these changes focuses on performance, reliability, and type safety: using asynchronous execFile instead of blocking execFileSync to keep the event loop responsive, explicitly disallowing conflicting request parameters while wrapping key generation in a try-catch block to handle errors securely, and using Object.assign to prevent a TypeScript compilation error on the replication response.
|
Reviewed; no blockers found. |
d30fb7f to
0067f29
Compare
0067f29 to
8db85c4
Compare
With `generate: true` the duplicate-name guard ran after generation, so an add against a taken name spawned ssh-keygen and wrote throwaway private-key material to a temp file for a request that was guaranteed to throw "Key already exists". Resolve the paths and run the exists() check right after the key/generate xor validation instead, so a doomed request never mints anything. Addresses cb1kenobi's review on #594.
kriszyp
left a comment
There was a problem hiding this comment.
A few suggestions. I think most importantly keep the plaintext of the disk seems central to the goal here. Otherwise, a great new feature!
🤖 Reviewed with GPT 5.6
e0f0d86 to
c019827
Compare
|
@kriszyp — your review summary mentions "a few suggestions," but only the summary line came through; the inline comments aren't on the PR. I checked the REST comments endpoint, the per-review comments endpoint, and the GraphQL The headline point in your summary is handled either way. Keeping the plaintext off disk was exactly right, and it turned out to be the same fix @cb1kenobi's Windows concern needed: c019827 drops the 🤖 Posted by Claude Code (Opus 5) on Dawson's behalf |
With `generate: true` the duplicate-name guard ran after generation, so an add against a taken name spawned ssh-keygen and wrote throwaway private-key material to a temp file for a request that was guaranteed to throw "Key already exists". Resolve the paths and run the exists() check right after the key/generate xor validation instead, so a doomed request never mints anything. Addresses cb1kenobi's review on #594.
c019827 to
ab11290
Compare
kriszyp
left a comment
There was a problem hiding this comment.
Looks good. Might be worth using wx file mode for better atomicity, but I wouldn't say atomicity is high priority.
Proposed inline comments (anchors failed):
security/sshKeyOperations.ts: The preceding existence check and this defaultwriteFileare a TOCTOU pair. Two concurrentgenerate: truerequests for the same name can both observe no file, mint different pairs, and overwrite the same.key; both callers can then receive 200 with different public keys even though only the last private key remains. A retry while the first request is still running is enough to produce a deploy key that can never authenticate. Please atomically reserve/create the name (wxor a per-name lock) before generation, return the duplicate-name error to the loser, and keep the config update in the same serialized section.
(security/sshKeyOperations.ts:196 is not part of this PR's diff, so this is a file-level comment)
security/sshKeyOperations.ts:public_keyexists only in this initial response. If the client disconnects or times out after the local key is committed, a retry gets “Key already exists,” whileget_ssh_keyexposes only the sealed envelope; automation cannot recover the public half without deleting and regenerating the key. Please persist the non-secret public key and expose it on retrieval/retry, or otherwise make generated adds idempotently return the existing public key.
(security/sshKeyOperations.ts:284 is not part of this PR's diff, so this is a file-level comment)
🤖 Reviewed with Codex
|
Thanks @kriszyp — and the inline comments came through this time, so we have them now. Both are real; I checked them against the code rather than taking them on faith. Filed as follow-ups so they don't hold up this PR:
One thing worth flagging on #693: any reservation scheme needs to stay compatible with the replicated path, since peers re-run the op with 🤖 Posted by Claude Code (Opus 5) on Dawson's behalf |
|
@kriszyp I think this is ready to merge, yeah? Or are we waiting for some more 5.2 stuff to land? |
This comment has been minimized.
This comment has been minimized.
|
Re-reviewed The one Nit left open from the last pass is fixed: The Both unit suites run and pass, 22/22 — 7 in — |
kriszyp
left a comment
There was a problem hiding this comment.
Looks good to me (leaving comments in place for the record, and I am investigating the failed anchors).
Proposed inline comments (anchors failed):
security/sshKeyOperations.ts: The preceding existence check and this defaultwriteFileare a TOCTOU pair. Two concurrentgenerate: truerequests for the same name can both observe no file, mint different pairs, and overwrite the same.key; both callers can then receive 200 with different public keys even though only the last private key remains. A retry while the first request is still running is enough to produce a deploy key that can never authenticate. Please atomically reserve/create the name (wxor a per-name lock) before generation, return the duplicate-name error to the loser, and keep the config update in the same serialized section.
(security/sshKeyOperations.ts:196 is not part of this PR's diff, so this is a file-level comment)
security/sshKeyOperations.ts:public_keyexists only in this initial response. If the client disconnects or times out after the local key is committed, a retry gets “Key already exists,” whileget_ssh_keyexposes only the sealed envelope; automation cannot recover the public half without deleting and regenerating the key. Please persist the non-secret public key and expose it on retrieval/retry, or otherwise make generated adds idempotently return the existing public key.
(security/sshKeyOperations.ts:284 is not part of this PR's diff, so this is a file-level comment)
🤖 Reviewed with Codex
|
Ran a Fixed here (45f3cd9): Two smaller ones in the same commit: the Filed, not fixed here — all pre-existing, none introduced by this PR:
What held up under the review: the hand-rolled Note this needs a fresh approval on 45f3cd9 — 🤖 Posted by Claude Code (Opus 5) on Dawson's behalf |
|
Re-reviewed Both halves of the commit hold up under checking, and the ✅ from Strict
Only the four strings joi coerces change, and every one of them moves toward rejection — nothing that previously declined to mint now mints, so the narrowing carries no regression in the dangerous direction. Validation runs ahead of every side effect ( Worth noting the idiom is not bespoke — core already reaches for it for exactly this reason in Tests off the network. I did not take this on trust: I re-ran the suite under a preload that hard-throws on Mutation test, rebuilding and re-running each time:
So the suite does discriminate the fix, with the schema carrying it; the Counts: 16 passing in On the private key, checked but not re-litigated: only One thought for later, no action here: — |
|
Thanks — the mutation test is the useful part here, and I'm taking the conclusion as-is: reverting only One correction, on #2199 — I'd rather not add the detail you offered, because it would narrow the issue past what's true. You're right about the origin: on the
Your last point was the most valuable thing in the review, and it's now filed: HarperFast/harper#2200. Checking those four surfaced one that isn't latent. CI is green at 45f3cd9 (26 success, 6 skipped). Note @kriszyp's approval is on 67db98b and 🤖 Posted by Claude Code (Opus 5) on Dawson's behalf |
`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>
With `generate: true` the duplicate-name guard ran after generation, so an add against a taken name spawned ssh-keygen and wrote throwaway private-key material to a temp file for a request that was guaranteed to throw "Key already exists". Resolve the paths and run the exists() check right after the key/generate xor validation instead, so a doomed request never mints anything. Addresses cb1kenobi's review on #594.
…o ssh-keygen `ssh-keygen` is not present on a stock Windows host, and the subprocess had to land the minted private key in a temp file before it could be read back — putting plaintext key material on disk, which is most of what `generate: true` exists to avoid. Node's crypto generates ed25519 but cannot serialize it for SSH: its `pkcs8` and `spki` PEM exports are formats OpenSSH refuses to load for ed25519 (`ssh-keygen -y` reports `invalid format`), and a PEM public key is not what a host accepts as a deploy key. So `sshKeyGeneration.ts` encodes the raw key bytes into the two formats SSH actually reads: the `openssh-key-v1` private container and the one-line `ssh-ed25519 <base64> <comment>` public key. Verified against the real ssh-keygen, which loads the private key, re-derives the same public key and comment, fingerprints both halves identically, and signs with it — the signature is what proves the encoded private seed rather than just the public blob embedded beside it. The ClientError wrapping a failed `ssh-keygen` spawn goes away with the subprocess; there is no longer an environmental failure mode to translate into a 4xx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…'s comment The comment lands verbatim on the one-line public key, so a line break would split it and leave the tail parseable as a separate entry by whatever consumes the key — an `authorized_keys` file, a deploy-key field. The invariant was documented but only enforced indirectly, by `SSH_KEY_NAME_REGEX` in another module; `generateEd25519SSHKeyPair` is exported from a security module, so it now keeps the invariant itself. Unreachable from today's only caller, which derives the comment from a validated key name. A plain Error rather than a ClientError: reaching it means a caller bug, not bad client input. Spaces stay legal — ssh treats the rest of the line as the comment — and a test pins that so the guard isn't tightened into rejecting them later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tests off the network `validateBySchema` discards Joi's coerced value, so a caller that stringifies booleans reached `addSSHKey` with `req.generate` still a string — and `'false'` is truthy. `add_ssh_key generate='false'` with no `key` therefore minted a keypair the caller explicitly declined, where before this feature the same body was a clean `key is required` error. Conversely `generate='false'` alongside a real `key` was rejected as "not both", so such a client could not add a key at all. Joi accepts those strings by coercion rather than rejecting them, so the schema now marks the field `.strict()`, and the branch tests `req.generate === true` rather than truthiness. `delete req.generate` also moves out of the mint branch so no variant of the flag — including a literal `false` — reaches a peer. Two unrelated fixes found in the same pass: - The `generate` unit tests passed `hostname: 'github.com'`, which is the branch that really fetches api.github.com. They were making live network calls on every run, subject to GitHub's unauthenticated rate limit and to undici's full timeout on an offline machine (`.mocharc` sets no cap). Switched to `example.com`, as the pre-existing tests in the file already do. - The comment claiming the minted plaintext "only ever exists in this process" was false on a node with no secret custody, where `sealSSHKey` passes the key through and it replicates in the clear. Reworded to name that exception rather than assert the opposite of what happens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
45f3cd9 to
e91c8b9
Compare
kriszyp
left a comment
There was a problem hiding this comment.
Some codex suggestions, but concurrent requests isn't exactly a "major" IMO.
🤖 Reviewed with Codex
… house style `core/AGENTS.md` is explicit that new unit tests use the bare `node:assert` module rather than `node:assert/strict`, calling `assert.strictEqual` / `assert.deepStrictEqual` where strict semantics are actually wanted. It also names `unitTests/security/` as one of the legacy directories that still imports `/strict` but is "not the target shape" — which is exactly where this new file landed, so the sibling file doing it is not a precedent to follow. All 11 `assert.equal` become `assert.strictEqual` and both `assert.notEqual` become `assert.notStrictEqual`, so no assertion loosens to `==`. `match`, `ok`, and `rejects` are identical on plain assert. Worth noting why lint did not catch this: the `no-restricted-imports` rule that rejects these imports is configured in `core/.oxlintrc.json`, and harper-pro's own oxlint config never inherited it, so `npm run lint:required` here is clean either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
add_ssh_key generate=true(withkeyomitted): the node mints an ed25519 keypair, stores/replicates the private half through the existing write path, and returns thepublic_keyfor the caller to register (e.g. as a GitHub deploy key). The private key never has to travel from the client.keybehaves exactly as before.key, so they never re-generate.Where:
security/sshKeyOperations.tsfor the operation;security/sshKeyGeneration.tsfor the keypair itself.Worth a look — the keypair is encoded by hand. Generation is in-process (
node:crypto), with nossh-keygensubprocess: that binary is absent on a stock Windows host, and shelling out meant the minted private key landed in a temp file before it could be read back, putting plaintext on disk thatgenerate: trueexists to avoid. Node can generate ed25519 but cannot serialize it for SSH — itspkcs8/spkiPEM exports are formats OpenSSH refuses to load (ssh-keygen -yreportsinvalid format), and a PEM public key is not what a git host accepts as a deploy key. SosshKeyGeneration.tsencodes the raw key bytes into theopenssh-key-v1container and the one-linessh-ed25519 <base64> <comment>public key directly. That encoder is the part of this PR that most deserves review.Because hand-rolled crypto serialization is only worth doing if it's provably right,
unitTests/security/sshKeyGeneration.test.mjsverifies it against the realssh-keygen: load the private key, re-derive the same public key and comment, fingerprint both halves identically, and sign with it — the signature is what proves the encoded private seed, since-yalone would pass on a wrong seed by reading the public blob embedded beside it. Plus a sweep over comment lengths 1–16 to cover every residue of the 8-byte padding boundary. Those assertions skip wheressh-keygenis absent (Windows, the platform that motivated the change) and run everywhere else.Refs #570. Docs: HarperFast/documentation#599 covers
generateand matches the response shape here.Generated by Claude Opus 5 via Claude Code.
Related — deploy-by-reference effort
HarperFast/harper#1849— two-phase stage/activate +revert_componentHarperFast/harper#1850—harper deploy by_ref=true(deploy by git reference)HarperFast/harper#1851—harper deploy setup=true(client-side sealed deploy credential)HarperFast/harper#1876— CI token auth +harper login --for-ciHarperFast/harper-pro#594—add_ssh_key generate=true(cluster-side keygen)HarperFast/create-harper#118— scaffolds this flowHarperFast/documentation#599— two-phase deploy, revert, by-reference deploys, sealed credentials,add_ssh_key generate, OIDC (all v5.3.0)HarperFast/documentation#630— CI token credentials in the canonical auth precedence (v5.2.0, shipped; mergeable independently)HarperFast/documentation#617—by_refref pinning,credential=true, GitHub Actions behaviorHarperFast/documentation#616— merged into Replication observability: link-down-since timestamp per link #599's branch on 2026-07-30; its content now lives in the three PRs above