--- id: node title: A host proves what it is once, and issues credentials to the sessions on it status: open crates: [didbot-agentd, didbot-attest] dependsOn: [credentials, attestation] exitCriterion: > A machine holding a node credential issues a session credential to a hook that asks for one, a machine without one is refused, and many hooks starting at once produce one provisioning request per session rather than one per hook. --- # node [credentials](credentials.md) states the shape and names this as its first item: *the shape mirrors SPIFFE — a node attests once, a local component issues credentials, and the workload talks to the server itself.* This epic is that local component, split out because it is a process with a lifetime, an install story and a privilege boundary of its own, and those do not fit inside a bullet. ## Why a daemon rather than the hook Both were on the table. The hook is the simpler answer and it does not survive contact with a busy machine. A hook is a short-lived process spawned per tool call. Putting the node credential in it means every one of those processes reads the credential off disk, so the blast radius of anything that can observe a process is the whole node identity rather than one session. And a machine running several sessions and their subagents fires many hooks at once, each asking the server for an account, so provisioning arrives as a stampede with nowhere to be coalesced. A long-lived component fixes both: one process holds the credential, and it is the natural place to serialise and rate-limit what reaches the server. The cost is honest and worth writing down: it is another thing to install, supervise and version on every agent host, and [deploy](deploy.md) already notes that agent hosts are their own deployment. [dev-setup](dev-setup.md)'s service/profile/binding model is where it has to fit. The argument above was written about code that did not exist. What it described as a daemon was one short-lived process per event, reading its state from a plaintext file. The reasoning holds; it was describing the design rather than the build. Two things it did not anticipate make it stronger. [subagents](subagents.md) gives every context an account, so a fan-out of parallel subagents acting at once is the same burst moved to the busiest moment rather than the quietest. And [cred-delivery](cred-delivery.md) has this process perform the write, which is what keeps a credential out of every place the model can read. ## What supervises it - [ ] **Start it from its socket, not from a hook.** A unit pair listening at a well-known path under the runtime directory, with the directory and the socket both created shut. The alternative — a hook that spawns the daemon when it is missing — is several hooks racing to be the one that launches it, which is the race this component exists to remove, reintroduced at its own front door. Socket activation makes it the kernel's problem: every caller connects to a socket that already exists. - [ ] **Do not linger.** The daemon dies with the login session, because a node credential resident while nobody is logged in is a target for no reason. - [ ] **Under socket activation the daemon does not create its own socket**, so every guarantee below has to be expressed twice — in the unit, and again in code for the path where it starts on its own. - [ ] **Installing unit files is where a narrow `didbot-setup apply` returns.** Not the one that edited a person's settings file and kept a ledger to undo it: this writes unit files and reports whether the plugin is present. `didbot_stack::ServiceKind` needs a variant so `didbot-setup check` can probe it. ## What it holds, and what that is worth - [ ] **Prefer a curve a keystore can hold.** The node key signs attestation claims, never a repository commit, so it is not bound by the two curves atproto accepts and can be chosen for where it lives instead. Every keystore on the hosts this project targets does P-256 and none do secp256k1. `p256` is already a direct dependency and `didbot-key`'s `secp256r1` module already reads the multibase form and knows the low-S predicate, so the cost is a curve-tagged signature in `NodeCredentialBackend` rather than a new dependency — and `deny.toml` denies multiple versions, so a third curve crate would not be cheap later. Carry the public half as a multikey and dispatch on the codec the way `didbot-claim`'s `identify` already does, so the curve stays a per-host fact. **This is a credential type and needs a human's approval before it is built.** - [ ] **One device credential per node**, with the file treated as seriously as the server treats its signing keys. [`NodeCredentialBackend`](../crates/didbot-attest/src/node_credential.rs) is the backend: one secp256k1 key per node, the verifier keeping only the public half, so a claim for one node can only be produced by whoever holds that node's credential and a compromise yields one node rather than the deployment. - [ ] **The bootstrap, concretely.** How a host gets its credential in the first place is the root of this whole chain and the step most easily hand-waved. Somebody issues it out of band, and that somebody is the operator; what that looks like, and what stops a second machine using a copy, is this epic's to answer rather than to assume. - [ ] **Say what it is not.** The crate's own documentation already does: the credential is a file, so anything that can read the node's disk can copy it, and the copy is indistinguishable from the original. That is what hardware attestation is for, and it is a stub in [attestation](attestation.md) rather than a thing this epic delivers. ## The local transport is a privilege boundary - [ ] **Harden the socket the way [e-stop](e-stop.md) had to.** That epic shipped the pattern after the exposure review found its admin socket landing world-writable at a predictable path whenever `XDG_RUNTIME_DIR` was unset — which is containers, most CI runners and many service accounts. A directory this process owns at `0700`, the socket `chmod`ed to `0600` after bind rather than left to the umask, a default path that is not world-writable, and a staleness check that an attacker cannot satisfy by binding first. Repeating that mistake here would be worse, because what is behind this socket is credential issuance. - [ ] **Copy the e-stop socket's hardening, all four parts.** `crates/didbot-serve/src/estop_admin.rs` is the only unix socket in this workspace and it already paid for this: a subdirectory this process owns rather than the socket at the base path; `0700` set on that directory on every start, including one that already exists, so a process that does not own it fails rather than binds; `chmod 0600` on the socket after bind regardless of umask; and a staleness check that unlinks only on `ECONNREFUSED`, because every other error means a listener may well be there and unlinking would hand the name to whoever binds next. - [ ] **Add a peer credential check, which e-stop did not need.** Its commands are uniform and uid-scoped; this socket issues per-context credentials, so it checks the connecting process rather than believing the payload. - [ ] **Anything a caller presents is a selector, not an authorization.** Reaching the socket is the permission, exactly as e-stop concluded when it deleted its own operator secret — the same caller could read it from the same filesystem, and the permissions were doing the real work. A ticket that starts deciding whether a caller is allowed has reintroduced what that removal took out. - [ ] **Cap what is provisioning at once, and publish the depth.** Provisioning is allowed to be slow. An unbounded queue of it is not, and a fan-out of subagents will produce one. The cap doubles as the bound on how much work an unadmitted caller can ask for, and the depth is a health number rather than a constant nobody can see — see [alerts](alerts.md). - [ ] **Nothing is prepared in advance.** An identity is reserved when a context appears and a key is generated when one is needed, and neither is kept spare against a future caller. Pre-preparing either is an optimisation for a latency nobody has measured, and it is not free: an account that exists before its entity makes `provisionedAt` mean the moment a pool was filled rather than the moment a context began, and leaves unclaimed material somebody has to reap. If provisioning turns out to be slow, measure it first and record the number here. - [ ] **A caller that cannot reach the daemon is refused, legibly.** Silence must not degrade into an unattributed write. [dev-setup](dev-setup.md) documents the recovery path that exists today — `PreToolUse` provisions when a session has no usable account — and whatever this becomes has to keep that working rather than replace it with a new way to fail quietly. ## Boundaries it must not move - [ ] **A subagent is issued for separately, and the socket is where that happens.** [subagents](subagents.md) moved the custody boundary from the session to the context, and this component is what makes that reachable: session-scoped environment cannot narrow below a session, and a socket can. What must not move is the rest of this section. - [ ] **Nothing the daemon accepts from a hook may widen anything.** A hook-asserted fact is a claim about accounts this server minted, checkable against what it issued, and never taken on its word — [agent-accounts](agent-accounts.md)'s rule, which a component sitting between the hook and the server is well placed to erode. ## A layer this is not - [ ] **Cloud instance identity attests a different machine.** The AWS EC2 instance-identity backend in [attestation](attestation.md) proves things about the cloud instance it runs on. Agents run on laptops and workstations, which have no instance identity document, so it is not the mechanism for the hosts this deployment has. It is built and dormant, and becomes relevant the day agents run in cloud sandboxes, which [account-types](account-types.md) contemplates. The node credential is the mechanism for the hosts that exist now. ## Done - [x] **Whatever key material this process holds, it is the only writer of.** The design this replaces documented a lost-update race it accepted, because the cost was one unstamped agent. On private keys the cost is a leaked or twice-issued one. `crates/didbot-agentd/src/node.rs` holds an exclusive lock beside the key for the daemon's life, and the identity the key is reserved under is written by the same process. - [x] **New crate, not a role something already running grows.** There is no daemon on an agent host to grow: `didbot-setup` is one-shot and deliberately read-only, `didbot-claim` and `didbot-verify` are one-shot commands, `didbot-swarm` is a load generator, and `didbot-pds` is the server `docs/deployment.md` keeps off a laptop on purpose. Growing the server would put credential issuance inside the process holding every signing key; growing the doctor would make the thing you run to ask "why is this not working" into an issuer. So `didbot-agentd`, carrying the daemon and the clients that reach it. This clears the bar this epic set — it is not a second daemon on an agent host, it is the first.