Key-custody seam: file + injected secret-key providers (closes #166)#512
Conversation
…ta-hook) Integration branch for the custody seam: core #1554 (registerSecretCustody + hdb_secret store) merged with harper#1559 (workerData providers + deferred decrypt replay). The pointer moves to the merged upstream branches at merge time, after both core PRs land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cluster-shared RSA-4096 keypair persisted under keys/: the leader generates it on first boot, cloning nodes fetch the private key from the leader during clone_node, and every thread loads it from disk. Ported from feat/env-secret-encryption (#505) with two changes: the envelope crypto now lives in core (utility/secretEnvelope.ts, itself ported from #505's envSecretCrypto.ts) instead of being duplicated here, and on-disk filenames are per-kid (envSecrets.<kid8>.private.pem) so a future rotation walk can stage multiple keys. The key store keeps a stable active-key alias (envSecrets.private.pem) so cloneNode can get_key it without discovering the kid first; the cloned key is persisted under its per-kid name derived from the key material. Co-authored-by: Dawson Toth <dawson@harperdb.io> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…riction Closes #166. The secretCustody component selects a custody provider at startup and registers it with core's registerSecretCustody (which installs the loadEnv decryptor and backs the secret operations; core owns the get_secrets_public_key operation — Pro registers none). - injected tier: the host provisions the private key into the main thread, fd-first (HARPER_SECRETS_KEY_FD read to EOF and closed) with a base64 env fallback (HARPER_SECRETS_PRIVATE_KEY_B64); both variables are scrubbed before any worker or job spawns. Memory-only: nothing persisted, nothing in the get_key store. Workers receive the key via core's workerData provider hook — request (http) workers only, never job workers — and the worker deletes the workerData property as early as it runs. - selection: auto only when exactly one source exists; injected material plus an on-disk key refuses to activate (main thread and, via a disabled workerData marker, all workers) until secretCustody.provider is set. - get_key restriction: custody key names are served only to bypass_auth (internal / node-identity) requests, closing the remote-exfiltration path through the operations API; replication cert-auth cloning still works, credential-auth clones cannot fetch the custody key. - decrypt is kid-map based (envelope kid lookup, active-key fallback for kid-less envelopes); getPublicKey exposes the single active key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a secret-custody component supporting two tiers of key custody for cluster-shared secrets: a file-based tier that persists RSA-4096 keys on disk and clones them from the leader, and an injected tier that ingests memory-only keys via file descriptors or environment variables. It also restricts custody key access to node-identity requests and adds comprehensive tests. The review feedback highlights a potential file-writing failure in cloneNode.ts if the target directory does not exist, and recommends wrapping key-parsing operations in try-catch blocks within keyCustody.ts to prevent unhandled exceptions from crashing the main process or worker threads when dealing with malformed key material.
|
Reviewed; no blockers found. |
kris/secret-custody-core now includes harper#1559's review fixes (reserved workerData keys incl. noServerStart, structuredClone detachment, redacted deferred-queue accessor, identical-value replay no-op). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bort, kid fail-fast - Clone ordering hole (Codex): cloneNode starts Harper before the leader's env-secrets key is cloned, so a fresh clone would self-generate a divergent file key and could even encrypt new secrets under it. cloneNode now sets a clone-bootstrap gate (security/custodyState.ts, dependency-free so it can load pre-start) before main(); the file tier stays dormant instead of generating while gated (loading an existing on-disk key is still allowed for re-clones), and cloneEnvSecretsKeys lifts the gate and registers the cloned key via activateFileCustodyFromDisk once persisted (core's deferred replay heals queued .env values). If no key can be fetched, the gate stays latched for the session — fail-closed, no divergent generation. Pre-start fetch was not viable: cert auth cannot reach the leader before replication is up, and credential auth is barred from custody keys by the get_key gate. - Single-thread bypass (Codex): disableCustody now latches; start() honors the latch so threadCount=0 cannot fall through to the on-disk key an ambiguous selection just refused. - Unreadable key files (Gemini): files-exist-but-unreadable now aborts ensureFileKeys with a clear error instead of generating a replacement keypair (cluster key split); workers log an error instead of running silently dormant. - Unknown-kid fail-fast (Gemini): the active-key fallback applies only to kid-less envelopes; an unknown kid throws `no secrets key for kid` rather than surfacing an OpenSSL padding error from the wrong key. - Guard the workerData `in` check against non-object workerData. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // requests have it stripped) — otherwise one super-user API call could exfiltrate the | ||
| // cluster's secrets key (#166). Credential/token-auth cloning therefore cannot fetch these. | ||
| if (isCustodyKeyName(name) && req.bypass_auth !== true) { | ||
| throw new ClientError('This key is restricted to node-identity requests', 403); |
There was a problem hiding this comment.
Do we list these keys in the SSH keys list operation that the UI calls? To avoid innocent errors, if we do, filtering them out of that operation would be helpful. A core change... double checking here though.
There was a problem hiding this comment.
Great instinct to check — I dug into both listing surfaces the Studio calls, and the good news is the custody keys don't show up in either, so no filter (core or pro) is needed:
list_ssh_keys(security/sshKeyOperations.ts→listSSHKeys) enumerates<rootPath>/ssh/only (withknown_hosts/configexcluded). The custody keys live in<rootPath>/keys/(envSecrets.<kid8>.private.pem), a different directory, so they can never appear there.list_certificates(coresecurity/keys.ts→listCertificates) reads rows from thehdb_certificatetable. Custody keys are never inserted there — they exist only as files underkeys/plus entries in core's in-memorygetPrivateKeys()map, and I verified nothing enumerates either of those into any operation response (the only readdir surfaces in core + pro are thessh/dir and the custody module's own scan).
So the only operation that can return custody key material at all is get_key by exact name — which is exactly the path this PR gates to node-identity/bypass_auth requests. If a future op ever enumerates the keys/ dir or the private-key map, isCustodyKeyName() is exported and ready to be that filter.
Leaving this thread open for you in case you had a different listing surface in mind!
🤖 (Claude, Fable 5)
…esults) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ial never crashes - cloneEnvSecretsKeys creates the keys dir before persisting the cloned key (a fresh clone cannot depend on boot having created it). - Malformed injected key material (bad PEM/base64, fd garbage) no longer throws out of startOnMainThread/start: custody logs a clear error (never the material), latches disabled, and the node keeps booting — plaintext set_secret stays rejected and encrypted .env values stay queued. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wns (#530) The entrypoint hands the cluster custody private key to the container over fd 3 via `exec 3<`, which cannot set close-on-exec. Until the consumer closes that fd, it is inherited by every descendant that shares the process fd table (worker threads) or that the process forks, so a compromised component could read the key straight off /proc/self/fd/3 (raised in host-manager#130 review). - Move the fd read/scrub/close into the dependency-free custodyState.ts (node:fs only) as consumeInjectedKeyMaterial(), and call it at the very top of bin/harper.js — before cloneNode()/harper() spawn any worker thread or subprocess. Because the module pulls in no config/logging graph, this runs before the base path is even set. - Make ingestion idempotent (read-once + cached): the top-of-boot consume and the later secretCustody component call now share one cached PEM; the fd is read exactly once. Deferred read/misconfig errors are stashed and logged by the logging-capable front door (injectedKeyCustody.ingestInjectedMaterial). - Keep the HARPER_SECRETS_PRIVATE_KEY_B64 env fallback scrub path unchanged. Tests: a worker thread can read the key off /proc while the fd is open and cannot after ingestion (fails if the close regresses); an end-to-end subprocess spot-check of /proc/self/fd/3; and an idempotency (read-once) check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Folded in a follow-up commit addressing #530 (fd-custody hardening) — this is the consumer half of the delivery contract @DavidCockerill flagged in host-manager#130 review. What it does
On the test: the acceptance asks for a — KrAIs 🤖 (Opus 4.8) |
# Conflicts: # core
# Conflicts: # core
Summary
Closes #166.
Implements the KeyCustody seam for cluster secret keys (#166): a Pro
secretCustodycomponent that selects a key-custody provider at startup and registers it with core'sregisterSecretCustody(from harper#1554), which installs the.envdecryptor and backs the secret operations. Core owns theget_secrets_public_keyoperation — this component registers no operations (regression-tested: Pro startup never touches that op-map entry).Supersedes #505 (feat/env-secret-encryption). The file tier is #505's provider, ported (with credit to @dawsontoth via
Co-authored-byon the ported commit) onto the core seam: the envelope crypto now lives in core (core/utility/secretEnvelope.ts, itself the portedenvSecretCrypto.ts), theget_secrets_public_keyregistration is dropped (core's), and on-disk key files are per-kid.Tiers and trade-offs
fileinjectedkeys/envSecrets.<kid8>.private.pem(0600)clone_nodefetches viaget_keyget_keyexposureHARPER_SECRETS_KEY_FDread to EOF then closed (key bytes never in the environ); fallbackHARPER_SECRETS_PRIVATE_KEY_B64, deleted immediately — but the environ residue at spawn time (/proc/<pid>/environ) is why fd is preferred. Both vars are scrubbed during main-thread component load, before any worker/job spawns (job workers copyprocess.envexplicitly).Provider selection (explicit-when-ambiguous)
Auto only when exactly one source exists: injected material → injected; none → file (generates on first boot). If injected material AND an on-disk key are both present, custody refuses to activate —
logger.errordemands an explicitsecretCustody: provider: file|injectedconfig block, and a disabled marker is delivered to workers via workerData so they don't silently activate the file tier while the main thread refused.get_keyrestriction (the exfiltration hole)keyService.ts'sget_keydocuments "must have bypass_auth" but never enforced it — any super-user API call could read anygetPrivateKeys()entry remotely. Registering the custody key there would have made the cluster's secrets key exfiltrable with one SU HTTP call. This PR gates custody-named keys (envSecrets.*private.pem) onreq.bypass_auth === true, which external HTTP requests can never present (the server strips it) and replication OPERATION_REQUESTs from authorized node identities do (server.operation(data, {user}, !isAuthorizedNode)in replicationConnection.ts). Consequence: cert-authclone_nodestill clones the custody key; credential/token-auth clones cannot — they get a 403, log clear guidance, and the node will generate its own (divergent) keypair on first boot. This is deliberate fail-closed behavior; flagging it here for review. (JWT keys keep their existing open-path behavior — out of scope.)kid-map rotation shape
Decrypt is a kid→privateKey map lookup by the envelope's own kid, falling back to the active key for kid-less envelopes (unknown kid → the standard mismatch error). Encrypt-side (
getPublicKey) exposes the single ACTIVE key. File tier persists per-kid filenames with the newest file as active, plus a stable key-store alias (envSecrets.private.pem) so a cloning node can fetch the active key without kid discovery; the cloned key is re-persisted under its per-kid name derived from the key material. Single key today; the rotation walk (P5) consumes the map.Ordering
Registration order vs
.envloading is not load-bearing: core queues encrypted entries it can't decrypt yet andregisterSecretDecryptorreplays them (harper#1559), so late custody registration heals rather than fails. (For reference, the normal order is still fine:startHTTPThreadsawaitsloadRootComponents()before spawning workers, and workers load root components — including this one — before user apps'.enventries.)Dependencies
Builds on harper#1554 (
registerSecretCustody+ hdb_secret store) and harper#1559 (workerData providers + deferred decrypt replay). Thecoresubmodule pointer in this PR references the integration branchkris/secret-custody-core(= #1554 branch + #1559 merged); the pointer moves to upstreammainat merge time, after both core PRs land. Merge order: harper#1554, harper#1559, then this.Where to focus
security/keyCustody.ts— selection + workerstart()fallback ladder (workerData → env ingest → disk). The worker env-ingest fallback exists for spawn paths that copy the environment before main-thread component load (dynamic threads); it also removes raw key material from a worker'sprocess.envrather than leaving it readable.security/custodyState.ts+ the clone gate inkeyCustody.ts/cloneNode.ts— the fix for the clone-ordering hole (see Cross-model review below); the fail-closed latch when no key can be fetched is a deliberate trade-off.security/keyService.ts— the 4-line gate carries the security weight of the file tier.Testing
unitTests/security/: file tier (generate/persist 0600/reload round-trip, decrypt of a core-encryptEnvelopeenvelope, fingerprint match, key-store registration + cleanup); injected tier (real-fd ingestion + close + scrub, b64 fallback + scrub, invalid-fd rejection, kid-map decrypt incl. non-active kid / kid-less / unknown-kid, worker-type filtering, selection matrix); component-level against the real core submodule (injected activation flushes core's deferred.envqueue, ambiguity refusal, explicit override, dormancy,get_key403/served/unaffected,get_secrets_public_keyhandler identity unchanged by Pro startup). Full Pro unit suite: 264 passing. Build (tsc, includesdist/corefrom the integration branch) emits with the same pre-existing type-error baseline asmain(36 lines, none in touched files).Generated by Claude (Fable 5).
Cross-model review (Codex + Gemini, adjudicated)
Found and fixed (commit ba65c15):
cloneEnvSecretsKeys, so a fresh clone would self-generate a divergent file key, register it, and could acceptset_secretunder a clone-only key before the leader's key arrived — with no re-registration after the fetch. Chosen fix: path (b), generation suppression. Pre-start fetch (path a) is not viable: cert auth "has no way to talk to the leader before replication is up" (cloneNode's own constraint), and credential auth is barred from custody keys by theget_keygate — so no auth mode can fetch the key pre-start. Instead, cloneNode sets a clone-bootstrap gate (security/custodyState.ts, dependency-free so it loads pre-start) beforemain(); while gated, the file tier never GENERATES (loading an existing on-disk key stays allowed for re-clones) and custody runs dormant —set_secretwith plaintext is correctly rejected, encrypted.envvalues queue in core. AftercloneEnvSecretsKeyspersists the fetched key, the gate lifts andactivateFileCustodyFromDisk()registers it (core's deferred replay flushes queued values on the main thread; workers that started dormant pick the key up from disk on the next restart — same residual feat(security): Pro env-secrets component for .env encryption #505 had). If no key can be fetched, the gate stays latched for the session: fail-closed, no divergent generation.disableCustodynow latches module state; withthreadCount === 0, the same-processstart()honors the latch instead of falling through toloadFileKeys()and activating the very key the ambiguous selection refused. Test added.hasFileKeys() && !loadFileKeys()(permission/disk fault) now ABORTSensureFileKeyswith a clear error instead of generating a replacement keypair (which would split the cluster key and orphan every existingenc:v1:value); worker threads log an error instead of running silently dormant. Test added.no secrets key for kid <kid>immediately instead of attempting RSA with the wrong key and surfacing an opaque OpenSSL padding error.inguard:start()guards against non-objectworkerDatabefore theincheck.Verified, no action needed: the claimed missing
LICENSE_KEY_DIR_NAMEimport in cloneNode.ts exists (pre-existing import block, line 28). Dismissed:.tsimport-extension claims (repo TypeStrip/dist convention).