CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

common#

Last verified: 2026-04-25

Purpose#

Narrow, mockable primitives shared across atproto-devtool's conformance test subcommands. Five submodules:

  • common::identity — DNS, HTTPS, PLC directory, DID document, and labeler record primitives. Every network hop here is a trait object so integration tests can replay recorded fixtures instead of hitting real servers. Also owns the signing/verifying key newtypes (AnySigningKey, AnyVerifyingKey, AnySignature) that every curve-generic operation routes through.
  • common::jwt — minimal hand-rolled compact JWS encoder/decoder used by the labeler report stage to mint self-mint service-auth tokens without pulling a JWT library. Only ES256 and ES256K.
  • common::oauth — OAuth 2.0 / JOSE primitives for the test oauth * family: clock (trait-object time source), jws (JWK parsing + ES256 signing/verification via jsonwebtoken), and relying_party (atproto-spec Relying Party that drives PAR, PKCE S256, DPoP proofs, and private_key_jwt with deterministic seeded RNG for reproducible tests).
  • common::report — the cross-subcommand report contract (CheckStatus, CheckResult, Stage, LabelerReport, RenderConfig, plus the blocked_by / skipped_with_reason skip-row constructors). Promoted out of the labeler tree so oauth_client can reuse it. Both subcommands render through LabelerReport::render; the name is historical.
  • common::diagnostics — miette NamedSource / SourceSpan helpers used when attaching JSON source context to check failures.

