diff --git a/crates/didbot-pds/src/admission.rs b/crates/didbot-pds/src/admission.rs index cb7ac935..8b07f3f1 100644 --- a/crates/didbot-pds/src/admission.rs +++ b/crates/didbot-pds/src/admission.rs @@ -7,11 +7,12 @@ //! current key — the same record the ownership poll fetches from the //! operator's repository for the server itself, read here from a repository //! this deployment holds, because a parent is a [`HostedDid`]. An -//! **attested** edge is evidence a backend verified, matched against the -//! parent's [`Membership`] rule: an instance identity document proving a -//! host belongs to the pool a platform principal stands for. Which kind an -//! edge is, the parent decides — a parent with a membership rule admits by -//! attestation, every other parent vouches. +//! **attested** edge is evidence a backend verified, matched against what +//! the parent admits: an instance identity document proving a host belongs +//! to the pool a platform principal's [`Membership`] stands for, or a claim +//! signed with the node key a host holds, which is that host's +//! `NodeMembership`. Which kind an edge is, the parent decides — a parent +//! with a membership admits by attestation, every other parent vouches. //! //! [`Registry::admit`](crate::Registry::admit) walks a claim's chain //! one edge at a time until it reaches a root, and refuses at the first edge @@ -79,6 +80,46 @@ impl Membership { } } +/// What a host holding a node key admits beneath it: only what that key +/// signed for, which the backend records as the host's own DID. +/// +/// A platform's [`Membership`] is a pool; a host's is one node, itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NodeMembership { + backend: String, + node_id: String, +} + +impl NodeMembership { + /// The membership of the host `node_id`, trusted through `backend`. + pub(crate) fn new(backend: impl Into, node_id: impl Into) -> Self { + Self { + backend: backend.into(), + node_id: node_id.into(), + } + } + + /// Why `provenance` is not this host's own word, or `None` when it is. + /// + /// The backend is checked before the node, so a refusal for the wrong + /// backend never names a host the backend was not trusted for. + pub(crate) fn refuses(&self, provenance: &Provenance) -> Option { + if provenance.backend != self.backend { + return Some(format!( + "attested by backend {:?}, and the host admits {:?}", + provenance.backend, self.backend + )); + } + if provenance.node_id != self.node_id { + return Some(format!( + "node {:?} is not the host {:?} whose key signed for it", + provenance.node_id, self.node_id + )); + } + None + } +} + /// What this server trusts when it walks a chain. /// /// The server's own account is always a root; [`Trust::with_root`] adds @@ -294,4 +335,24 @@ mod tests { .expect("refused"); assert!(wrong_pool.contains("pool"), "{wrong_pool}"); } + + /// A host admits only what its own key signed for: another backend's + /// verdict naming the host is refused on the backend, and this backend's + /// verdict naming any other node is refused on the node. + #[test] + fn a_host_membership_admits_its_own_key_alone() { + let host = NodeMembership::new("node-credential", "did:web:host.example"); + assert_eq!( + host.refuses(&provenance("node-credential", "did:web:host.example")), + None + ); + let wrong_backend = host + .refuses(&provenance("aws-instance-identity", "did:web:host.example")) + .expect("refused"); + assert!(wrong_backend.contains("backend"), "{wrong_backend}"); + let other_node = host + .refuses(&provenance("node-credential", "did:web:host.example.evil")) + .expect("refused"); + assert!(other_node.contains("not the host"), "{other_node}"); + } } diff --git a/crates/didbot-pds/src/ownership.rs b/crates/didbot-pds/src/ownership.rs index d93c7a7c..4f68edb5 100644 --- a/crates/didbot-pds/src/ownership.rs +++ b/crates/didbot-pds/src/ownership.rs @@ -95,6 +95,34 @@ impl OperatorClaim { } } +impl OperatorClaim { + /// Renders the claim as the record body `bot.did.operator` holds — + /// the inverse of [`OperatorClaim::from_json`], field for field, so the + /// one writer this crate has (a mirrored vouch) and the one reader agree + /// by construction. + /// + /// `Rfc3339` refuses only a year outside `0000..=9999`, which no + /// timestamp read by `from_json` or taken from a clock can carry; the + /// epoch stands in rather than failing a write over an impossible date. + pub fn to_json(&self) -> serde_json::Value { + let render = |at: OffsetDateTime| { + at.to_offset(time::UtcOffset::UTC) + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) + }; + let mut value = serde_json::json!({ + "$type": didbot_lexicon::nsid::OPERATOR, + "subject": self.subject, + "subjectKey": self.subject_key, + "createdAt": render(self.created_at), + }); + if let Some(expires_at) = self.expires_at { + value["expiresAt"] = serde_json::Value::String(render(expires_at)); + } + value + } +} + /// Reads one `bot.did.operator` record out of one repository. /// /// A trait, and a synchronous one, so this crate — which holds no HTTP diff --git a/crates/didbot-pds/src/provision.rs b/crates/didbot-pds/src/provision.rs index 98a75cca..2023bbce 100644 --- a/crates/didbot-pds/src/provision.rs +++ b/crates/didbot-pds/src/provision.rs @@ -4,7 +4,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use didbot_attest::{Assurance, Provenance}; +use didbot_attest::{ + Assurance, AttestationBackend, AttestationClaim, NodeCredentialBackend, Provenance, +}; use didbot_dns::{DnsError, DnsProvider, RecordTarget}; use didbot_identity::{ hostname_is_at_or_below, validate_handle, AgentDid, DidDocument, DidError, HandleError, Zone, @@ -14,7 +16,9 @@ use didbot_key::{SigningKey, VerifyingKey}; use time::OffsetDateTime; use crate::account::{AccountState, AccountStore, AgentAccount, StoreError}; -use crate::admission::{AdmissionClaim, AdmissionError, EdgeKind, Lineage, Trust, VerifiedEdge}; +use crate::admission::{ + AdmissionClaim, AdmissionError, EdgeKind, Lineage, NodeMembership, Trust, VerifiedEdge, +}; use crate::blobs::{ BlobError, BlobLimits, BlobStats, BlobStore, BlobUpload, Fetch, MemoryBlobStore, }; @@ -61,6 +65,12 @@ pub struct ProvisionRequest { pub agent_id: String, /// An atproto handle to claim in the DID document, if the caller has one. pub handle: Option, + /// A host's signed word that this context runs on it, when a host is + /// asking. Verified against the key that host presented when it was + /// reserved, and the host becomes the account's parent — see + /// `Provisioner::node_backend`. Absent, the account is admitted under + /// no check, as `UNAUTHENTICATED_BACKEND` records. + pub attestation: Option, /// What the harness says about the agent this account is for. /// /// Written into the account's `bot.did.registration` as @@ -76,6 +86,7 @@ impl ProvisionRequest { Self { agent_id: agent_id.into(), handle, + attestation: None, profile: RegistrationFacts::default(), } } @@ -86,6 +97,13 @@ impl ProvisionRequest { self.profile = profile; self } + + /// Attaches a host's attestation of the context. + #[must_use] + pub fn with_attestation(mut self, attestation: AttestationClaim) -> Self { + self.attestation = Some(attestation); + self + } } /// The facts about an agent that only its harness can answer. @@ -619,6 +637,21 @@ pub enum ProvisionError { /// The DID that was named. did: String, }, + /// A host's attestation of a context did not verify against the key + /// that host presented, or names a host this deployment holds no key + /// for. Nothing was minted. + #[error("attestation refused: {0}")] + Attestation(#[from] didbot_attest::AttestError), + /// The host that signed the attestation and the parent the registration + /// names are two different accounts. The signature is the fact; the + /// registration is the harness's word, and the two have to agree. + #[error("the attestation is signed by {attested} but the registration names {registered} as the parent")] + ParentDisagrees { + /// The node the attestation names and its signature proves. + attested: String, + /// The parent the registration claimed. + registered: String, + }, } impl From for ProvisionError { @@ -1121,12 +1154,18 @@ pub trait Registry: Send + Sync { /// expired one is left out even before the sweep has taken it. fn reservations(&self) -> Vec; - /// Finishes provisioning a reservation `owner` has vouched for, moving - /// it to [`AccountState::Active`] and announcing it. The caller — the - /// ownership poll — has already matched the vouch against the DID and - /// the host's key; this is the write side of that decision, the same - /// division [`Registry::confirm_ownership`] draws. - fn admit_reservation(&self, did: &str, owner: &str) -> Result; + /// Finishes provisioning a reservation `owner` has vouched for with + /// `vouch`, moving it to [`AccountState::Active`] under the server's own + /// account and announcing it. The caller — the ownership poll — has + /// already matched the vouch against the DID and the host's key; the + /// edge is verified again by [`Registry::admit`] from the mirrored + /// record, so the tree holds nothing this deployment cannot re-read. + fn admit_reservation( + &self, + did: &str, + owner: &str, + vouch: &OperatorClaim, + ) -> Result; /// The account's own erasure: the repository, its history, its blobs /// and its credentials go, the name is burned, and the account ends @@ -1730,6 +1769,16 @@ pub struct Provisioner { naming: Option>, /// How long a reserved identity waits for its vouch, and how many may. reservations: ReservationPolicy, + /// The keys of every host this deployment has admitted, by the host's + /// DID, and the nonces their claims have spent. + /// + /// The verifier for a context's attestation — see + /// [`ProvisionRequest::attestation`]. A host's key enters at + /// [`Registry::admit_reservation`] and at construction for hosts already + /// active, so the allowlist *is* the set of hosts that stand: whether a + /// host still stands is [`Registry::admit`]'s walk to decide, and this + /// only says whether the signature is that host's. + node_backend: Mutex, /// The DID of the party accountable for every account this server mints. /// /// Deployment-wide, and not optional: an account nobody is answerable for @@ -1802,7 +1851,14 @@ where dns: D, store: S, ) -> Self { - let hosts = HostIndex::from_accounts(&store.list()); + let accounts = store.list(); + let hosts = HostIndex::from_accounts(&accounts); + let node_backend = Mutex::new(NodeCredentialBackend::new( + accounts + .iter() + .filter(|account| account.state == AccountState::Active) + .filter_map(node_credential), + )); Self { owner: owner.into(), zone: zone.clone(), @@ -1829,6 +1885,7 @@ where avatar_style: None, naming: None, reservations: ReservationPolicy::default(), + node_backend, zones: None, router: None, hosted: ZoneRegistry::single(zone), @@ -3511,9 +3568,9 @@ where bound, }); } - let kind = match self.trust.platform(&parent_did) { - Some(_) => EdgeKind::Attested, - None => EdgeKind::Vouched, + let kind = match self.lookup(&parent_did) { + Ok(parent) if self.admits_by_attestation(&parent) => EdgeKind::Attested, + _ => EdgeKind::Vouched, }; let unverifiable = |reason: String| { tracing::info!( @@ -3549,10 +3606,7 @@ where let refusal = match kind { EdgeKind::Attested => match child_provenance.as_ref() { None => Some("no attestation was presented".to_owned()), - Some(provenance) => self - .trust - .platform(&parent_did) - .and_then(|rule| rule.refuses(provenance)), + Some(provenance) => self.attestation_refusal(&parent, provenance), }, EdgeKind::Vouched => self.vouch_refusal(&parent, child_account, now), }; @@ -3578,6 +3632,66 @@ where } } + /// Adds `host`'s key to the attestation verifier, once the host stands. + fn register_node(&self, host: &AgentAccount) { + let Some((node_id, key)) = node_credential(host) else { + return; + }; + let mut backend = self + .node_backend + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let registered = std::mem::replace( + &mut *backend, + NodeCredentialBackend::new(std::iter::empty::<(String, VerifyingKey)>()), + ) + .with_node(node_id, key); + *backend = registered; + tracing::info!(host = host.did.as_str(), "registered the host's node key"); + } + + /// Verifies a context's attestation against the host it names. + fn attest(&self, claim: &AttestationClaim) -> Result { + let backend = self + .node_backend + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + Ok(backend.attest(claim)?) + } + + /// Whether `parent` admits by attestation rather than by vouching. + /// + /// A platform principal does, by its [`Membership`] in [`Trust`]. A host + /// does by holding a key: what it admits is what its own key signed for. + /// Every other parent vouches, with a record in its repository. + /// + /// [`Membership`]: crate::admission::Membership + fn admits_by_attestation(&self, parent: &AgentAccount) -> bool { + self.trust.platform(parent.did.as_str()).is_some() || parent.node_key.is_some() + } + + /// Why `provenance` is outside what `parent` admits, or `None` when it + /// is inside; see [`Self::admits_by_attestation`] for what admits. + fn attestation_refusal( + &self, + parent: &AgentAccount, + provenance: &Provenance, + ) -> Option { + if let Some(pool) = self.trust.platform(parent.did.as_str()) { + return pool.refuses(provenance); + } + if parent.node_key.is_none() { + return Some("the parent admits nothing by attestation".to_owned()); + } + let backend = self + .node_backend + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .name() + .to_owned(); + NodeMembership::new(backend, parent.did.as_str()).refuses(provenance) + } + /// Why the `bot.did.operator` record in `parent`'s repository does not /// vouch for `child` right now, or `None` when it does. /// @@ -3612,7 +3726,13 @@ where let Some(key) = self.store.verifying_key(&child.did) else { return Some("the child holds no signing key".to_owned()); }; - if vouch.subject_key != key.to_multibase() { + // A host holds a key of its own beside the signing key this + // deployment minted for its repository — `AgentAccount::node_key`, + // the document's `#node` entry — and a vouch for a host names that + // one, because it is what the host can prove possession of. + let held = vouch.subject_key == key.to_multibase() + || child.node_key.as_deref() == Some(vouch.subject_key.as_str()); + if !held { return Some("the parent's record names a key the child does not hold".to_owned()); } if let Some(expires) = vouch.expires_at.filter(|expires| *expires <= now) { @@ -4374,7 +4494,7 @@ where return true; } lock == Lock::Quarantined - && self.trust.platform(parent_did.as_str()).is_none() + && !self.admits_by_attestation(&parent) && match self.vouch_refusal(&parent, account, now) { Some(reason) => { tracing::debug!( @@ -4718,6 +4838,19 @@ fn trimmed(value: Option<&str>) -> Option { /// field. See [`Provisioner`]. pub const UNAUTHENTICATED_BACKEND: &str = "unauthenticated"; +/// The credential a host's account registers with the attestation +/// verifier: its DID, and the key it presented when it was reserved. +/// +/// `None` for an account holding no node key, and for one whose key is on +/// a curve the verifier does not sign with: `didbot_attest`'s node +/// credential is secp256k1, and a host that presented a P-256 key has its +/// key in the document for an owner to bind and nothing here to verify a +/// context against. +fn node_credential(account: &AgentAccount) -> Option<(String, VerifyingKey)> { + let key = VerifyingKey::from_multibase(account.node_key.as_deref()?).ok()?; + Some((account.did.as_str().to_owned(), key)) +} + /// The kebab-case label for an assurance level. /// /// Matched rather than derived from serde so that the strings a log reader @@ -4777,13 +4910,48 @@ where // `Assurance::SelfAsserted` are what the account's registration // record will say forever, so a reader can tell an account admitted // under no check from one admitted under a check that later exists. - let provenance = Provenance::new( - UNAUTHENTICATED_BACKEND, - String::new(), - time::OffsetDateTime::now_utc(), - Assurance::SelfAsserted, - None, - ); + // + // A host's attestation is the one check this path runs: the claim + // is verified against the key the host presented at its reservation, + // and the host becomes the parent. Refused here, before a name is + // taken, because a signature that does not verify is a request that + // should cost nothing. + let (provenance, parent) = match &request.attestation { + None => ( + Provenance::new( + UNAUTHENTICATED_BACKEND, + String::new(), + time::OffsetDateTime::now_utc(), + Assurance::SelfAsserted, + None, + ), + None, + ), + Some(claim) => { + if let Some(registered) = request + .profile + .parent + .as_deref() + .filter(|registered| *registered != claim.node_id) + { + tracing::info!( + attested = %claim.node_id, + registered, + "refused: the attestation and the registration name different parents" + ); + return Err(ProvisionError::ParentDisagrees { + attested: claim.node_id.clone(), + registered: registered.to_owned(), + }); + } + let provenance = self.attest(claim).inspect_err(|error| { + tracing::info!(%error, node = %claim.node_id, "attestation refused; provisioning nothing"); + })?; + let host = self.hosted(AgentDid::parse(&claim.node_id)?)?; + tracing::info!(host = host.as_str(), "the context is attested by its host"); + (provenance, Some(host)) + } + }; // Which zone this agent lands in — server configuration only, keyed // on the same admission facts as everything else here; see @@ -4989,7 +5157,7 @@ where // principal: an account it makes is a root the way the accounts // before parents existed are. `Registry::admit` is what puts an // account under one. - parent: None, + parent: parent.clone(), state: AccountState::Reserved, locks: crate::lockout::Locks::none(), holds: crate::lockout::Holds::none(), @@ -5051,6 +5219,22 @@ where self.note_event(did.as_str(), LedgerEvent::Deprovisioned { operator: None }); }; + // 8a. An attested context is admitted now, while the row is + // `Reserved` and nothing has been published: the walk from its host + // up to the root is what says the host still stands, and a chain + // that does not reach one is refused before it costs a hostname. + if let Some(host) = &parent { + if let Err(error) = self.admit(&AdmissionClaim { + subject: did.as_str().to_owned(), + parent: host.as_str().to_owned(), + provenance: account.provenance.clone(), + }) { + tracing::info!(%error, host = host.as_str(), "admission refused; provisioning nothing"); + unwind(&[]); + return Err(error); + } + } + // 9. Publish DNS. First externally visible effect. if let Err(error) = self.dns.publish(did.host(), &self.target) { tracing::info!(host = did.host(), %error, "dns publish failed; nothing was provisioned"); @@ -5356,14 +5540,31 @@ where /// Finishes a reservation, or leaves it waiting. /// + /// `vouch` is the owner's `bot.did.operator` record as the poll read it + /// out of the owner's own repository, already matched against the + /// reservation's DID and the key the host presented. This deployment's + /// tree of principals reads a vouched edge from the *parent's* + /// repository here — see [`crate::admission`] — and the owner is not an + /// account this deployment holds; the server's own account stands for + /// the owner at the root. So the record is mirrored into the server's + /// repository, keyed by the host's hostname exactly as the owner keyed + /// it, and [`Registry::admit`] then verifies the edge from there the + /// way it verifies every other, records the parent, and confines the + /// host under whatever hangs on the server. + /// /// The retention moves first and moves back on failure, so a reservation /// this could not complete is still one the sweep can reap rather than - /// one held forever in a state nothing serves from. The transition is - /// then `Self::complete`, the same steps provisioning takes, with the - /// owner recorded on the ledger's own state change: that entry is what - /// says who admitted the account, since nothing checked the host itself. - #[tracing::instrument(name = "admit_reservation", skip(self), fields(did = %did, owner = %owner))] - fn admit_reservation(&self, did: &str, owner: &str) -> Result { + /// one held forever in a state nothing serves from. The rest is what + /// provisioning does, with the owner recorded on the ledger's own state + /// change: that entry is what says who vouched, since nothing checked + /// the host itself. + #[tracing::instrument(name = "admit_reservation", skip(self, vouch), fields(did = %did, owner = %owner))] + fn admit_reservation( + &self, + did: &str, + owner: &str, + vouch: &OperatorClaim, + ) -> Result { let account = self.lookup(did)?; if !account.is_reservation() { tracing::info!(state = ?account.state, "refused: not a pending reservation"); @@ -5382,27 +5583,43 @@ where }); } + let server = self.zone.service_did(); + self.put_record_as( + &server, + didbot_lexicon::nsid::OPERATOR, + Some(account.did.host()), + vouch.to_json(), + &Swap::default(), + WriteAuthor::Server { defer: false }, + )?; + tracing::info!( + host = account.did.host(), + "mirrored the owner's vouch into the server's repository" + ); + self.admit(&AdmissionClaim { + subject: did.to_owned(), + parent: server, + provenance: None, + })?; + let window = account.retention; let retention = account.kind.retention(); self.store.set_retention(&account.did, retention)?; let mut account = account; account.retention = retention; - let active = match self - .land_initial_records(&account) - .and_then(|deferred| self.activate(account.clone(), deferred)) - { - Ok(active) => active, - Err(error) => { - tracing::error!(%error, "could not complete the reservation; it keeps waiting"); - if let Err(revert) = self.store.set_retention(&account.did, window) { - tracing::error!( - %revert, - "could not restore the reservation's window; it will not be reaped" - ); - } - return Err(error); + let revert = |error: ProvisionError| { + tracing::error!(%error, "could not complete the reservation; it keeps waiting"); + if let Err(revert) = self.store.set_retention(&account.did, window) { + tracing::error!( + %revert, + "could not restore the reservation's window; it will not be reaped" + ); } + error }; + let deferred = self.land_initial_records(&account).map_err(revert)?; + let active = self.activate(account.clone(), deferred).map_err(revert)?; + self.register_node(&active); self.note_event( active.did.as_str(), LedgerEvent::StateChanged { diff --git a/crates/didbot-serve/src/bin/didbot-pds.rs b/crates/didbot-serve/src/bin/didbot-pds.rs index 85f7f1e7..5bc9b473 100644 --- a/crates/didbot-serve/src/bin/didbot-pds.rs +++ b/crates/didbot-serve/src/bin/didbot-pds.rs @@ -2372,6 +2372,7 @@ async fn demo(registry: Arc, zone_host: String, count: usize, name let agent_id = demo_agent_id(step); let request = ProvisionRequest { agent_id: agent_id.clone(), + attestation: None, // Nothing is asked for when the deployment issues names: the // demo exists to show what a run does, and a run that names its // agents should be seen naming them rather than honouring a diff --git a/crates/didbot-serve/src/error.rs b/crates/didbot-serve/src/error.rs index be8cc0e2..3c2e1840 100644 --- a/crates/didbot-serve/src/error.rs +++ b/crates/didbot-serve/src/error.rs @@ -494,6 +494,11 @@ impl From<&ProvisionError> for ApiError { ProvisionError::ReservationExpired { .. } => { (StatusCode::CONFLICT, "ReservationExpired") } + // 403: the signature did not verify, or names a host this + // deployment holds no key for; which of the two is in the + // message, not the name. + ProvisionError::Attestation(..) => (StatusCode::FORBIDDEN, "AttestationRefused"), + ProvisionError::ParentDisagrees { .. } => (StatusCode::BAD_REQUEST, "ParentDisagrees"), }; Self::new(status, name, err.to_string()) } diff --git a/crates/didbot-serve/src/ownership_poll.rs b/crates/didbot-serve/src/ownership_poll.rs index a056acee..5680aa34 100644 --- a/crates/didbot-serve/src/ownership_poll.rs +++ b/crates/didbot-serve/src/ownership_poll.rs @@ -706,7 +706,7 @@ pub async fn admit_vouched_reservations( ); continue; } - match registry.admit_reservation(did, operator_did) { + match registry.admit_reservation(did, operator_did, &claim) { Ok(account) => { info!( did, diff --git a/crates/didbot-serve/src/tests.rs b/crates/didbot-serve/src/tests.rs index ea00432a..50e39c31 100644 --- a/crates/didbot-serve/src/tests.rs +++ b/crates/didbot-serve/src/tests.rs @@ -604,7 +604,12 @@ impl Registry for FakeRegistry { .collect() } - fn admit_reservation(&self, did: &str, _owner: &str) -> Result { + fn admit_reservation( + &self, + did: &str, + _owner: &str, + _vouch: &didbot_pds::OperatorClaim, + ) -> Result { let mut accounts = self.accounts.lock().expect("poisoned"); let account = accounts .iter_mut() diff --git a/crates/didbot-serve/src/wire.rs b/crates/didbot-serve/src/wire.rs index ac07a1f8..27e2cde5 100644 --- a/crates/didbot-serve/src/wire.rs +++ b/crates/didbot-serve/src/wire.rs @@ -17,6 +17,7 @@ use serde_json::Value; use time::format_description::well_known::Rfc3339; use crate::error::ApiError; +use didbot_attest::AttestationClaim; /// Body of `bot.did.provisionAgent`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -38,6 +39,11 @@ pub struct ProvisionAgentRequest { /// should not have to invent it. #[serde(default, skip_serializing_if = "Option::is_none")] pub registration: Option, + /// The host's signed word that this context runs on it, when a host's + /// daemon is asking: the same type the daemon signs, so the two sides + /// read one definition. See [`didbot_pds::ProvisionRequest::attestation`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attestation: Option, } /// The harness's account of an agent, as it appears on the wire. @@ -77,6 +83,7 @@ impl ProvisionAgentRequest { Ok(ProvisionRequest { agent_id: self.agent_id, handle: self.handle, + attestation: self.attestation, profile: self.registration.unwrap_or_default().into_facts(), }) } diff --git a/crates/didbot/tests/reservation.rs b/crates/didbot/tests/reservation.rs index 24c47a64..17f56e30 100644 --- a/crates/didbot/tests/reservation.rs +++ b/crates/didbot/tests/reservation.rs @@ -560,13 +560,18 @@ async fn post_json(app: &Router, uri: &str, body: Value) -> (StatusCode, Value) struct HostKey { did_key: String, multibase: String, + /// The private half, which only the host holds: what it signs a + /// context's attestation with. + signing: SigningKey, } fn host_key() -> HostKey { - let public = SigningKey::generate().verifying_key(); + let signing = SigningKey::generate(); + let public = signing.verifying_key(); HostKey { did_key: public.to_did_key(), multibase: public.to_multibase(), + signing, } } @@ -985,6 +990,44 @@ async fn an_owners_vouch_moves_a_reservation_to_active_exactly_once() { assert!(!account.is_reservation()); assert!(owned.booted.registry.reservations().is_empty()); assert_eq!(owned.activations(&did), 1); + + // Admitted under the server's own account, which stands for the owner + // at the root of the tree, with the owner's record mirrored into the + // server's repository so the edge is one this deployment can re-read. + let server = owned.booted.registry.service_did(); + assert_eq!( + account + .parent + .as_ref() + .map(|parent| parent.as_str().to_owned()), + Some(server.clone()), + "the host's parent is the server's account" + ); + let admitted = owned + .booted + .registry + .ledger(&did) + .expect("a ledger") + .entries + .iter() + .find_map(|entry| match &entry.event { + LedgerEvent::Admitted { parent, root, .. } => Some((parent.clone(), root.clone())), + _ => None, + }) + .expect("the ledger records the admission"); + assert_eq!(admitted, (server.clone(), server.clone())); + let (status, mirrored) = get_json( + app, + &format!( + "/xrpc/com.atproto.repo.getRecord?repo={server}&collection={}&rkey={}", + didbot::nsid::OPERATOR, + rkey_of(&did) + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{mirrored}"); + assert_eq!(mirrored["value"]["subject"], did); + assert_eq!(mirrored["value"]["subjectKey"], key.multibase); let admitted_by = owned .booted .registry @@ -1242,3 +1285,136 @@ async fn the_claim_command_vouches_for_a_reserved_host_by_name() { ); assert_eq!(owned.activations(&did), 1); } + +// --------------------------------------------------------------------------- +// What an admitted host can do +// --------------------------------------------------------------------------- + +/// A host attests the contexts it spawns with the key it presented, and the +/// server admits each one beneath it: the daemon's provisioning body, the +/// same claim type, verified against the reservation's key and walked up +/// through the mirrored vouch to the root. +/// +/// Before the owner vouches, the host's key is nothing the server verifies +/// against, so its attestations are refused with nothing minted; a claim +/// another key signed, a claim presented twice, and a registration naming a +/// different parent are refused the same way afterwards. +#[tokio::test(flavor = "multi_thread")] +async fn an_admitted_host_attests_the_contexts_it_spawns() { + let owned = boot_owned(&["mossy-vole", "quern-one", "quern-two"]).await; + let app = &owned.booted.app; + let key = host_key(); + let (status, body) = reserve(app, &key).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let host = body["did"].as_str().expect("a did").to_owned(); + + let claim = |nonce: &str| { + didbot::attest::NodeCredentialBackend::sign_claim( + &key.signing, + host.clone(), + nonce, + OffsetDateTime::now_utc(), + ) + .expect("the host signs") + }; + let provision = |agent_id: &str, parent: &str, claim: didbot::attest::AttestationClaim| { + post_json( + app, + "/xrpc/bot.did.provisionAgent", + json!({ + "agentId": agent_id, + "registration": { "harness": "tests", "agentType": "subagent", "parent": parent }, + "attestation": claim, + }), + ) + }; + + // A reserved host stands for nothing yet. + let (status, body) = provision("ctx-0", &host, claim("n0")).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(body["error"], "AttestationRefused"); + assert_eq!( + owned.booted.registry.accounts().len(), + 1, + "nothing was minted for a context of an unadmitted host" + ); + + owned.pds.vouch(&rkey_of(&host), &host, &key.multibase); + owned.poll().await; + assert_eq!( + owned.booted.registry.account(&host).expect("stored").state, + AccountState::Active + ); + + let first = claim("n1"); + let (status, body) = provision("ctx-1", &host, first.clone()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let context = body["did"].as_str().expect("a did").to_owned(); + let account = owned.booted.registry.account(&context).expect("stored"); + assert_eq!(account.state, AccountState::Active); + assert_eq!( + account + .parent + .as_ref() + .map(|parent| parent.as_str().to_owned()), + Some(host.clone()), + "the context is admitted under the host that attested it" + ); + // Built from the claim, so the parent is in the entry that inserted the + // account rather than in a later `Admitted` one — see + // `didbot_pds::AgentAccount::parent`. + let recorded = owned + .booted + .registry + .ledger(&context) + .expect("a ledger") + .entries + .iter() + .find_map(|entry| match &entry.event { + LedgerEvent::Provisioned { parent, .. } => Some(parent.clone()), + _ => None, + }) + .expect("the ledger records the provisioning"); + assert_eq!( + recorded, + Some(host.clone()), + "the ledger names the host as parent" + ); + + // The same claim again: its nonce is spent. + let (status, body) = provision("ctx-2", &host, first).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(body["error"], "AttestationRefused"); + + // Another key naming this host. + let forged = didbot::attest::NodeCredentialBackend::sign_claim( + &SigningKey::generate(), + host.clone(), + "n2", + OffsetDateTime::now_utc(), + ) + .expect("signs"); + let (status, body) = provision("ctx-2", &host, forged).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(body["error"], "AttestationRefused"); + + // A registration whose parent is not the host that signed. + let (status, body) = + provision("ctx-2", "did:web:elsewhere.agents.localhost", claim("n3")).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(body["error"], "ParentDisagrees"); + + assert_eq!( + owned.booted.registry.accounts().len(), + 2, + "the host and one context; every refusal minted nothing" + ); + assert!( + owned + .booted + .naming + .registry() + .is_free("quern-two", OffsetDateTime::now_utc()), + "a refused context takes no name" + ); +}