From 129f7d512d33019c8f9561642117b829b473b00a Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sun, 30 Aug 2026 00:22:40 -0400 Subject: [PATCH] test(agent-accounts): pin the sibling-zone containment refusal and add spec conformance Zone::delegated already refuses a zone that is not at or below the PDS hostname, at construction rather than at first mint, but nothing named the epic's own example: a server at pds.example.com asking for agents.example.com, siblings under example.com rather than one containing the other. Also adds a conformance suite validating the did:web documents this crate builds directly against the did:web method specification and the atproto identity specification, since neither the vendored interop vectors nor vectors/ carry a suite for document shape or handle resolution. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I8ff8291532d93009888c8f496748aaca021e4b0a --- crates/didbot-identity/tests/identity.rs | 25 +++ .../didbot-identity/tests/spec_conformance.rs | 142 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 crates/didbot-identity/tests/spec_conformance.rs diff --git a/crates/didbot-identity/tests/identity.rs b/crates/didbot-identity/tests/identity.rs index bc0d9180..bcf6172f 100644 --- a/crates/didbot-identity/tests/identity.rs +++ b/crates/didbot-identity/tests/identity.rs @@ -464,6 +464,31 @@ fn a_pds_may_not_delegate_an_unrelated_zone() { )); } +/// The epic's own example of the layout that must be refused: a server at +/// `pds.example.com` cannot mint agents under `agents.example.com`, because +/// the two are siblings under `example.com` rather than one containing the +/// other. Apex-plus-subzone is the only shape that satisfies both "at or +/// below the PDS" and "agents off the apex", and this is what rules out the +/// nearby-looking wrong answer. +/// +/// This has to fail at [`Zone::delegated`], which runs at startup when a +/// deployment is configured — not at the first `AgentDid::mint`, by which +/// point a bad zone would already have minted and published a hostname. +#[test] +fn a_server_may_not_delegate_a_sibling_of_its_own_hostname() { + let err = Zone::delegated("pds.example.com", "agents.example.com") + .expect_err("a sibling zone must be refused at construction, not at first mint"); + assert!( + matches!(err, DidError::ZoneEscapesPds { .. }), + "expected ZoneEscapesPds, got {err}" + ); + // The message has to name both hostnames, because there is nothing else + // in a startup failure for an operator to compare against their config. + let rendered = err.to_string(); + assert!(rendered.contains("pds.example.com"), "{rendered}"); + assert!(rendered.contains("agents.example.com"), "{rendered}"); +} + #[test] fn containment_respects_label_boundaries() { // Suffix collision: "evilfoo" and "foo" are different labels, so a naive diff --git a/crates/didbot-identity/tests/spec_conformance.rs b/crates/didbot-identity/tests/spec_conformance.rs new file mode 100644 index 00000000..5babf9e4 --- /dev/null +++ b/crates/didbot-identity/tests/spec_conformance.rs @@ -0,0 +1,142 @@ +//! Third-party conformance: does what this crate emits match the specs it +//! claims to implement? +//! +//! # There are no vendored vectors for this +//! +//! `crates/didbot/tests/conformance/mst_vectors.rs` and its neighbours drive +//! vendored suites — `scripts/refresh-interop-vectors.sh` pulls +//! `syntax`, `lexicon`, `crypto`, `data-model`, `mst` and `firehose` from +//! `bluesky-social/atproto-interop-tests`, and `scripts/refresh-atproto-lexicons.sh` +//! pulls the lexicon schemas. Neither the upstream repository nor `vectors/` +//! in this one carries a suite for `did:web` document shape or for atproto's +//! handle-resolution rule: the closest upstream directory is `syntax`, and it +//! tests handle and DID *string* syntax, not document shape or the +//! bidirectional check. There is nothing to vendor and wire in here, so this +//! file says that explicitly and validates directly against the +//! specification text instead, which is the same text +//! `crates/didbot-identity/src/did.rs` and `document.rs` already quote in +//! their doc comments — this file is the assertions that back those quotes. +//! +//! Two documents, both normative for this crate: +//! +//! - The `did:web` method specification (W3C CCG), for the identifier's +//! transform from a hostname and for the document's `id`. +//! - The atproto identity specification, for the account document's required +//! fields and for bidirectional handle/DID verification. +//! +//! Every test below names the clause it checks. + +use didbot_identity::document::{ATPROTO_KEY_FRAGMENT, ATPROTO_PDS_FRAGMENT, MULTIKEY_TYPE}; +use didbot_identity::{AgentDid, DidDocument, Zone}; + +const KEY: &str = "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF"; + +fn zone() -> Zone { + Zone::delegated("example.com", "agents.example.com").expect("zone is below the pds host") +} + +/// did:web spec: "the domain name of the DID, mapped to an HTTPS URL... The +/// path colon separators are replaced by the URL path separator `/`." An +/// atproto DID never contains such a path — "atproto supports only +/// hostname-level `did:web` DIDs" — so the identifier is exactly +/// `did:web:` with no path segment, and every character between +/// the method and the end of the string decodes straight to that hostname. +#[test] +fn the_identifier_is_the_method_prefix_and_nothing_but_the_hostname() { + let did = AgentDid::mint(&zone(), "scribe").expect("mint succeeds"); + assert_eq!(did.as_str(), "did:web:scribe.agents.example.com"); + assert_eq!(did.host(), "scribe.agents.example.com"); + // No `%3A` in this one at all: a port is a percent-encoded colon and none + // was asked for, so nothing here should ever look like a path or a port. + assert!(!did.as_str().contains('/')); +} + +/// did:web spec: a port on the domain "MUST be percent-encoded", specifically +/// as the colon that separates host from port, because an unencoded colon in +/// that position is the method's own path separator. Checked here against a +/// literal spec-shaped example rather than round-tripped through this +/// crate's own encoder, so a bug shared between minting and parsing could +/// not hide the mismatch from itself. +#[test] +fn a_port_is_carried_as_a_percent_encoded_colon() { + let did = AgentDid::parse("did:web:agents.localhost%3A3000").expect("spec-shaped example"); + assert_eq!(did.host(), "agents.localhost"); + assert_eq!(did.as_str(), "did:web:agents.localhost%3A3000"); +} + +/// atproto identity spec: a `did:web` account document's `id` "must match +/// the DID being resolved". Checked as an equality against the exact DID +/// string a caller asked to resolve, not merely "some non-empty id". +#[test] +fn the_documents_id_is_exactly_the_did_it_was_built_for() { + let did = AgentDid::mint(&zone(), "scribe").expect("mint succeeds"); + let document = DidDocument::for_account( + &did, + KEY, + "scribe.agents.example.com", + "https://pds.example.com", + ); + assert_eq!(document.id, did.as_str()); +} + +/// atproto identity spec: the document "should have a public signing key +/// referenced... with id ending in `#atproto`" and controller equal to the +/// subject DID, so a reader can tell this key belongs to this account without +/// a second lookup. +#[test] +fn the_signing_key_is_fragment_qualified_and_self_controlled() { + let did = AgentDid::mint(&zone(), "scribe").expect("mint succeeds"); + let document = DidDocument::for_account( + &did, + KEY, + "scribe.agents.example.com", + "https://pds.example.com", + ); + let method = &document.verification_method[0]; + assert_eq!(method.id, format!("{}{ATPROTO_KEY_FRAGMENT}", did.as_str())); + assert_eq!(method.controller, did.as_str()); + assert_eq!(method.type_, MULTIKEY_TYPE); + assert_eq!(document.pds_endpoint(), Some("https://pds.example.com")); + assert!(document.service[0].id.ends_with(ATPROTO_PDS_FRAGMENT)); +} + +/// atproto identity spec: identity is confirmed only when handle-to-DID and +/// DID-to-handle resolution "agree" — "bidirectional verification". A +/// document that claims a handle is only half of that; the other half is +/// resolving the handle back to this exact DID, which is a statement about +/// the deployment serving `/.well-known/atproto-did`, not about the document +/// alone, so it is asserted here as the pair the spec describes rather than +/// as a document-only property. +#[test] +fn a_claimed_handle_resolves_back_to_the_same_did() { + let did = AgentDid::mint(&zone(), "scribe").expect("mint succeeds"); + let handle = "scribe.agents.example.com"; + let document = DidDocument::for_account(&did, KEY, handle, "https://pds.example.com"); + + // Direction one: the document claims the handle. + assert!(document.claims_handle(handle)); + // Direction two, stood in for the well-known endpoint by the same + // predicate `didbot-pds::Registry::handle_did` is built from + // (`claimed_handle` in `crates/didbot-pds/src/provision.rs`): the + // account's own claim is what a request to `/.well-known/atproto-did` + // for this hostname would answer. + assert_eq!(document.handle(), Some(handle)); +} + +/// did:web spec: a percent escape "MUST" use the encoding this method +/// defines for a port colon and nothing broader — an escape decoding to a +/// path separator is refused outright, because atproto's hostname-only rule +/// means no `did:web` this crate mints or accepts may resolve to a path. +#[test] +fn an_encoded_path_separator_is_not_a_usable_did() { + use didbot_identity::did::DidError; + let err = AgentDid::parse("did:web:example.com%2Fpath") + .expect_err("a did:web with an encoded path separator must be refused"); + assert!( + matches!( + err, + DidError::EncodedPathSeparator(_) | DidError::BadPercentEscape(_) + ), + "expected an encoding refusal, got {err}" + ); +} -- 2.51.2