Contracts#

  • Exposes from identity:
    • Traits: HttpClient (get_bytes(&Url) -> (u16, Vec<u8>)), DnsResolver (txt_lookup(&str) -> Vec<String>). These are the only sanctioned seams for network I/O in the whole crate.
    • Real implementations: RealHttpClient (wraps reqwest::Client, constructible from a shared client via from_client so stages can reuse one TLS pool) and RealDnsResolver (hickory).
    • Types: Did, DidMethod, DidDocument, RawDidDocument, Service, VerificationMethod, Curve, AnyVerifyingKey, AnySigningKey, AnySignature, ParsedMultikey, PlcHistoricKey.
    • Resolvers: resolve_handle, resolve_did, find_service, parse_multikey, encode_multikey, plc_history_for_fragment.
    • Classification: is_local_labeler_hostname(&Url) -> bool — returns true for loopback, .local, and RFC 1918 IPv4 addresses. Drives the report stage's self-mint viability check.
    • IdentityError — single error enum covering every resolution failure. Variants are matched on by the identity stage to emit distinct check results, so adding or removing variants is a contract change.
  • Signing API:
    • AnySigningKey::{K256, P256} mirror AnyVerifyingKey for the signing side. sign(msg) and sign_prehash(&[u8; 32]) return AnySignature. All signatures are low-s normalized — the k256 backend does this automatically; the p256 backend normalizes explicitly because p256's sign_prehash can return high-s.
    • AnySigningKey::verifying_key() returns the paired AnyVerifyingKey.
    • AnySigningKey::jwt_alg() returns "ES256K" or "ES256".
    • AnySignature::to_jws_bytes() -> [u8; 64] serializes r || s big-endian (JWS raw-signature form, NOT DER).
    • encode_multikey(&AnyVerifyingKey) -> String is the exact inverse of parse_multikey: base58btc multibase with the multicodec curve prefix (0xe701 for secp256k1, 0x8024 for P-256) followed by the compressed SEC1 point.
  • Exposes from jwt:
    • Types: JwtHeader, JwtClaims, JwtError. Field names (alg, typ, iss, aud, exp, iat, lxm, jti) are the exact JSON keys atproto labelers expect — do NOT rename without adding #[serde(rename = "...")].
    • Functions: encode_compact(&header, &claims, &AnySigningKey) for producing compact-form tokens, decode_compact(token) for parsing, verify_compact(token, &AnyVerifyingKey) for end-to-end verify.
    • Only ES256 and ES256K are supported. nbf is deliberately omitted — the atproto spec does not require it and some servers reject unexpected claims.
    • JwtError does NOT derive miette::Diagnostic with stable codes. It is surfaced only inside the stage, which wraps any failure in a stage-local diagnostic with a labeler::report::* code before rendering.
  • Exposes from oauth::clock:
    • Trait: Clock (now_unix_seconds(&self) -> u64). The only sanctioned time source for anything the OAuth tests observe. Passed as Arc<dyn Clock> so both the pipeline and the in-process RP can share one instance.
    • Real implementation: RealClock. Tests inject FakeClock from tests/common/mod.rs.
  • Exposes from oauth::jws:
    • Enums: JwsAlg (Es256 | Es256k), JwkUse (Sig | Other(Arc<str>)).
    • Types: ParsedJwk (kid, alg, alg_raw, r#use, pre-built ES256 verifier), plus the error type JwsError (#[derive(Diagnostic)] with oauth_client::jws::* codes).
    • Functions: parse_jwk, sign_jws, verify_jws.
    • sign_jws/verify_jws only support ES256. ES256K parses but signing and verification return JwsError::UnsupportedOperation — handled at the JWKS stage as a curve-not-modern-EC violation rather than a hard structural failure.
    • ParsedJwk::decoding_key is Some whenever the JWK is on P-256 and either declares alg = ES256 or omits alg (RFC 7517 marks alg as optional, and real-world DPoP proofs routinely embed a JWK with no alg because the JWT header already carries it). It is None for secp256k1 or for a P-256 JWK that explicitly declares an unsupported alg (the caller's intent is respected rather than overridden by the curve hint).
  • Exposes from oauth::relying_party:
    • Enum: ClientKind (Confidential | Public).
    • Types: RelyingParty, AsDescriptor, ParRequest, ParResponse, TokenResponse (with a public sub: Option<String> field — atproto mandates the token endpoint return the authenticated account's DID), AuthorizeOutcome, RpError (#[derive(Diagnostic)] with oauth_client::relying_party::* codes).
    • RpError variants carry stable diagnostic codes; the current set includes issuer_mismatch (IssuerMismatch { expected, got }, raised by do_authorize when the AS redirect's iss query parameter does not match AsDescriptor::issuer) and sub_mismatch (SubMismatch { expected, got: Option<String> }, raised by do_token / do_refresh when the token response's sub is missing or differs from the DID the flow is pinned to).
    • do_authorize, do_token, and do_refresh perform iss / sub verification internally; do_token and do_refresh take an expected_sub: &str argument. For modeling non-conformant clients, the sibling helpers do_authorize_skipping_iss_verification, do_token_skipping_sub_verification, and do_refresh_skipping_sub_verification return the raw response with no verification — these are test-surface only and drive the iss_sub_verification interactive sub-stage.
    • Trait: RpFactory (fn build(&self, client_id: Url, kind: ClientKind) -> RelyingParty; callers typically hold an Arc<dyn RpFactory>). Two implementations: DefaultRpFactory (production; fresh OS entropy per call) and DeterministicRpFactory (tests; seeded ChaCha20Rng for reproducible DPoP proofs and PKCE verifiers).
    • DPoP nonce rotation is handled inside RelyingParty — callers do not plumb nonces through.
  • Exposes from report:
    • Enum: CheckStatus (Pass | SpecViolation | NetworkError | Advisory | Skipped). Rendering glyphs ([OK]/[FAIL]/[NET]/ [WARN]/[SKIP]) and per-status ANSI colors are part of the public contract — snapshot tests pin them.
    • Struct: CheckResult { id: &'static str, stage: Stage, status, summary, diagnostic: Option<Box<dyn Diagnostic + Send + Sync>>, skipped_reason }.
    • Newtype: Stage(pub &'static str) with label() and the constants IDENTITY, HTTP, SUBSCRIPTION, CRYPTO, REPORT (labeler) plus DISCOVERY, METADATA, JWKS, INTERACTIVE (oauth client). Use Stage::<NAME> rather than constructing ad-hoc stages — the render loop groups by stage identity and stable heading strings appear in snapshots.
    • Struct: LabelerReport { header, results, started_at, finished_at } with record, finish, exit_code, summary_counts, and render(&mut W, &RenderConfig). The name is historical; the OAuth client subcommand wraps it in OauthClientReport rather than duplicating the rendering logic.
    • Helpers: blocked_by(check_id, stage, summary, blocker_check_id) produces Skipped with reason = "blocked by <blocker_check_id>". skipped_with_reason produces Skipped with an arbitrary reason for non-blocking skips (e.g. "metadata is implicit for loopback clients").
    • Exit code contract: 1 if any SpecViolation, else 2 if any NetworkError, else 0. Advisory and Skipped never fail a run. SpecViolation takes precedence over NetworkError.
  • Guarantees:
    • resolve_did returns RawDidDocument with the original bytes retained in an Arc<[u8]> so downstream stages can build NamedSource diagnostics that point back at the unmodified server response.
    • parse_multikey accepts only did:key / multibase-z prefixed inputs and rejects unknown codec prefixes, wrong lengths, and mismatched curves. Never panics on malformed input.
    • find_service matches on the trailing #fragment of Service::id anchored to the end of the string (not a substring search).
    • plc_history_for_fragment returns the set of distinct keys from the PLC audit log for a given fragment, deduplicated by multikey string (keeping the earliest introduction). Order is chronological (oldest-first, matching the PLC API wire order), but the crypto stage treats the result as a set.
  • Expects: Callers supply a &dyn HttpClient / &dyn DnsResolver rather than constructing their own reqwest/hickory instances. The CLI wires the real clients once in LabelerCmd::run and passes them through LabelerOptions.

Dependencies#

  • Uses: reqwest (rustls, json, gzip), hickory-resolver, k256, p256 (with pkcs8 feature for the RP), multibase, sha2, serde_json, serde_urlencoded (OAuth form bodies), miette, thiserror, jsonwebtoken (rust_crypto backend, for oauth::jws), rand_chacha + rand_core (for the deterministic RP RNG), url. common::jwt additionally uses base64 (URL_SAFE_NO_PAD engine) and serde.
  • Used by: every stage in commands/test/labeler/ and commands/test/oauth/client/, plus integration tests under tests/. common::jwt is currently only used by the labeler report stage.
  • Boundary: nothing in common/ depends on anything under commands/. Stage-specific types (IdentityFacts, DiscoveryFacts, MetadataFacts, etc.) live next to their stage, not here. common::report is the only cross-subcommand coupling — both labeler and oauth_client share its CheckStatus / Stage / LabelerReport contracts, and changes to those ripple through every snapshot in the crate.

Key decisions#

  • Narrow trait seams, not reqwest::Client everywhere: Every previous refactor that tried to pass reqwest::Client directly eventually broke integration tests. The HttpClient trait's two-method surface is deliberately small so fakes are trivial to write.
  • Arc<[u8]> for source bytes: miette NamedSource wants owned bytes and we fan the same payload out to multiple diagnostics, so every raw response is stored as Arc<[u8]>.
  • Single IdentityError enum: We tried split-per-stage errors and it forced lossy conversions. One enum, matched exhaustively by the identity stage, is less code and produces better diagnostics.
  • did:plc percent-encoding: resolve_did percent-encodes the DID before building the PLC directory URL. Do not regress.
  • No #[serde(flatten)] / #[serde(untagged)]: Required by project conventions; all DID document types use explicit #[serde(rename)].
  • All signatures low-s normalized: AnySigningKey::sign guarantees low-s form for both curves so AnyVerifyingKey::verify_prehash round-trip always succeeds. atproto requires low-s; p256's backend does not enforce it, so we explicitly call normalize_s.
  • Hand-rolled JWT instead of a library: We only need compact JWS with ES256/ES256K for a handful of tightly-scoped report-stage tokens. A full JWT library would pull RSA, HMAC, JWE, and a JSON Schema validator we do not want. The module is <500 lines and fully covered by round-trip tests.
  • is_local_labeler_hostname is deliberately conservative: IPv6 private ranges (fc00::/7, link-local) are NOT classified as local in v1. Operators running labelers on IPv6 ULA must pass --force-self-mint.

Invariants#

  • A RawDidDocument always has source_bytes matching exactly what the server returned — never pretty-printed, never re-serialized.
  • AnyVerifyingKey::verify_prehash rejects curve mismatches rather than silently returning Ok(()).
  • HttpClient::get_bytes returns the HTTP status even for non-2xx responses rather than converting them to errors; callers decide what a non-200 means in context.
  • AnySignature::to_jws_bytes() is always exactly 64 bytes, for both curves.
  • encode_multikey(parse_multikey(s).verifying_key) == s for every well-formed atproto multikey — round-tripping is pinned by unit tests.
  • jwt::verify_compact accepts exactly three .-separated segments. Four-segment (JWE) or malformed inputs return JwtError::MalformedCompact.

Key files#

  • identity.rs — all resolvers, DID types, signing/verifying key newtypes, encode_multikey / parse_multikey, is_local_labeler_hostname, plus extensive unit tests at the bottom.
  • jwt.rs — compact JWS encoder/decoder for ES256 and ES256K: JwtHeader, JwtClaims, JwtError, encode_compact, decode_compact, verify_compact. Segments use unpadded base64url; signatures are raw r || s (not DER) per RFC 7518 §3.4.
  • oauth/clock.rsClock trait and RealClock.
  • oauth/jws.rsJwsAlg, JwkUse, ParsedJwk, JwsError, parse_jwk, sign_jws, verify_jws. All JwsError variants carry stable oauth_client::jws::* diagnostic codes pinned in snapshots.
  • oauth/relying_party.rsRelyingParty, RpFactory, DefaultRpFactory, DeterministicRpFactory, and the request/response types for PAR / authorize / token / refresh. Signing keys are generated from a seeded ChaCha20Rng when built through DeterministicRpFactory.
  • report.rsCheckStatus, CheckResult, Stage, LabelerReport, RenderConfig, blocked_by, skipped_with_reason. Unit tests at the bottom pin exit-code and render behavior.
  • diagnostics.rsinstall_miette_handler, named_source_from_bytes / named_source_from_str, plus the JSON display helpers: pretty_json_for_display (re-serialize a JSON body so miette's caret rendering has newlines to land on), span_at_line_column (convert a serde_json::Error (line, column) into a SourceSpan, clamped to the matched line), and span_for_quoted_literal (find the span of a quoted JSON key or string value). Every stage that attaches JSON source context to a diagnostic should pretty-print the body once up front and compute spans against that same pretty body — never mix raw and pretty.

Gotchas#

  • RealHttpClient::new() builds a fresh reqwest::Client with a 10s timeout and a User-Agent header. Production code should prefer RealHttpClient::from_client so identity, HTTP, and crypto stages all share one connection pool.
  • resolve_handle has a DNS-first / HTTPS-fallback order and the HTTPS fallback must send a User-Agent. Tests exercise both paths.
  • plc_history_for_fragment traverses the PLC audit log in wire order (oldest-first) and dedupes by multikey string, not by position — the same key appearing across multiple rotations collapses to a single PlcHistoricKey (keeping the earliest introduction's metadata).
  • AnySigningKey nonces (jti in JWTs, run-id in sentinels) come from getrandom::getrandom, not from rand. The crate is a direct dep because the transitive rand_core in elliptic-curve is built without the getrandom feature.
  • p256 sign_prehash returns signatures that may be high-s; always go through AnySigningKey::sign / sign_prehash rather than calling the backend trait directly, or low-s normalization will be skipped and atproto servers will reject the signature.