--- 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: [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.** - [ ] **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. ## The local transport is a privilege boundary - [ ] **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). - [ ] **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 - [ ] **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] **One device credential per node**, with the file treated as seriously as the server treats its signing keys. `crates/didbot-agentd/src/node.rs` mints one secp256k1 key on first start, in a `0700` directory as a `0600` file, under a lock held for the daemon's life, and hands out only the public half and signatures. The server's [`NodeCredentialBackend`](../crates/didbot-attest/src/node_credential.rs) keeps only the public half, registered once the host stands. - [x] **Say what it is not.** `crates/didbot-attest/src/node_credential.rs` and `node.rs`'s module doc both say the key is a file: anything that can read the disk can copy it, and the copy is indistinguishable from the original. - [x] **The socket carries e-stop's hardening, all four parts.** `crates/didbot-agentd/src/socket.rs`: a subdirectory this process owns under `XDG_RUNTIME_DIR` or the temporary directory; `0700` set on it on every start, which fails when another user owns it; `0600` on the socket after bind; and `remove_if_stale`, which unlinks only on `ECONNREFUSED`. - [x] **A peer credential check, which e-stop did not need.** `Listener::accept` reads the peer's credentials off the socket and drops a connection from any user other than the socket's owner, or one whose credentials cannot be read. - [x] **Nothing is prepared in advance.** The daemon's one call to `Registrar::provision` is in `provision_context` (`crates/didbot-agentd/src/serve.rs`), reached only when a report names a context with no identity. Reports for one context mint it once, whether they arrive in turn (`a_context_is_minted_once_and_told_once`) or together (`two_reports_for_one_new_context_at_once_mint_it_once`). - [x] **A subagent is issued for separately.** A context is keyed by session and subagent id (`crates/didbot-agentd/src/context.rs`), and a session and its two subagents get three identities (`a_session_and_its_subagents_get_their_own_identities`). - [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.