···11+//! Command-line tool for signing AT Protocol records with inline or remote attestations.
22+//!
33+//! This tool creates cryptographic signatures for AT Protocol records using the CID-first
44+//! attestation specification. It supports both inline attestations (embedding signatures
55+//! directly in records) and remote attestations (creating separate proof records).
66+//!
77+//! ## Usage Patterns
88+//!
99+//! ### Remote Attestation
1010+//! ```bash
1111+//! atproto-attestation-sign remote <source_record> <repository_did> <metadata_record>
1212+//! ```
1313+//!
1414+//! ### Inline Attestation
1515+//! ```bash
1616+//! atproto-attestation-sign inline <source_record> <signing_key> <metadata_record>
1717+//! ```
1818+//!
1919+//! ## Arguments
2020+//!
2121+//! - `source_record`: JSON string or path to JSON file containing the record being attested
2222+//! - `repository_did`: (Remote mode) DID of the repository that will contain the remote attestation record
2323+//! - `signing_key`: (Inline mode) Private key string (did:key format) used to sign the attestation
2424+//! - `metadata_record`: JSON string or path to JSON file with attestation metadata used during CID creation
2525+//!
2626+//! ## Examples
2727+//!
2828+//! ```bash
2929+//! # Remote attestation - creates proof record and strongRef
3030+//! atproto-attestation-sign remote \
3131+//! record.json \
3232+//! did:plc:xyz123... \
3333+//! metadata.json
3434+//!
3535+//! # Inline attestation - embeds signature in record
3636+//! atproto-attestation-sign inline \
3737+//! record.json \
3838+//! did:key:z42tv1pb3... \
3939+//! '{"$type":"com.example.attestation","purpose":"demo"}'
4040+//!
4141+//! # Read from stdin
4242+//! cat record.json | atproto-attestation-sign inline \
4343+//! - \
4444+//! did:key:z42tv1pb3... \
4545+//! metadata.json
4646+//! ```
4747+4848+use anyhow::{Context, Result, anyhow};
4949+use atproto_attestation::{
5050+ create_inline_attestation, create_remote_attestation, create_remote_attestation_reference,
5151+};
5252+use atproto_identity::key::identify_key;
5353+use clap::{Parser, Subcommand};
5454+use serde_json::Value;
5555+use std::{
5656+ fs,
5757+ io::{self, Read},
5858+ path::Path,
5959+};
6060+6161+/// Command-line tool for signing AT Protocol records with cryptographic attestations.
6262+///
6363+/// Creates inline or remote attestations following the CID-first specification.
6464+/// Inline attestations embed signatures directly in records, while remote attestations
6565+/// generate separate proof records with strongRef references.
6666+#[derive(Parser)]
6767+#[command(
6868+ name = "atproto-attestation-sign",
6969+ version,
7070+ about = "Sign AT Protocol records with cryptographic attestations",
7171+ long_about = "
7272+A command-line tool for signing AT Protocol records using the CID-first attestation
7373+specification. Supports both inline attestations (signatures embedded in the record)
7474+and remote attestations (separate proof records with CID references).
7575+7676+MODES:
7777+ remote Creates a separate proof record with strongRef reference
7878+ Syntax: remote <source_record> <repository_did> <metadata_record>
7979+8080+ inline Embeds signature bytes directly in the record
8181+ Syntax: inline <source_record> <signing_key> <metadata_record>
8282+8383+ARGUMENTS:
8484+ source_record JSON string or file path to the record being attested
8585+ repository_did (Remote) DID of repository containing the attestation record
8686+ signing_key (Inline) Private key in did:key format for signing
8787+ metadata_record JSON string or file path with attestation metadata
8888+8989+EXAMPLES:
9090+ # Remote attestation (creates proof record + strongRef):
9191+ atproto-attestation-sign remote \\
9292+ record.json \\
9393+ did:plc:xyz123abc... \\
9494+ metadata.json
9595+9696+ # Inline attestation (embeds signature):
9797+ atproto-attestation-sign inline \\
9898+ record.json \\
9999+ did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\
100100+ '{\"$type\":\"com.example.attestation\",\"purpose\":\"demo\"}'
101101+102102+ # Read source record from stdin:
103103+ cat record.json | atproto-attestation-sign inline \\
104104+ - \\
105105+ did:key:z42tv1pb3... \\
106106+ metadata.json
107107+108108+OUTPUT:
109109+ Remote mode outputs TWO JSON objects:
110110+ 1. The proof record (to be stored in the repository)
111111+ 2. The source record with strongRef attestation appended
112112+113113+ Inline mode outputs ONE JSON object:
114114+ - The source record with inline attestation embedded
115115+"
116116+)]
117117+struct Args {
118118+ #[command(subcommand)]
119119+ command: Commands,
120120+}
121121+122122+#[derive(Subcommand)]
123123+enum Commands {
124124+ /// Create a remote attestation with separate proof record
125125+ ///
126126+ /// Generates a proof record containing the CID and returns both the proof
127127+ /// record (to be stored in the repository) and the source record with a
128128+ /// strongRef attestation reference.
129129+ #[command(visible_alias = "r")]
130130+ Remote {
131131+ /// Source record JSON string or file path (use '-' for stdin)
132132+ source_record: String,
133133+134134+ /// Repository DID that will contain the remote attestation record
135135+ repository_did: String,
136136+137137+ /// Attestation metadata JSON string or file path
138138+ metadata_record: String,
139139+ },
140140+141141+ /// Create an inline attestation with embedded signature
142142+ ///
143143+ /// Signs the record with the provided private key and embeds the signature
144144+ /// directly in the record's attestation structure.
145145+ #[command(visible_alias = "i")]
146146+ Inline {
147147+ /// Source record JSON string or file path (use '-' for stdin)
148148+ source_record: String,
149149+150150+ /// Private signing key in did:key format (e.g., did:key:z...)
151151+ signing_key: String,
152152+153153+ /// Attestation metadata JSON string or file path
154154+ metadata_record: String,
155155+ },
156156+}
157157+158158+#[tokio::main]
159159+async fn main() -> Result<()> {
160160+ let args = Args::parse();
161161+162162+ match args.command {
163163+ Commands::Remote {
164164+ source_record,
165165+ repository_did,
166166+ metadata_record,
167167+ } => handle_remote_attestation(&source_record, &repository_did, &metadata_record)?,
168168+169169+ Commands::Inline {
170170+ source_record,
171171+ signing_key,
172172+ metadata_record,
173173+ } => handle_inline_attestation(&source_record, &signing_key, &metadata_record)?,
174174+ }
175175+176176+ Ok(())
177177+}
178178+179179+/// Handle remote attestation mode.
180180+///
181181+/// Creates a proof record and appends a strongRef to the source record.
182182+/// Outputs both the proof record and the updated source record.
183183+fn handle_remote_attestation(
184184+ source_record: &str,
185185+ repository_did: &str,
186186+ metadata_record: &str,
187187+) -> Result<()> {
188188+ // Load source record and metadata
189189+ let record_json = load_json_input(source_record)?;
190190+ let metadata_json = load_json_input(metadata_record)?;
191191+192192+ // Validate inputs
193193+ if !record_json.is_object() {
194194+ return Err(anyhow!("Source record must be a JSON object"));
195195+ }
196196+197197+ if !metadata_json.is_object() {
198198+ return Err(anyhow!("Metadata record must be a JSON object"));
199199+ }
200200+201201+ // Validate repository DID
202202+ if !repository_did.starts_with("did:") {
203203+ return Err(anyhow!(
204204+ "Repository DID must start with 'did:' prefix, got: {}",
205205+ repository_did
206206+ ));
207207+ }
208208+209209+ // Create the remote attestation proof record
210210+ let proof_record = create_remote_attestation(&record_json, &metadata_json)
211211+ .context("Failed to create remote attestation proof record")?;
212212+213213+ // Create the source record with strongRef reference
214214+ let attested_record =
215215+ create_remote_attestation_reference(&record_json, &proof_record, repository_did)
216216+ .context("Failed to create remote attestation reference")?;
217217+218218+ // Output both records
219219+ println!("=== Proof Record (store in repository) ===");
220220+ println!("{}", serde_json::to_string_pretty(&proof_record)?);
221221+ println!();
222222+ println!("=== Attested Record (with strongRef) ===");
223223+ println!("{}", serde_json::to_string_pretty(&attested_record)?);
224224+225225+ Ok(())
226226+}
227227+228228+/// Handle inline attestation mode.
229229+///
230230+/// Signs the record with the provided key and embeds the signature.
231231+/// Outputs the record with inline attestation.
232232+fn handle_inline_attestation(
233233+ source_record: &str,
234234+ signing_key: &str,
235235+ metadata_record: &str,
236236+) -> Result<()> {
237237+ // Load source record and metadata
238238+ let record_json = load_json_input(source_record)?;
239239+ let metadata_json = load_json_input(metadata_record)?;
240240+241241+ // Validate inputs
242242+ if !record_json.is_object() {
243243+ return Err(anyhow!("Source record must be a JSON object"));
244244+ }
245245+246246+ if !metadata_json.is_object() {
247247+ return Err(anyhow!("Metadata record must be a JSON object"));
248248+ }
249249+250250+ // Parse the signing key
251251+ let key_data = identify_key(signing_key)
252252+ .with_context(|| format!("Failed to parse signing key: {}", signing_key))?;
253253+254254+ // Create inline attestation
255255+ let signed_record = create_inline_attestation(&record_json, &metadata_json, &key_data)
256256+ .context("Failed to create inline attestation")?;
257257+258258+ // Output the signed record
259259+ println!("{}", serde_json::to_string_pretty(&signed_record)?);
260260+261261+ Ok(())
262262+}
263263+264264+/// Load JSON input from various sources.
265265+///
266266+/// Accepts:
267267+/// - "-" for stdin
268268+/// - File paths (if the file exists)
269269+/// - Direct JSON strings
270270+///
271271+/// Returns the parsed JSON value or an error.
272272+fn load_json_input(argument: &str) -> Result<Value> {
273273+ // Handle stdin input
274274+ if argument == "-" {
275275+ let mut input = String::new();
276276+ io::stdin()
277277+ .read_to_string(&mut input)
278278+ .context("Failed to read from stdin")?;
279279+ return serde_json::from_str(&input).context("Failed to parse JSON from stdin");
280280+ }
281281+282282+ // Try as file path first
283283+ let path = Path::new(argument);
284284+ if path.is_file() {
285285+ let file_content = fs::read_to_string(path)
286286+ .with_context(|| format!("Failed to read file: {}", argument))?;
287287+ return serde_json::from_str(&file_content)
288288+ .with_context(|| format!("Failed to parse JSON from file: {}", argument));
289289+ }
290290+291291+ // Try as direct JSON string
292292+ serde_json::from_str(argument).with_context(|| {
293293+ format!(
294294+ "Argument is neither valid JSON nor a readable file: {}",
295295+ argument
296296+ )
297297+ })
298298+}
···11+//! Command-line tool for verifying cryptographic signatures on AT Protocol records.
22+//!
33+//! This tool validates attestation signatures on AT Protocol records by reconstructing
44+//! the signed content and verifying ECDSA signatures against public keys embedded in the
55+//! attestation metadata.
66+//!
77+//! ## Usage Patterns
88+//!
99+//! ### Verify all signatures in a record
1010+//! ```bash
1111+//! atproto-attestation-verify <record>
1212+//! ```
1313+//!
1414+//! ### Verify a specific attestation against a record
1515+//! ```bash
1616+//! atproto-attestation-verify <record> <attestation>
1717+//! ```
1818+//!
1919+//! ## Parameter Formats
2020+//!
2121+//! Both `record` and `attestation` parameters accept:
2222+//! - **JSON string**: Direct JSON payload (e.g., `'{"$type":"...","text":"..."}'`)
2323+//! - **File path**: Path to a JSON file (e.g., `./record.json`)
2424+//! - **AT-URI**: AT Protocol URI to fetch the record (e.g., `at://did:plc:abc/app.bsky.feed.post/123`)
2525+//!
2626+//! ## Examples
2727+//!
2828+//! ```bash
2929+//! # Verify all signatures in a record from file
3030+//! atproto-attestation-verify ./signed_post.json
3131+//!
3232+//! # Verify all signatures in a record from AT-URI
3333+//! atproto-attestation-verify at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g
3434+//!
3535+//! # Verify specific attestation against a record (both from files)
3636+//! atproto-attestation-verify ./record.json ./attestation.json
3737+//!
3838+//! # Verify specific attestation (from AT-URI) against record (from file)
3939+//! atproto-attestation-verify ./record.json at://did:plc:xyz/com.example.attestation/abc123
4040+//!
4141+//! # Read record from stdin, verify all signatures
4242+//! cat signed.json | atproto-attestation-verify -
4343+//!
4444+//! # Verify inline JSON
4545+//! atproto-attestation-verify '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}'
4646+//! ```
4747+4848+use anyhow::{Context, Result, anyhow};
4949+use atproto_attestation::{VerificationStatus, verify_all_signatures_with_resolver};
5050+use clap::Parser;
5151+use serde_json::Value;
5252+use std::{
5353+ fs,
5454+ io::{self, Read},
5555+ path::Path,
5656+};
5757+5858+/// Command-line tool for verifying cryptographic signatures on AT Protocol records.
5959+///
6060+/// Validates attestation signatures by reconstructing signed content and checking
6161+/// ECDSA signatures against embedded public keys. Supports verifying all signatures
6262+/// in a record or validating a specific attestation record.
6363+#[derive(Parser)]
6464+#[command(
6565+ name = "atproto-attestation-verify",
6666+ version,
6767+ about = "Verify cryptographic signatures of AT Protocol records",
6868+ long_about = "
6969+A command-line tool for verifying cryptographic signatures of AT Protocol records.
7070+7171+USAGE:
7272+ atproto-attestation-verify <record> Verify all signatures in record
7373+ atproto-attestation-verify <record> <attestation> Verify specific attestation
7474+7575+PARAMETER FORMATS:
7676+ Each parameter accepts JSON strings, file paths, or AT-URIs:
7777+ - JSON string: '{\"$type\":\"...\",\"text\":\"...\"}'
7878+ - File path: ./record.json
7979+ - AT-URI: at://did:plc:abc/app.bsky.feed.post/123
8080+ - Stdin: - (for record parameter only)
8181+8282+EXAMPLES:
8383+ # Verify all signatures in a record:
8484+ atproto-attestation-verify ./signed_post.json
8585+ atproto-attestation-verify at://did:plc:abc/app.bsky.feed.post/123
8686+8787+ # Verify specific attestation:
8888+ atproto-attestation-verify ./record.json ./attestation.json
8989+ atproto-attestation-verify ./record.json at://did:plc:xyz/com.example.attestation/abc
9090+9191+ # Read from stdin:
9292+ cat signed.json | atproto-attestation-verify -
9393+9494+OUTPUT:
9595+ Single record mode: Reports each signature with ✓ (valid), ✗ (invalid), or ? (unverified)
9696+ Attestation mode: Outputs 'OK' on success, error message on failure
9797+9898+VERIFICATION:
9999+ - Inline signatures are verified by reconstructing $sig and validating against embedded keys
100100+ - Remote attestations (strongRef) are reported as unverified (require fetching proof record)
101101+ - Keys are resolved from did:key identifiers or require a key resolver for DID document keys
102102+"
103103+)]
104104+struct Args {
105105+ /// Record to verify - JSON string, file path, AT-URI, or '-' for stdin
106106+ record: String,
107107+108108+ /// Optional attestation record to verify against the record - JSON string, file path, or AT-URI
109109+ attestation: Option<String>,
110110+}
111111+112112+#[tokio::main]
113113+async fn main() -> Result<()> {
114114+ let args = Args::parse();
115115+116116+ // Load the record
117117+ let record = load_input(&args.record, true)
118118+ .await
119119+ .context("Failed to load record")?;
120120+121121+ if !record.is_object() {
122122+ return Err(anyhow!("Record must be a JSON object"));
123123+ }
124124+125125+ // Determine verification mode
126126+ match args.attestation {
127127+ None => {
128128+ // Mode 1: Verify all signatures in the record
129129+ verify_all_mode(&record).await
130130+ }
131131+ Some(attestation_input) => {
132132+ // Mode 2: Verify specific attestation against record
133133+ let attestation = load_input(&attestation_input, false)
134134+ .await
135135+ .context("Failed to load attestation")?;
136136+137137+ if !attestation.is_object() {
138138+ return Err(anyhow!("Attestation must be a JSON object"));
139139+ }
140140+141141+ verify_attestation_mode(&record, &attestation).await
142142+ }
143143+ }
144144+}
145145+146146+/// Mode 1: Verify all signatures contained in the record.
147147+///
148148+/// Reports each signature with status indicators:
149149+/// - ✓ Valid signature
150150+/// - ✗ Invalid signature
151151+/// - ? Unverified (e.g., remote attestations requiring proof record fetch)
152152+async fn verify_all_mode(record: &Value) -> Result<()> {
153153+ // Create an identity resolver for fetching remote attestations
154154+ use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
155155+ use std::sync::Arc;
156156+157157+ let http_client = reqwest::Client::new();
158158+ let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
159159+160160+ let identity_resolver = InnerIdentityResolver {
161161+ http_client: http_client.clone(),
162162+ dns_resolver: Arc::new(dns_resolver),
163163+ plc_hostname: "plc.directory".to_string(),
164164+ };
165165+166166+ // Create record resolver that can fetch remote attestation proof records
167167+ let record_resolver = RemoteAttestationResolver {
168168+ http_client,
169169+ identity_resolver,
170170+ };
171171+172172+ let reports = verify_all_signatures_with_resolver(record, None, Some(&record_resolver))
173173+ .await
174174+ .context("Failed to verify signatures")?;
175175+176176+ if reports.is_empty() {
177177+ return Err(anyhow!("No signatures found in record"));
178178+ }
179179+180180+ let mut all_valid = true;
181181+ let mut has_errors = false;
182182+183183+ for report in &reports {
184184+ match &report.status {
185185+ VerificationStatus::Valid { cid } => {
186186+ let key_info = report
187187+ .key
188188+ .as_deref()
189189+ .map(|k| format!(" (key: {})", truncate_did(k)))
190190+ .unwrap_or_default();
191191+ println!(
192192+ "✓ Signature {} valid{} [CID: {}]",
193193+ report.index, key_info, cid
194194+ );
195195+ }
196196+ VerificationStatus::Invalid { error } => {
197197+ println!("✗ Signature {} invalid: {}", report.index, error);
198198+ all_valid = false;
199199+ has_errors = true;
200200+ }
201201+ VerificationStatus::Unverified { reason } => {
202202+ println!("? Signature {} unverified: {}", report.index, reason);
203203+ all_valid = false;
204204+ }
205205+ }
206206+ }
207207+208208+ println!();
209209+ println!(
210210+ "Summary: {} total, {} valid",
211211+ reports.len(),
212212+ reports
213213+ .iter()
214214+ .filter(|r| matches!(r.status, VerificationStatus::Valid { .. }))
215215+ .count()
216216+ );
217217+218218+ if has_errors {
219219+ Err(anyhow!("One or more signatures are invalid"))
220220+ } else if !all_valid {
221221+ Err(anyhow!("One or more signatures could not be verified"))
222222+ } else {
223223+ Ok(())
224224+ }
225225+}
226226+227227+/// Mode 2: Verify a specific attestation record against the provided record.
228228+///
229229+/// The attestation should be a standalone attestation object (e.g., from a remote proof record)
230230+/// that will be verified against the record's content.
231231+async fn verify_attestation_mode(record: &Value, attestation: &Value) -> Result<()> {
232232+ // The attestation should have a CID field that we can use to verify
233233+ let attestation_obj = attestation
234234+ .as_object()
235235+ .ok_or_else(|| anyhow!("Attestation must be a JSON object"))?;
236236+237237+ // Get the CID from the attestation
238238+ let cid_str = attestation_obj
239239+ .get("cid")
240240+ .and_then(Value::as_str)
241241+ .ok_or_else(|| anyhow!("Attestation must contain a 'cid' field"))?;
242242+243243+ // Prepare the signing record with the attestation metadata
244244+ let mut signing_metadata = attestation_obj.clone();
245245+ signing_metadata.remove("cid");
246246+ signing_metadata.remove("signature");
247247+248248+ let signing_record =
249249+ atproto_attestation::prepare_signing_record(record, &Value::Object(signing_metadata))
250250+ .context("Failed to prepare signing record")?;
251251+252252+ // Generate the CID from the signing record
253253+ let computed_cid =
254254+ atproto_attestation::create_cid(&signing_record).context("Failed to generate CID")?;
255255+256256+ // Compare CIDs
257257+ if computed_cid.to_string() != cid_str {
258258+ return Err(anyhow!(
259259+ "CID mismatch: attestation claims {}, but computed {}",
260260+ cid_str,
261261+ computed_cid
262262+ ));
263263+ }
264264+265265+ println!("OK");
266266+ println!("CID: {}", computed_cid);
267267+268268+ Ok(())
269269+}
270270+271271+/// Load input from various sources: JSON string, file path, AT-URI, or stdin.
272272+///
273273+/// The `allow_stdin` parameter controls whether "-" is interpreted as stdin.
274274+async fn load_input(input: &str, allow_stdin: bool) -> Result<Value> {
275275+ // Handle stdin
276276+ if input == "-" {
277277+ if !allow_stdin {
278278+ return Err(anyhow!(
279279+ "Stdin ('-') is only supported for the record parameter"
280280+ ));
281281+ }
282282+283283+ let mut buffer = String::new();
284284+ io::stdin()
285285+ .read_to_string(&mut buffer)
286286+ .context("Failed to read from stdin")?;
287287+288288+ return serde_json::from_str(&buffer).context("Failed to parse JSON from stdin");
289289+ }
290290+291291+ // Check if it's an AT-URI
292292+ if input.starts_with("at://") {
293293+ return load_from_aturi(input)
294294+ .await
295295+ .with_context(|| format!("Failed to fetch record from AT-URI: {}", input));
296296+ }
297297+298298+ // Try as file path
299299+ let path = Path::new(input);
300300+ if path.exists() && path.is_file() {
301301+ let content =
302302+ fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", input))?;
303303+304304+ return serde_json::from_str(&content)
305305+ .with_context(|| format!("Failed to parse JSON from file: {}", input));
306306+ }
307307+308308+ // Try as direct JSON string
309309+ serde_json::from_str(input).with_context(|| {
310310+ format!(
311311+ "Input is not valid JSON, an existing file, or an AT-URI: {}",
312312+ input
313313+ )
314314+ })
315315+}
316316+317317+/// Load a record from an AT-URI by fetching it from a PDS.
318318+///
319319+/// This requires resolving the DID to find the PDS endpoint, then fetching the record.
320320+async fn load_from_aturi(aturi: &str) -> Result<Value> {
321321+ use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
322322+ use atproto_record::aturi::ATURI;
323323+ use std::str::FromStr;
324324+ use std::sync::Arc;
325325+326326+ // Parse the AT-URI
327327+ let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?;
328328+329329+ // Create resolver components
330330+ let http_client = reqwest::Client::new();
331331+ let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
332332+333333+ // Create identity resolver
334334+ let identity_resolver = InnerIdentityResolver {
335335+ http_client: http_client.clone(),
336336+ dns_resolver: Arc::new(dns_resolver),
337337+ plc_hostname: "plc.directory".to_string(),
338338+ };
339339+340340+ // Resolve the DID to get the PDS endpoint
341341+ let document = identity_resolver
342342+ .resolve(&parsed.authority)
343343+ .await
344344+ .with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?;
345345+346346+ // Find the PDS endpoint
347347+ let pds_endpoint = document
348348+ .service
349349+ .iter()
350350+ .find(|s| s.r#type == "AtprotoPersonalDataServer")
351351+ .map(|s| s.service_endpoint.as_str())
352352+ .ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?;
353353+354354+ // Fetch the record using the XRPC client
355355+ let response = atproto_client::com::atproto::repo::get_record(
356356+ &http_client,
357357+ &atproto_client::client::Auth::None,
358358+ pds_endpoint,
359359+ &parsed.authority,
360360+ &parsed.collection,
361361+ &parsed.record_key,
362362+ None,
363363+ )
364364+ .await
365365+ .with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?;
366366+367367+ match response {
368368+ atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => Ok(value),
369369+ atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => {
370370+ Err(anyhow!("Failed to fetch record: {}", error.error_message()))
371371+ }
372372+ }
373373+}
374374+375375+/// Truncate a DID or did:key for display purposes.
376376+fn truncate_did(did: &str) -> String {
377377+ if did.len() > 40 {
378378+ format!("{}...{}", &did[..20], &did[did.len() - 12..])
379379+ } else {
380380+ did.to_string()
381381+ }
382382+}
383383+384384+/// Record resolver for remote attestations that resolves DIDs to find PDS endpoints.
385385+struct RemoteAttestationResolver {
386386+ http_client: reqwest::Client,
387387+ identity_resolver: atproto_identity::resolve::InnerIdentityResolver,
388388+}
389389+390390+#[async_trait::async_trait]
391391+impl atproto_client::record_resolver::RecordResolver for RemoteAttestationResolver {
392392+ async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T>
393393+ where
394394+ T: serde::de::DeserializeOwned + Send,
395395+ {
396396+ use atproto_record::aturi::ATURI;
397397+ use std::str::FromStr;
398398+399399+ // Parse the AT-URI
400400+ let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?;
401401+402402+ // Resolve the DID to get the PDS endpoint
403403+ let document = self
404404+ .identity_resolver
405405+ .resolve(&parsed.authority)
406406+ .await
407407+ .with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?;
408408+409409+ // Find the PDS endpoint
410410+ let pds_endpoint = document
411411+ .service
412412+ .iter()
413413+ .find(|s| s.r#type == "AtprotoPersonalDataServer")
414414+ .map(|s| s.service_endpoint.as_str())
415415+ .ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?;
416416+417417+ // Fetch the record using the XRPC client
418418+ let response = atproto_client::com::atproto::repo::get_record(
419419+ &self.http_client,
420420+ &atproto_client::client::Auth::None,
421421+ pds_endpoint,
422422+ &parsed.authority,
423423+ &parsed.collection,
424424+ &parsed.record_key,
425425+ None,
426426+ )
427427+ .await
428428+ .with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?;
429429+430430+ match response {
431431+ atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => {
432432+ serde_json::from_value(value)
433433+ .map_err(|e| anyhow!("Failed to deserialize record: {}", e))
434434+ }
435435+ atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => {
436436+ Err(anyhow!("Failed to fetch record: {}", error.error_message()))
437437+ }
438438+ }
439439+ }
440440+}
+194
crates/atproto-attestation/src/errors.rs
···11+//! Errors that can occur during attestation preparation and verification.
22+//!
33+//! Covers CID construction, `$sig` metadata validation, inline attestation
44+//! structure checks, and identity/key resolution failures.
55+66+use thiserror::Error;
77+88+/// Errors that can occur during attestation preparation and verification.
99+#[derive(Debug, Error)]
1010+pub enum AttestationError {
1111+ /// Error when the record value is not a JSON object.
1212+ #[error("error-atproto-attestation-1 Record must be a JSON object")]
1313+ RecordMustBeObject,
1414+1515+ /// Error when attestation metadata is not a JSON object.
1616+ #[error("error-atproto-attestation-2 Attestation metadata must be a JSON object")]
1717+ MetadataMustBeObject,
1818+1919+ /// Error when attestation metadata is missing a required field.
2020+ #[error("error-atproto-attestation-3 Attestation metadata missing required field: {field}")]
2121+ MetadataMissingField {
2222+ /// Name of the missing field.
2323+ field: String,
2424+ },
2525+2626+ /// Error when attestation metadata omits the `$type` discriminator.
2727+ #[error("error-atproto-attestation-4 Attestation metadata must include a string `$type` field")]
2828+ MetadataMissingSigType,
2929+3030+ /// Error when the record does not contain a signatures array.
3131+ #[error("error-atproto-attestation-5 Signatures array not found on record")]
3232+ SignaturesArrayMissing,
3333+3434+ /// Error when the signatures field exists but is not an array.
3535+ #[error("error-atproto-attestation-6 Signatures field must be an array")]
3636+ SignaturesFieldInvalid,
3737+3838+ /// Error when attempting to verify a signature at an invalid index.
3939+ #[error("error-atproto-attestation-7 Signature index {index} out of bounds")]
4040+ SignatureIndexOutOfBounds {
4141+ /// Index that was requested.
4242+ index: usize,
4343+ },
4444+4545+ /// Error when a signature object is missing a required field.
4646+ #[error("error-atproto-attestation-8 Signature object missing required field: {field}")]
4747+ SignatureMissingField {
4848+ /// Field name that was expected.
4949+ field: String,
5050+ },
5151+5252+ /// Error when a signature object uses an invalid `$type` for inline attestations.
5353+ #[error(
5454+ "error-atproto-attestation-9 Inline attestation `$type` cannot be `com.atproto.repo.strongRef`"
5555+ )]
5656+ InlineAttestationTypeInvalid,
5757+5858+ /// Error when a remote attestation entry does not use the strongRef type.
5959+ #[error(
6060+ "error-atproto-attestation-10 Remote attestation entries must use `com.atproto.repo.strongRef`"
6161+ )]
6262+ RemoteAttestationTypeInvalid,
6363+6464+ /// Error when a remote attestation entry is missing a CID.
6565+ #[error(
6666+ "error-atproto-attestation-11 Remote attestation entries must include a string `cid` field"
6767+ )]
6868+ RemoteAttestationMissingCid,
6969+7070+ /// Error when signature bytes are not provided using the `$bytes` wrapper.
7171+ #[error(
7272+ "error-atproto-attestation-12 Signature bytes must be encoded as `{{\"$bytes\": \"...\"}}`"
7373+ )]
7474+ SignatureBytesFormatInvalid,
7575+7676+ /// Error when record serialization to DAG-CBOR fails.
7777+ #[error("error-atproto-attestation-13 Record serialization failed: {error}")]
7878+ RecordSerializationFailed {
7979+ /// Underlying serialization error.
8080+ #[from]
8181+ error: serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>,
8282+ },
8383+8484+ /// Error when `$sig` metadata is missing from the record before CID creation.
8585+ #[error("error-atproto-attestation-14 `$sig` metadata must be present before generating a CID")]
8686+ SigMetadataMissing,
8787+8888+ /// Error when `$sig` metadata is not an object.
8989+ #[error("error-atproto-attestation-15 `$sig` metadata must be a JSON object")]
9090+ SigMetadataNotObject,
9191+9292+ /// Error when `$sig` metadata omits the `$type` discriminator.
9393+ #[error("error-atproto-attestation-16 `$sig` metadata must include a string `$type` field")]
9494+ SigMetadataMissingType,
9595+9696+ /// Error when a key resolver is required but not provided.
9797+ #[error("error-atproto-attestation-17 Key resolver required to resolve key reference: {key}")]
9898+ KeyResolverRequired {
9999+ /// Key reference that required resolution.
100100+ key: String,
101101+ },
102102+103103+ /// Error when key resolution using the provided resolver fails.
104104+ #[error("error-atproto-attestation-18 Failed to resolve key reference {key}: {error}")]
105105+ KeyResolutionFailed {
106106+ /// Key reference that was being resolved.
107107+ key: String,
108108+ /// Underlying resolution error.
109109+ #[source]
110110+ error: anyhow::Error,
111111+ },
112112+113113+ /// Error when the key type is unsupported for inline attestations.
114114+ #[error("error-atproto-attestation-21 Unsupported key type for attestation: {key_type}")]
115115+ UnsupportedKeyType {
116116+ /// Unsupported key type.
117117+ key_type: atproto_identity::key::KeyType,
118118+ },
119119+120120+ /// Error when signature decoding fails.
121121+ #[error("error-atproto-attestation-22 Signature decoding failed: {error}")]
122122+ SignatureDecodingFailed {
123123+ /// Underlying base64 decoding error.
124124+ #[from]
125125+ error: base64::DecodeError,
126126+ },
127127+128128+ /// Error when signature length does not match the expected size.
129129+ #[error(
130130+ "error-atproto-attestation-23 Signature length invalid: expected {expected} bytes, found {actual}"
131131+ )]
132132+ SignatureLengthInvalid {
133133+ /// Expected signature length.
134134+ expected: usize,
135135+ /// Actual signature length.
136136+ actual: usize,
137137+ },
138138+139139+ /// Error when signature is not normalized to low-S form.
140140+ #[error("error-atproto-attestation-24 Signature must be normalized to low-S form")]
141141+ SignatureNotNormalized,
142142+143143+ /// Error when cryptographic verification fails.
144144+ #[error("error-atproto-attestation-25 Signature verification failed: {error}")]
145145+ SignatureValidationFailed {
146146+ /// Underlying key validation error.
147147+ #[source]
148148+ error: atproto_identity::errors::KeyError,
149149+ },
150150+151151+ /// Error when multihash construction for CID generation fails.
152152+ #[error("error-atproto-attestation-26 Failed to construct CID multihash: {error}")]
153153+ MultihashWrapFailed {
154154+ /// Underlying multihash error.
155155+ #[source]
156156+ error: multihash::Error,
157157+ },
158158+159159+ /// Error when signature creation fails during inline attestation.
160160+ #[error("error-atproto-attestation-27 Signature creation failed: {error}")]
161161+ SignatureCreationFailed {
162162+ /// Underlying signing error.
163163+ #[source]
164164+ error: atproto_identity::errors::KeyError,
165165+ },
166166+167167+ /// Error when fetching a remote attestation proof record fails.
168168+ #[error("error-atproto-attestation-28 Failed to fetch remote attestation from {uri}: {error}")]
169169+ RemoteAttestationFetchFailed {
170170+ /// AT-URI that failed to resolve.
171171+ uri: String,
172172+ /// Underlying fetch error.
173173+ #[source]
174174+ error: anyhow::Error,
175175+ },
176176+177177+ /// Error when the CID of a remote attestation proof record doesn't match expected.
178178+ #[error(
179179+ "error-atproto-attestation-29 Remote attestation CID mismatch: expected {expected}, got {actual}"
180180+ )]
181181+ RemoteAttestationCidMismatch {
182182+ /// Expected CID.
183183+ expected: String,
184184+ /// Actual CID.
185185+ actual: String,
186186+ },
187187+188188+ /// Error when parsing a CID string fails.
189189+ #[error("error-atproto-attestation-30 Invalid CID format: {cid}")]
190190+ InvalidCid {
191191+ /// Invalid CID string.
192192+ cid: String,
193193+ },
194194+}
+1021
crates/atproto-attestation/src/lib.rs
···11+//! AT Protocol record attestation utilities based on the CID-first specification.
22+//!
33+//! This crate implements helpers for constructing deterministic signing payloads,
44+//! creating inline and remote attestation references, and verifying signatures
55+//! against DID verification methods. It follows the requirements documented in
66+//! `bluesky-attestation-tee/documentation/spec/attestation.md`.
77+//!
88+//! The workflow for inline attestations is:
99+//! 1. Prepare a signing record with [`prepare_signing_record`].
1010+//! 2. Generate the content identifier using [`create_cid`].
1111+//! 3. Sign the CID bytes externally and embed the attestation with
1212+//! [`create_inline_attestation_reference`].
1313+//! 4. Verify signatures with [`verify_signature`] or [`verify_all_signatures`].
1414+//!
1515+//! Remote attestations follow the same `$sig` preparation process but store the
1616+//! generated CID in a proof record and reference it with
1717+//! [`create_remote_attestation_reference`].
1818+1919+#![forbid(unsafe_code)]
2020+#![warn(missing_docs)]
2121+2222+pub mod errors;
2323+2424+use atproto_record::tid::Tid;
2525+pub use errors::AttestationError;
2626+2727+use atproto_identity::key::{KeyData, KeyResolver, KeyType, identify_key, sign, validate};
2828+use base64::{
2929+ Engine,
3030+ alphabet::STANDARD as STANDARD_ALPHABET,
3131+ engine::{
3232+ DecodePaddingMode,
3333+ general_purpose::{GeneralPurpose, GeneralPurposeConfig},
3434+ },
3535+};
3636+use cid::Cid;
3737+use elliptic_curve::scalar::IsHigh;
3838+use k256::ecdsa::Signature as K256Signature;
3939+use multihash::Multihash;
4040+use p256::ecdsa::Signature as P256Signature;
4141+use serde_json::{Map, Value, json};
4242+use sha2::{Digest, Sha256};
4343+4444+// Base64 engine that accepts both padded and unpadded input for maximum compatibility
4545+// with various AT Protocol implementations. Uses standard encoding with padding for output,
4646+// but accepts any padding format for decoding.
4747+const BASE64: GeneralPurpose = GeneralPurpose::new(
4848+ &STANDARD_ALPHABET,
4949+ GeneralPurposeConfig::new()
5050+ .with_encode_padding(true)
5151+ .with_decode_padding_mode(DecodePaddingMode::Indifferent),
5252+);
5353+5454+const STRONG_REF_TYPE: &str = "com.atproto.repo.strongRef";
5555+5656+/// Resolver trait for retrieving remote attestation records by AT URI.
5757+///
5858+/// Kind of attestation represented within the `signatures` array.
5959+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6060+pub enum AttestationKind {
6161+ /// Inline attestation containing signature bytes.
6262+ Inline,
6363+ /// Remote attestation referencing a proof record via strongRef.
6464+ Remote,
6565+}
6666+6767+/// Result of verifying a single attestation entry.
6868+#[derive(Debug)]
6969+pub enum VerificationStatus {
7070+ /// Signature is valid for the reconstructed signing payload.
7171+ Valid {
7272+ /// CID produced for the reconstructed record.
7373+ cid: Cid,
7474+ },
7575+ /// Signature verification or metadata validation failed.
7676+ Invalid {
7777+ /// Failure reason.
7878+ error: AttestationError,
7979+ },
8080+ /// Attestation cannot be verified locally (e.g., remote references).
8181+ Unverified {
8282+ /// Explanation for why verification was skipped.
8383+ reason: String,
8484+ },
8585+}
8686+8787+/// Structured verification report for a single attestation entry.
8888+#[derive(Debug)]
8989+pub struct VerificationReport {
9090+ /// Zero-based index of the signature in the record's `signatures` array.
9191+ pub index: usize,
9292+ /// Detected attestation kind.
9393+ pub kind: AttestationKind,
9494+ /// `$type` discriminator from the attestation entry, if present.
9595+ pub signature_type: Option<String>,
9696+ /// Key reference for inline signatures (if available).
9797+ pub key: Option<String>,
9898+ /// Verification outcome.
9999+ pub status: VerificationStatus,
100100+}
101101+102102+/// Create a deterministic CID for a record prepared with [`prepare_signing_record`].
103103+///
104104+/// The record **must** contain a `$sig` object with at least a `$type` string
105105+/// to scope the signature. The returned CID uses the blessed parameters:
106106+/// CIDv1, dag-cbor codec (0x71), and sha2-256 multihash.
107107+pub fn create_cid(record: &Value) -> Result<Cid, AttestationError> {
108108+ let record_object = record
109109+ .as_object()
110110+ .ok_or(AttestationError::RecordMustBeObject)?;
111111+112112+ let sig_value = record_object
113113+ .get("$sig")
114114+ .ok_or(AttestationError::SigMetadataMissing)?;
115115+116116+ let sig_object = sig_value
117117+ .as_object()
118118+ .ok_or(AttestationError::SigMetadataNotObject)?;
119119+120120+ if !sig_object
121121+ .get("$type")
122122+ .and_then(Value::as_str)
123123+ .filter(|value| !value.is_empty())
124124+ .is_some()
125125+ {
126126+ return Err(AttestationError::SigMetadataMissingType);
127127+ }
128128+129129+ let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?;
130130+ let digest = Sha256::digest(&dag_cbor_bytes);
131131+ let multihash = Multihash::wrap(0x12, &digest)
132132+ .map_err(|error| AttestationError::MultihashWrapFailed { error })?;
133133+134134+ Ok(Cid::new_v1(0x71, multihash))
135135+}
136136+137137+fn create_plain_cid(record: &Value) -> Result<Cid, AttestationError> {
138138+ let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?;
139139+ let digest = Sha256::digest(&dag_cbor_bytes);
140140+ let multihash = Multihash::wrap(0x12, &digest)
141141+ .map_err(|error| AttestationError::MultihashWrapFailed { error })?;
142142+143143+ Ok(Cid::new_v1(0x71, multihash))
144144+}
145145+146146+/// Prepare a record for signing by removing attestation artifacts and adding `$sig`.
147147+///
148148+/// - Removes any existing `signatures`, `sigs`, and `$sig` fields.
149149+/// - Inserts the provided `attestation` metadata as the new `$sig` object.
150150+/// - Ensures the metadata contains a string `$type` discriminator.
151151+pub fn prepare_signing_record(
152152+ record: &Value,
153153+ attestation: &Value,
154154+) -> Result<Value, AttestationError> {
155155+ let mut prepared = record
156156+ .as_object()
157157+ .cloned()
158158+ .ok_or(AttestationError::RecordMustBeObject)?;
159159+160160+ let mut sig_metadata = attestation
161161+ .as_object()
162162+ .cloned()
163163+ .ok_or(AttestationError::MetadataMustBeObject)?;
164164+165165+ if !sig_metadata
166166+ .get("$type")
167167+ .and_then(Value::as_str)
168168+ .filter(|value| !value.is_empty())
169169+ .is_some()
170170+ {
171171+ return Err(AttestationError::MetadataMissingSigType);
172172+ }
173173+174174+ sig_metadata.remove("signature");
175175+ sig_metadata.remove("cid");
176176+177177+ prepared.remove("signatures");
178178+ prepared.remove("sigs");
179179+ prepared.remove("$sig");
180180+ prepared.insert("$sig".to_string(), Value::Object(sig_metadata));
181181+182182+ Ok(Value::Object(prepared))
183183+}
184184+185185+/// Creates an inline attestation by signing the prepared record with the provided key.
186186+pub fn create_inline_attestation(
187187+ record: &Value,
188188+ attestation_metadata: &Value,
189189+ signing_key: &KeyData,
190190+) -> Result<Value, AttestationError> {
191191+ let signing_record = prepare_signing_record(record, attestation_metadata)?;
192192+ let cid = create_cid(&signing_record)?;
193193+194194+ let raw_signature = sign(signing_key, &cid.to_bytes())
195195+ .map_err(|error| AttestationError::SignatureCreationFailed { error })?;
196196+ let signature_bytes = normalize_signature(raw_signature, signing_key.key_type())?;
197197+198198+ let mut inline_object = attestation_metadata
199199+ .as_object()
200200+ .cloned()
201201+ .ok_or(AttestationError::MetadataMustBeObject)?;
202202+203203+ inline_object.remove("signature");
204204+ inline_object.remove("cid");
205205+ inline_object.insert(
206206+ "signature".to_string(),
207207+ json!({"$bytes": BASE64.encode(signature_bytes)}),
208208+ );
209209+210210+ create_inline_attestation_reference(record, &Value::Object(inline_object))
211211+}
212212+213213+/// Creates a remote attestation by generating a proof record and strongRef entry.
214214+///
215215+/// Returns a tuple containing:
216216+/// - Remote proof record containing the CID for storage in a repository.
217217+pub fn create_remote_attestation(
218218+ record: &Value,
219219+ attestation_metadata: &Value,
220220+) -> Result<Value, AttestationError> {
221221+ let metadata = attestation_metadata
222222+ .as_object()
223223+ .cloned()
224224+ .ok_or(AttestationError::MetadataMustBeObject)?;
225225+226226+ let metadata_value = Value::Object(metadata.clone());
227227+ let signing_record = prepare_signing_record(record, &metadata_value)?;
228228+ let cid = create_cid(&signing_record)?;
229229+230230+ let mut remote_attestation = metadata.clone();
231231+ remote_attestation.insert("cid".to_string(), Value::String(cid.to_string()));
232232+233233+ Ok(Value::Object(remote_attestation))
234234+}
235235+236236+/// Normalize raw signature bytes to the required low-S form.
237237+///
238238+/// This helper ensures signatures produced by signing APIs comply with the
239239+/// specification requirements before embedding them in attestation objects.
240240+pub fn normalize_signature(
241241+ signature: Vec<u8>,
242242+ key_type: &KeyType,
243243+) -> Result<Vec<u8>, AttestationError> {
244244+ match key_type {
245245+ KeyType::P256Private | KeyType::P256Public => normalize_p256(signature),
246246+ KeyType::K256Private | KeyType::K256Public => normalize_k256(signature),
247247+ other => Err(AttestationError::UnsupportedKeyType {
248248+ key_type: other.clone(),
249249+ }),
250250+ }
251251+}
252252+253253+/// Attach a remote attestation entry (strongRef) to the record.
254254+///
255255+/// The `attestation` value must be an object containing:
256256+/// - `$type`: `"com.atproto.repo.strongRef"`
257257+/// - `cid`: base32 CID string referencing the remote proof record
258258+/// - Optional `uri`: AT URI for the remote record
259259+pub fn create_remote_attestation_reference(
260260+ record: &Value,
261261+ attestation: &Value,
262262+ did: &str,
263263+) -> Result<Value, AttestationError> {
264264+ let mut result = record
265265+ .as_object()
266266+ .cloned()
267267+ .ok_or(AttestationError::RecordMustBeObject)?;
268268+269269+ let attestation = attestation
270270+ .as_object()
271271+ .cloned()
272272+ .ok_or(AttestationError::MetadataMustBeObject)?;
273273+274274+ let remote_object_type = attestation
275275+ .get("$type")
276276+ .and_then(Value::as_str)
277277+ .filter(|value| !value.is_empty())
278278+ .ok_or(AttestationError::RemoteAttestationMissingCid)?;
279279+280280+ let tid = Tid::new();
281281+282282+ let attestion_cid = create_plain_cid(&serde_json::Value::Object(attestation.clone()))?;
283283+284284+ let remote_object = json!({
285285+ "$type": STRONG_REF_TYPE,
286286+ "uri": format!("at://{did}/{remote_object_type}/{tid}"),
287287+ "cid": attestion_cid.to_string()
288288+ });
289289+290290+ let mut signatures = extract_signatures_vec(&mut result)?;
291291+ signatures.push(remote_object);
292292+ result.insert("signatures".to_string(), Value::Array(signatures));
293293+294294+ Ok(Value::Object(result))
295295+}
296296+297297+/// Attach an inline attestation entry containing signature bytes.
298298+///
299299+/// The `attestation` value must be an object containing:
300300+/// - `$type`: union discriminator (must NOT be `com.atproto.repo.strongRef`)
301301+/// - `key`: verification method reference used to sign
302302+/// - `signature`: object with `$bytes` base64 signature
303303+/// Additional custom fields are preserved for `$sig` metadata.
304304+pub fn create_inline_attestation_reference(
305305+ record: &Value,
306306+ attestation: &Value,
307307+) -> Result<Value, AttestationError> {
308308+ let mut result = record
309309+ .as_object()
310310+ .cloned()
311311+ .ok_or(AttestationError::RecordMustBeObject)?;
312312+313313+ let inline_object = attestation
314314+ .as_object()
315315+ .cloned()
316316+ .ok_or(AttestationError::MetadataMustBeObject)?;
317317+318318+ let signature_type = inline_object
319319+ .get("$type")
320320+ .and_then(Value::as_str)
321321+ .ok_or_else(|| AttestationError::MetadataMissingField {
322322+ field: "$type".to_string(),
323323+ })?;
324324+325325+ if signature_type == STRONG_REF_TYPE {
326326+ return Err(AttestationError::InlineAttestationTypeInvalid);
327327+ }
328328+329329+ inline_object
330330+ .get("key")
331331+ .and_then(Value::as_str)
332332+ .filter(|value| !value.is_empty())
333333+ .ok_or_else(|| AttestationError::SignatureMissingField {
334334+ field: "key".to_string(),
335335+ })?;
336336+337337+ let signature_bytes = inline_object
338338+ .get("signature")
339339+ .and_then(Value::as_object)
340340+ .and_then(|object| object.get("$bytes"))
341341+ .and_then(Value::as_str)
342342+ .filter(|value| !value.is_empty())
343343+ .ok_or(AttestationError::SignatureBytesFormatInvalid)?;
344344+345345+ // Ensure the signature bytes decode cleanly to catch malformed input early.
346346+ let _ = BASE64
347347+ .decode(signature_bytes)
348348+ .map_err(|error| AttestationError::SignatureDecodingFailed { error })?;
349349+350350+ let mut signatures = extract_signatures_vec(&mut result)?;
351351+ signatures.push(Value::Object(inline_object));
352352+ result.insert("signatures".to_string(), Value::Array(signatures));
353353+ result.remove("$sig");
354354+355355+ Ok(Value::Object(result))
356356+}
357357+358358+/// Verify a single attestation entry at the specified index without a record resolver.
359359+///
360360+/// Inline signatures are reconstructed into `$sig` metadata, a CID is generated,
361361+/// and the signature bytes are validated against the resolved public key.
362362+/// Remote attestations will be reported as unverified.
363363+///
364364+/// This is a convenience function for the common case where no record resolver is needed.
365365+/// For verifying remote attestations, use [`verify_signature_with_resolver`].
366366+pub async fn verify_signature(
367367+ record: &Value,
368368+ index: usize,
369369+ key_resolver: Option<&dyn KeyResolver>,
370370+) -> Result<VerificationReport, AttestationError> {
371371+ verify_signature_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>(
372372+ record,
373373+ index,
374374+ key_resolver,
375375+ None,
376376+ )
377377+ .await
378378+}
379379+380380+/// Verify a single attestation entry at the specified index with optional record resolver.
381381+///
382382+/// Inline signatures are reconstructed into `$sig` metadata, a CID is generated,
383383+/// and the signature bytes are validated against the resolved public key.
384384+/// Remote attestations can be verified if a `record_resolver` is provided to fetch
385385+/// the proof record via AT-URI. Without a record resolver, remote attestations are
386386+/// reported as unverified.
387387+pub async fn verify_signature_with_resolver<R>(
388388+ record: &Value,
389389+ index: usize,
390390+ key_resolver: Option<&dyn KeyResolver>,
391391+ record_resolver: Option<&R>,
392392+) -> Result<VerificationReport, AttestationError>
393393+where
394394+ R: atproto_client::record_resolver::RecordResolver,
395395+{
396396+ let signatures_array = extract_signatures_array(record)?;
397397+ let signature_entry = signatures_array
398398+ .get(index)
399399+ .ok_or(AttestationError::SignatureIndexOutOfBounds { index })?;
400400+401401+ let signature_map =
402402+ signature_entry
403403+ .as_object()
404404+ .ok_or_else(|| AttestationError::SignatureMissingField {
405405+ field: "object".to_string(),
406406+ })?;
407407+408408+ let signature_type = signature_map
409409+ .get("$type")
410410+ .and_then(Value::as_str)
411411+ .map(ToOwned::to_owned);
412412+413413+ let report_kind = match signature_type.as_deref() {
414414+ Some(STRONG_REF_TYPE) => AttestationKind::Remote,
415415+ _ => AttestationKind::Inline,
416416+ };
417417+418418+ let key_reference = signature_map
419419+ .get("key")
420420+ .and_then(Value::as_str)
421421+ .map(ToOwned::to_owned);
422422+423423+ let status = match report_kind {
424424+ AttestationKind::Remote => {
425425+ match record_resolver {
426426+ Some(resolver) => {
427427+ match verify_remote_attestation(record, signature_map, resolver).await {
428428+ Ok(cid) => VerificationStatus::Valid { cid },
429429+ Err(error) => VerificationStatus::Invalid { error },
430430+ }
431431+ }
432432+ None => VerificationStatus::Unverified {
433433+ reason: "Remote attestations require a record resolver to fetch the proof record via strongRef.".to_string(),
434434+ },
435435+ }
436436+ }
437437+ AttestationKind::Inline => {
438438+ match verify_inline_attestation(record, signature_map, key_resolver).await {
439439+ Ok(cid) => VerificationStatus::Valid { cid },
440440+ Err(error) => VerificationStatus::Invalid { error },
441441+ }
442442+ }
443443+ };
444444+445445+ Ok(VerificationReport {
446446+ index,
447447+ kind: report_kind,
448448+ signature_type,
449449+ key: key_reference,
450450+ status,
451451+ })
452452+}
453453+454454+/// Verify all attestation entries attached to the record without a record resolver.
455455+///
456456+/// Returns a report per signature. Structural issues with the record (for
457457+/// example, a missing `signatures` array) are returned as an error.
458458+///
459459+/// Remote attestations will be reported as unverified. For verifying remote
460460+/// attestations, use [`verify_all_signatures_with_resolver`].
461461+pub async fn verify_all_signatures(
462462+ record: &Value,
463463+ key_resolver: Option<&dyn KeyResolver>,
464464+) -> Result<Vec<VerificationReport>, AttestationError> {
465465+ verify_all_signatures_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>(
466466+ record,
467467+ key_resolver,
468468+ None,
469469+ )
470470+ .await
471471+}
472472+473473+/// Verify all attestation entries attached to the record with optional record resolver.
474474+///
475475+/// Returns a report per signature. Structural issues with the record (for
476476+/// example, a missing `signatures` array) are returned as an error.
477477+///
478478+/// If a `record_resolver` is provided, remote attestations will be fetched and verified.
479479+/// Otherwise, remote attestations will be reported as unverified.
480480+pub async fn verify_all_signatures_with_resolver<R>(
481481+ record: &Value,
482482+ key_resolver: Option<&dyn KeyResolver>,
483483+ record_resolver: Option<&R>,
484484+) -> Result<Vec<VerificationReport>, AttestationError>
485485+where
486486+ R: atproto_client::record_resolver::RecordResolver,
487487+{
488488+ let signatures_array = extract_signatures_array(record)?;
489489+ let mut reports = Vec::with_capacity(signatures_array.len());
490490+491491+ for index in 0..signatures_array.len() {
492492+ reports.push(
493493+ verify_signature_with_resolver(record, index, key_resolver, record_resolver).await?,
494494+ );
495495+ }
496496+497497+ Ok(reports)
498498+}
499499+500500+async fn verify_remote_attestation<R>(
501501+ record: &Value,
502502+ signature_object: &Map<String, Value>,
503503+ record_resolver: &R,
504504+) -> Result<Cid, AttestationError>
505505+where
506506+ R: atproto_client::record_resolver::RecordResolver,
507507+{
508508+ // Extract the strongRef URI and CID
509509+ let uri = signature_object
510510+ .get("uri")
511511+ .and_then(Value::as_str)
512512+ .ok_or_else(|| AttestationError::SignatureMissingField {
513513+ field: "uri".to_string(),
514514+ })?;
515515+516516+ let expected_cid_str = signature_object
517517+ .get("cid")
518518+ .and_then(Value::as_str)
519519+ .ok_or_else(|| AttestationError::SignatureMissingField {
520520+ field: "cid".to_string(),
521521+ })?;
522522+523523+ // Fetch the proof record from the URI
524524+ let proof_record: Value = record_resolver.resolve(uri).await.map_err(|error| {
525525+ AttestationError::RemoteAttestationFetchFailed {
526526+ uri: uri.to_string(),
527527+ error,
528528+ }
529529+ })?;
530530+531531+ // Verify the proof record CID matches
532532+ let proof_cid = create_plain_cid(&proof_record)?;
533533+ if proof_cid.to_string() != expected_cid_str {
534534+ return Err(AttestationError::RemoteAttestationCidMismatch {
535535+ expected: expected_cid_str.to_string(),
536536+ actual: proof_cid.to_string(),
537537+ });
538538+ }
539539+540540+ // Extract the CID from the proof record
541541+ let attestation_cid_str = proof_record
542542+ .get("cid")
543543+ .and_then(Value::as_str)
544544+ .ok_or_else(|| AttestationError::SignatureMissingField {
545545+ field: "cid".to_string(),
546546+ })?;
547547+548548+ // Parse the attestation CID
549549+ let attestation_cid =
550550+ attestation_cid_str
551551+ .parse::<Cid>()
552552+ .map_err(|_| AttestationError::InvalidCid {
553553+ cid: attestation_cid_str.to_string(),
554554+ })?;
555555+556556+ // Prepare the signing record using the proof record as metadata (without the CID field)
557557+ let mut proof_metadata = proof_record
558558+ .as_object()
559559+ .cloned()
560560+ .ok_or(AttestationError::RecordMustBeObject)?;
561561+ proof_metadata.remove("cid");
562562+563563+ let signing_record = prepare_signing_record(record, &Value::Object(proof_metadata))?;
564564+ let computed_cid = create_cid(&signing_record)?;
565565+566566+ // Verify the CID matches
567567+ if computed_cid != attestation_cid {
568568+ return Err(AttestationError::RemoteAttestationCidMismatch {
569569+ expected: attestation_cid.to_string(),
570570+ actual: computed_cid.to_string(),
571571+ });
572572+ }
573573+574574+ Ok(computed_cid)
575575+}
576576+577577+async fn verify_inline_attestation(
578578+ record: &Value,
579579+ signature_object: &Map<String, Value>,
580580+ key_resolver: Option<&dyn KeyResolver>,
581581+) -> Result<Cid, AttestationError> {
582582+ let key_reference = signature_object
583583+ .get("key")
584584+ .and_then(Value::as_str)
585585+ .ok_or_else(|| AttestationError::SignatureMissingField {
586586+ field: "key".to_string(),
587587+ })?;
588588+589589+ let key_data = resolve_key_reference(key_reference, key_resolver).await?;
590590+591591+ let signature_bytes = signature_object
592592+ .get("signature")
593593+ .and_then(Value::as_object)
594594+ .and_then(|object| object.get("$bytes"))
595595+ .and_then(Value::as_str)
596596+ .ok_or(AttestationError::SignatureBytesFormatInvalid)?;
597597+598598+ let signature_bytes = BASE64
599599+ .decode(signature_bytes)
600600+ .map_err(|error| AttestationError::SignatureDecodingFailed { error })?;
601601+602602+ ensure_normalized_signature(&key_data, &signature_bytes)?;
603603+604604+ let mut sig_metadata = signature_object.clone();
605605+ sig_metadata.remove("signature");
606606+607607+ let signing_record = prepare_signing_record(record, &Value::Object(sig_metadata))?;
608608+ let cid = create_cid(&signing_record)?;
609609+ let cid_bytes = cid.to_bytes();
610610+611611+ validate(&key_data, &signature_bytes, &cid_bytes)
612612+ .map_err(|error| AttestationError::SignatureValidationFailed { error })?;
613613+614614+ Ok(cid)
615615+}
616616+617617+async fn resolve_key_reference(
618618+ key_reference: &str,
619619+ key_resolver: Option<&dyn KeyResolver>,
620620+) -> Result<KeyData, AttestationError> {
621621+ if let Some(base) = key_reference.split('#').next() {
622622+ if let Ok(key_data) = identify_key(base) {
623623+ return Ok(key_data);
624624+ }
625625+ }
626626+627627+ if let Ok(key_data) = identify_key(key_reference) {
628628+ return Ok(key_data);
629629+ }
630630+631631+ let resolver = key_resolver.ok_or_else(|| AttestationError::KeyResolverRequired {
632632+ key: key_reference.to_string(),
633633+ })?;
634634+635635+ resolver
636636+ .resolve(key_reference)
637637+ .await
638638+ .map_err(|error| AttestationError::KeyResolutionFailed {
639639+ key: key_reference.to_string(),
640640+ error,
641641+ })
642642+}
643643+644644+fn normalize_p256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> {
645645+ if signature.len() != 64 {
646646+ return Err(AttestationError::SignatureLengthInvalid {
647647+ expected: 64,
648648+ actual: signature.len(),
649649+ });
650650+ }
651651+652652+ let parsed = P256Signature::from_slice(&signature).map_err(|_| {
653653+ AttestationError::SignatureLengthInvalid {
654654+ expected: 64,
655655+ actual: signature.len(),
656656+ }
657657+ })?;
658658+659659+ let normalized = parsed.normalize_s().unwrap_or(parsed);
660660+661661+ Ok(normalized.to_vec())
662662+}
663663+664664+fn normalize_k256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> {
665665+ if signature.len() != 64 {
666666+ return Err(AttestationError::SignatureLengthInvalid {
667667+ expected: 64,
668668+ actual: signature.len(),
669669+ });
670670+ }
671671+672672+ let parsed = K256Signature::from_slice(&signature).map_err(|_| {
673673+ AttestationError::SignatureLengthInvalid {
674674+ expected: 64,
675675+ actual: signature.len(),
676676+ }
677677+ })?;
678678+679679+ let normalized = parsed.normalize_s().unwrap_or(parsed);
680680+681681+ Ok(normalized.to_vec())
682682+}
683683+684684+fn ensure_normalized_signature(
685685+ key_data: &KeyData,
686686+ signature: &[u8],
687687+) -> Result<(), AttestationError> {
688688+ match key_data.key_type() {
689689+ KeyType::P256Private | KeyType::P256Public => {
690690+ if signature.len() != 64 {
691691+ return Err(AttestationError::SignatureLengthInvalid {
692692+ expected: 64,
693693+ actual: signature.len(),
694694+ });
695695+ }
696696+697697+ let parsed = P256Signature::from_slice(signature).map_err(|_| {
698698+ AttestationError::SignatureLengthInvalid {
699699+ expected: 64,
700700+ actual: signature.len(),
701701+ }
702702+ })?;
703703+704704+ if bool::from(parsed.s().is_high()) {
705705+ return Err(AttestationError::SignatureNotNormalized);
706706+ }
707707+ }
708708+ KeyType::K256Private | KeyType::K256Public => {
709709+ if signature.len() != 64 {
710710+ return Err(AttestationError::SignatureLengthInvalid {
711711+ expected: 64,
712712+ actual: signature.len(),
713713+ });
714714+ }
715715+716716+ let parsed = K256Signature::from_slice(signature).map_err(|_| {
717717+ AttestationError::SignatureLengthInvalid {
718718+ expected: 64,
719719+ actual: signature.len(),
720720+ }
721721+ })?;
722722+723723+ if bool::from(parsed.s().is_high()) {
724724+ return Err(AttestationError::SignatureNotNormalized);
725725+ }
726726+ }
727727+ other => {
728728+ return Err(AttestationError::UnsupportedKeyType {
729729+ key_type: other.clone(),
730730+ });
731731+ }
732732+ }
733733+734734+ Ok(())
735735+}
736736+737737+fn extract_signatures_array(record: &Value) -> Result<&Vec<Value>, AttestationError> {
738738+ let signatures = record.get("signatures");
739739+740740+ match signatures {
741741+ Some(value) => value
742742+ .as_array()
743743+ .ok_or(AttestationError::SignaturesFieldInvalid),
744744+ None => Err(AttestationError::SignaturesArrayMissing),
745745+ }
746746+}
747747+748748+fn extract_signatures_vec(record: &mut Map<String, Value>) -> Result<Vec<Value>, AttestationError> {
749749+ let existing = record.remove("signatures");
750750+751751+ match existing {
752752+ Some(Value::Array(array)) => Ok(array),
753753+ Some(_) => Err(AttestationError::SignaturesFieldInvalid),
754754+ None => Ok(Vec::new()),
755755+ }
756756+}
757757+758758+#[cfg(test)]
759759+mod tests {
760760+ use super::*;
761761+ use atproto_identity::key::{IdentityDocumentKeyResolver, KeyType, generate_key, to_public};
762762+ use atproto_identity::model::{Document, DocumentBuilder, VerificationMethod};
763763+ use atproto_identity::resolve::IdentityResolver;
764764+ use serde_json::json;
765765+ use std::sync::Arc;
766766+767767+ struct StaticResolver {
768768+ document: Document,
769769+ }
770770+771771+ #[async_trait::async_trait]
772772+ impl IdentityResolver for StaticResolver {
773773+ async fn resolve(&self, _subject: &str) -> anyhow::Result<Document> {
774774+ Ok(self.document.clone())
775775+ }
776776+ }
777777+778778+ #[test]
779779+ fn prepare_signing_record_removes_signatures() -> Result<(), AttestationError> {
780780+ let record = json!({
781781+ "$type": "app.bsky.feed.post",
782782+ "text": "hello",
783783+ "signatures": [
784784+ {"$type": "example.sig", "signature": {"$bytes": "dGVzdA=="}, "key": "did:key:zabc"}
785785+ ]
786786+ });
787787+788788+ let metadata = json!({
789789+ "$type": "com.example.inlineSignature",
790790+ "key": "did:key:zabc",
791791+ "purpose": "demo",
792792+ "signature": {"$bytes": "trim"},
793793+ "cid": "bafyignored"
794794+ });
795795+796796+ let prepared = prepare_signing_record(&record, &metadata)?;
797797+ let object = prepared.as_object().unwrap();
798798+ assert!(object.get("signatures").is_none());
799799+ assert!(object.get("sigs").is_none());
800800+ assert!(object.get("$sig").is_some());
801801+802802+ let sig_object = object.get("$sig").unwrap().as_object().unwrap();
803803+ assert_eq!(
804804+ sig_object.get("$type").and_then(Value::as_str),
805805+ Some("com.example.inlineSignature")
806806+ );
807807+ assert_eq!(
808808+ sig_object.get("purpose").and_then(Value::as_str),
809809+ Some("demo")
810810+ );
811811+ assert!(sig_object.get("signature").is_none());
812812+ assert!(sig_object.get("cid").is_none());
813813+814814+ Ok(())
815815+ }
816816+817817+ #[test]
818818+ fn create_cid_produces_expected_codec_and_length() -> Result<(), AttestationError> {
819819+ let prepared = json!({
820820+ "$type": "app.example.record",
821821+ "text": "cid demo",
822822+ "$sig": {
823823+ "$type": "com.example.inlineSignature",
824824+ "key": "did:key:zabc"
825825+ }
826826+ });
827827+828828+ let cid = create_cid(&prepared)?;
829829+ assert_eq!(cid.codec(), 0x71);
830830+ assert_eq!(cid.hash().code(), 0x12);
831831+ assert_eq!(cid.hash().digest().len(), 32);
832832+ assert_eq!(cid.to_bytes().len(), 36);
833833+834834+ Ok(())
835835+ }
836836+837837+ #[test]
838838+ fn create_inline_attestation_appends_signature() -> Result<(), AttestationError> {
839839+ let record = json!({
840840+ "$type": "app.example.record",
841841+ "body": "Important content"
842842+ });
843843+844844+ let inline = json!({
845845+ "$type": "com.example.inlineSignature",
846846+ "key": "did:key:zabc",
847847+ "signature": {"$bytes": "ZHVtbXk="}
848848+ });
849849+850850+ let updated = create_inline_attestation_reference(&record, &inline)?;
851851+ let signatures = updated
852852+ .get("signatures")
853853+ .and_then(Value::as_array)
854854+ .expect("signatures array should exist");
855855+ assert_eq!(signatures.len(), 1);
856856+ assert_eq!(
857857+ signatures[0].get("$type").and_then(Value::as_str),
858858+ Some("com.example.inlineSignature")
859859+ );
860860+861861+ Ok(())
862862+ }
863863+864864+ #[test]
865865+ fn create_remote_attestation_produces_reference_and_proof()
866866+ -> Result<(), Box<dyn std::error::Error>> {
867867+ let record = json!({
868868+ "$type": "app.example.record",
869869+ "body": "remote attestation"
870870+ });
871871+872872+ let metadata = json!({
873873+ "$type": "com.example.inlineSignature"
874874+ });
875875+876876+ let proof_record = create_remote_attestation(&record, &metadata)?;
877877+878878+ let proof_object = proof_record
879879+ .as_object()
880880+ .expect("reference should be an object");
881881+ assert_eq!(
882882+ proof_object.get("$type").and_then(Value::as_str),
883883+ Some("com.example.inlineSignature")
884884+ );
885885+ assert!(
886886+ proof_object.get("cid").and_then(Value::as_str).is_some(),
887887+ "proof must contain a cid"
888888+ );
889889+890890+ Ok(())
891891+ }
892892+893893+ #[tokio::test]
894894+ async fn verify_inline_signature_with_did_key() -> Result<(), Box<dyn std::error::Error>> {
895895+ let private_key = generate_key(KeyType::K256Private)?;
896896+ let public_key = to_public(&private_key)?;
897897+ let key_reference = format!("{}", &public_key);
898898+899899+ let base_record = json!({
900900+ "$type": "app.example.record",
901901+ "body": "Sign me"
902902+ });
903903+904904+ let sig_metadata = json!({
905905+ "$type": "com.example.inlineSignature",
906906+ "key": key_reference,
907907+ "purpose": "unit-test"
908908+ });
909909+910910+ let signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?;
911911+912912+ let report = verify_signature(&signed, 0, None).await?;
913913+ match report.status {
914914+ VerificationStatus::Valid { .. } => {}
915915+ other => panic!("expected valid signature, got {:?}", other),
916916+ }
917917+918918+ Ok(())
919919+ }
920920+921921+ #[tokio::test]
922922+ async fn verify_inline_signature_with_resolver() -> Result<(), Box<dyn std::error::Error>> {
923923+ let private_key = generate_key(KeyType::P256Private)?;
924924+ let public_key = to_public(&private_key)?;
925925+ let key_multibase = format!("{}", &public_key);
926926+ let key_reference = "did:plc:resolvertest#atproto".to_string();
927927+928928+ let document = DocumentBuilder::new()
929929+ .id("did:plc:resolvertest")
930930+ .add_verification_method(VerificationMethod::Multikey {
931931+ id: key_reference.clone(),
932932+ controller: "did:plc:resolvertest".to_string(),
933933+ public_key_multibase: key_multibase
934934+ .strip_prefix("did:key:")
935935+ .unwrap_or(&key_multibase)
936936+ .to_string(),
937937+ extra: std::collections::HashMap::new(),
938938+ })
939939+ .build()
940940+ .unwrap();
941941+942942+ let identity_resolver = Arc::new(StaticResolver { document });
943943+ let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver.clone());
944944+945945+ let base_record = json!({
946946+ "$type": "app.example.record",
947947+ "body": "resolver test"
948948+ });
949949+950950+ let sig_metadata = json!({
951951+ "$type": "com.example.inlineSignature",
952952+ "key": key_reference,
953953+ "scope": "resolver"
954954+ });
955955+956956+ let signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?;
957957+958958+ let report = verify_signature(&signed, 0, Some(&key_resolver)).await?;
959959+ match report.status {
960960+ VerificationStatus::Valid { .. } => {}
961961+ other => panic!("expected valid signature, got {:?}", other),
962962+ }
963963+964964+ Ok(())
965965+ }
966966+967967+ #[tokio::test]
968968+ async fn verify_all_signatures_reports_remote() -> Result<(), Box<dyn std::error::Error>> {
969969+ let record = json!({
970970+ "$type": "app.example.record",
971971+ "signatures": [
972972+ {
973973+ "$type": STRONG_REF_TYPE,
974974+ "cid": "bafyreid473y2gjzvzgjwdj3vpbk2bdzodf5hvbgxncjc62xmy3zsmb3pxq",
975975+ "uri": "at://did:plc:example/com.example.attestation/abc123"
976976+ }
977977+ ]
978978+ });
979979+980980+ let reports = verify_all_signatures(&record, None).await?;
981981+ assert_eq!(reports.len(), 1);
982982+ match &reports[0].status {
983983+ VerificationStatus::Unverified { reason } => {
984984+ assert!(reason.contains("Remote attestations"));
985985+ }
986986+ other => panic!("expected unverified status, got {:?}", other),
987987+ }
988988+989989+ Ok(())
990990+ }
991991+992992+ #[tokio::test]
993993+ async fn verify_detects_tampering() -> Result<(), Box<dyn std::error::Error>> {
994994+ let private_key = generate_key(KeyType::K256Private)?;
995995+ let public_key = to_public(&private_key)?;
996996+ let key_reference = format!("{}", &public_key);
997997+998998+ let base_record = json!({
999999+ "$type": "app.example.record",
10001000+ "body": "original"
10011001+ });
10021002+10031003+ let sig_metadata = json!({
10041004+ "$type": "com.example.inlineSignature",
10051005+ "key": key_reference
10061006+ });
10071007+10081008+ let mut signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?;
10091009+ if let Some(object) = signed.as_object_mut() {
10101010+ object.insert("body".to_string(), json!("tampered"));
10111011+ }
10121012+10131013+ let report = verify_signature(&signed, 0, None).await?;
10141014+ match report.status {
10151015+ VerificationStatus::Invalid { .. } => {}
10161016+ other => panic!("expected invalid signature, got {:?}", other),
10171017+ }
10181018+10191019+ Ok(())
10201020+ }
10211021+}
···2323//! OAuth access tokens and private keys for proof generation.
24242525use std::collections::HashMap;
2626+use std::iter;
26272728use anyhow::Result;
2828-use atproto_identity::url::URLBuilder;
2929+use atproto_identity::url::build_url;
2930use bytes::Bytes;
3031use serde::{Deserialize, Serialize, de::DeserializeOwned};
3132···7778 did: &str,
7879 cid: &str,
7980) -> Result<Bytes> {
8080- let mut url_builder = URLBuilder::new(base_url);
8181- url_builder.path("/xrpc/com.atproto.sync.getBlob");
8282-8383- url_builder.param("did", did);
8484- url_builder.param("cid", cid);
8585-8686- let url = url_builder.build();
8181+ let url = build_url(
8282+ base_url,
8383+ "/xrpc/com.atproto.sync.getBlob",
8484+ [("did", did), ("cid", cid)],
8585+ )?
8686+ .to_string();
87878888 get_bytes(http_client, &url).await
8989}
···112112 rkey: &str,
113113 cid: Option<&str>,
114114) -> Result<GetRecordResponse> {
115115- let mut url_builder = URLBuilder::new(base_url);
116116- url_builder.path("/xrpc/com.atproto.repo.getRecord");
117117-118118- url_builder.param("repo", repo);
119119- url_builder.param("collection", collection);
120120- url_builder.param("rkey", rkey);
121121-115115+ let mut params = vec![("repo", repo), ("collection", collection), ("rkey", rkey)];
122116 if let Some(cid) = cid {
123123- url_builder.param("cid", cid);
117117+ params.push(("cid", cid));
124118 }
125119126126- let url = url_builder.build();
120120+ let url = build_url(base_url, "/xrpc/com.atproto.repo.getRecord", params)?.to_string();
127121128122 match auth {
129123 Auth::None => get_json(http_client, &url)
···218212 collection: String,
219213 params: ListRecordsParams,
220214) -> Result<ListRecordsResponse<T>> {
221221- let mut url_builder = URLBuilder::new(base_url);
222222- url_builder.path("/xrpc/com.atproto.repo.listRecords");
215215+ let mut url = build_url(
216216+ base_url,
217217+ "/xrpc/com.atproto.repo.listRecords",
218218+ iter::empty::<(&str, &str)>(),
219219+ )?;
220220+ {
221221+ let mut pairs = url.query_pairs_mut();
222222+ pairs.append_pair("repo", &repo);
223223+ pairs.append_pair("collection", &collection);
223224224224- // Add query parameters
225225- url_builder.param("repo", &repo);
226226- url_builder.param("collection", &collection);
225225+ if let Some(limit) = params.limit {
226226+ pairs.append_pair("limit", &limit.to_string());
227227+ }
227228228228- if let Some(limit) = params.limit {
229229- url_builder.param("limit", &limit.to_string());
230230- }
229229+ if let Some(cursor) = params.cursor {
230230+ pairs.append_pair("cursor", &cursor);
231231+ }
231232232232- if let Some(cursor) = params.cursor {
233233- url_builder.param("cursor", &cursor);
233233+ if let Some(reverse) = params.reverse {
234234+ pairs.append_pair("reverse", &reverse.to_string());
235235+ }
234236 }
235237236236- if let Some(reverse) = params.reverse {
237237- url_builder.param("reverse", &reverse.to_string());
238238- }
239239-240240- let url = url_builder.build();
238238+ let url = url.to_string();
241239242240 match auth {
243241 Auth::None => get_json(http_client, &url)
···319317 base_url: &str,
320318 record: CreateRecordRequest<T>,
321319) -> Result<CreateRecordResponse> {
322322- let mut url_builder = URLBuilder::new(base_url);
323323- url_builder.path("/xrpc/com.atproto.repo.createRecord");
324324- let url = url_builder.build();
320320+ let url = build_url(
321321+ base_url,
322322+ "/xrpc/com.atproto.repo.createRecord",
323323+ iter::empty::<(&str, &str)>(),
324324+ )?
325325+ .to_string();
325326326327 let value = serde_json::to_value(record)?;
327328···413414 base_url: &str,
414415 record: PutRecordRequest<T>,
415416) -> Result<PutRecordResponse> {
416416- let mut url_builder = URLBuilder::new(base_url);
417417- url_builder.path("/xrpc/com.atproto.repo.putRecord");
418418- let url = url_builder.build();
417417+ let url = build_url(
418418+ base_url,
419419+ "/xrpc/com.atproto.repo.putRecord",
420420+ iter::empty::<(&str, &str)>(),
421421+ )?
422422+ .to_string();
419423420424 let value = serde_json::to_value(record)?;
421425···496500 base_url: &str,
497501 record: DeleteRecordRequest,
498502) -> Result<DeleteRecordResponse> {
499499- let mut url_builder = URLBuilder::new(base_url);
500500- url_builder.path("/xrpc/com.atproto.repo.deleteRecord");
501501- let url = url_builder.build();
503503+ let url = build_url(
504504+ base_url,
505505+ "/xrpc/com.atproto.repo.deleteRecord",
506506+ iter::empty::<(&str, &str)>(),
507507+ )?
508508+ .to_string();
502509503510 let value = serde_json::to_value(record)?;
504511
+26-13
crates/atproto-client/src/com_atproto_server.rs
···1919//! an access JWT token from an authenticated session.
20202121use anyhow::Result;
2222-use atproto_identity::url::URLBuilder;
2222+use atproto_identity::url::build_url;
2323use serde::{Deserialize, Serialize};
2424+use std::iter;
24252526use crate::{
2627 client::{Auth, post_json},
···118119 password: &str,
119120 auth_factor_token: Option<&str>,
120121) -> Result<AppPasswordSession> {
121121- let mut url_builder = URLBuilder::new(base_url);
122122- url_builder.path("/xrpc/com.atproto.server.createSession");
123123- let url = url_builder.build();
122122+ let url = build_url(
123123+ base_url,
124124+ "/xrpc/com.atproto.server.createSession",
125125+ iter::empty::<(&str, &str)>(),
126126+ )?
127127+ .to_string();
124128125129 let request = CreateSessionRequest {
126130 identifier: identifier.to_string(),
···156160 base_url: &str,
157161 refresh_token: &str,
158162) -> Result<RefreshSessionResponse> {
159159- let mut url_builder = URLBuilder::new(base_url);
160160- url_builder.path("/xrpc/com.atproto.server.refreshSession");
161161- let url = url_builder.build();
163163+ let url = build_url(
164164+ base_url,
165165+ "/xrpc/com.atproto.server.refreshSession",
166166+ iter::empty::<(&str, &str)>(),
167167+ )?
168168+ .to_string();
162169163170 // Create a new client with the refresh token in Authorization header
164171 let mut headers = reqwest::header::HeaderMap::new();
···197204 access_token: &str,
198205 name: &str,
199206) -> Result<AppPasswordResponse> {
200200- let mut url_builder = URLBuilder::new(base_url);
201201- url_builder.path("/xrpc/com.atproto.server.createAppPassword");
202202- let url = url_builder.build();
207207+ let url = build_url(
208208+ base_url,
209209+ "/xrpc/com.atproto.server.createAppPassword",
210210+ iter::empty::<(&str, &str)>(),
211211+ )?
212212+ .to_string();
203213204214 let request_body = serde_json::json!({
205215 "name": name
···260270 }
261271 };
262272263263- let mut url_builder = URLBuilder::new(base_url);
264264- url_builder.path("/xrpc/com.atproto.server.deleteSession");
265265- let url = url_builder.build();
273273+ let url = build_url(
274274+ base_url,
275275+ "/xrpc/com.atproto.server.deleteSession",
276276+ iter::empty::<(&str, &str)>(),
277277+ )?
278278+ .to_string();
266279267280 // Create headers with the Bearer token
268281 let mut headers = reqwest::header::HeaderMap::new();
+3
crates/atproto-client/src/lib.rs
···20202121pub mod client;
2222pub mod errors;
2323+pub mod record_resolver;
2424+2525+pub use record_resolver::{HttpRecordResolver, RecordResolver};
23262427mod com_atproto_identity;
2528mod com_atproto_repo;
+77
crates/atproto-client/src/record_resolver.rs
···11+//! Helpers for resolving AT Protocol records referenced by URI.
22+33+use std::str::FromStr;
44+55+use anyhow::{Result, anyhow, bail};
66+use async_trait::async_trait;
77+use atproto_record::aturi::ATURI;
88+99+use crate::{
1010+ client::Auth,
1111+ com::atproto::repo::{GetRecordResponse, get_record},
1212+};
1313+1414+/// Trait for resolving AT Protocol records by `at://` URI.
1515+///
1616+/// Implementations perform the network lookup and deserialize the response into
1717+/// the requested type.
1818+#[async_trait]
1919+pub trait RecordResolver: Send + Sync {
2020+ /// Resolve an AT URI to a typed record.
2121+ async fn resolve<T>(&self, aturi: &str) -> Result<T>
2222+ where
2323+ T: serde::de::DeserializeOwned + Send;
2424+}
2525+2626+/// Resolver that fetches records using public XRPC endpoints.
2727+#[derive(Clone)]
2828+pub struct HttpRecordResolver {
2929+ http_client: reqwest::Client,
3030+ base_url: String,
3131+}
3232+3333+impl HttpRecordResolver {
3434+ /// Create a new resolver using the provided HTTP client and PDS base URL.
3535+ pub fn new(http_client: reqwest::Client, base_url: impl Into<String>) -> Self {
3636+ Self {
3737+ http_client,
3838+ base_url: base_url.into(),
3939+ }
4040+ }
4141+}
4242+4343+#[async_trait]
4444+impl RecordResolver for HttpRecordResolver {
4545+ async fn resolve<T>(&self, aturi: &str) -> Result<T>
4646+ where
4747+ T: serde::de::DeserializeOwned + Send,
4848+ {
4949+ let parsed = ATURI::from_str(aturi).map_err(|error| anyhow!(error))?;
5050+ let auth = Auth::None;
5151+5252+ let response = get_record(
5353+ &self.http_client,
5454+ &auth,
5555+ &self.base_url,
5656+ &parsed.authority,
5757+ &parsed.collection,
5858+ &parsed.record_key,
5959+ None,
6060+ )
6161+ .await?;
6262+6363+ match response {
6464+ GetRecordResponse::Record { value, .. } => {
6565+ serde_json::from_value(value).map_err(|error| anyhow!(error))
6666+ }
6767+ GetRecordResponse::Error(error) => {
6868+ let message = error.error_message();
6969+ if message.is_empty() {
7070+ bail!("Record resolution failed without additional error details");
7171+ }
7272+7373+ bail!(message);
7474+ }
7575+ }
7676+ }
7777+}
···4747//! }
4848//! ```
49495050-use anyhow::Result;
5050+use anyhow::{Context, Result, anyhow};
5151use ecdsa::signature::Signer;
5252use elliptic_curve::JwkEcKey;
5353use elliptic_curve::sec1::ToEncodedPoint;
54545555+use crate::model::VerificationMethod;
5656+use crate::traits::IdentityResolver;
5757+5858+pub use crate::traits::KeyResolver;
5959+use std::sync::Arc;
6060+5561use crate::errors::KeyError;
56625763#[cfg(feature = "zeroize")]
5864use zeroize::{Zeroize, ZeroizeOnDrop};
59656066/// Cryptographic key types supported for AT Protocol identity.
6161-#[derive(Clone, PartialEq)]
6262-#[cfg_attr(debug_assertions, derive(Debug))]
6767+#[derive(Clone, PartialEq, Debug)]
6368#[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))]
6469pub enum KeyType {
6570 /// A p256 (P-256 / secp256r1 / ES256) public key.
···160165 // Add DID key prefix
161166 write!(f, "did:key:{}", multibase_encoded)
162167 }
163163-}
164164-165165-/// Trait for providing cryptographic keys by identifier.
166166-///
167167-/// This trait defines the interface for key providers that can retrieve private keys
168168-/// by their identifier. Implementations must be thread-safe to support concurrent access.
169169-#[async_trait::async_trait]
170170-pub trait KeyProvider: Send + Sync {
171171- /// Retrieves a private key by its identifier.
172172- ///
173173- /// # Arguments
174174- /// * `key_id` - The identifier of the key to retrieve
175175- ///
176176- /// # Returns
177177- /// * `Ok(Some(KeyData))` - If the key was found and successfully retrieved
178178- /// * `Ok(None)` - If no key exists for the given identifier
179179- /// * `Err(anyhow::Error)` - If an error occurred during key retrieval
180180- async fn get_private_key_by_id(&self, key_id: &str) -> Result<Option<KeyData>>;
181168}
182169183170/// DID key method prefix.
···362349 .map_err(|error| KeyError::ECDSAError { error })?;
363350 Ok(signature.to_vec())
364351 }
352352+ }
353353+}
354354+355355+/// Key resolver implementation that fetches DID documents using an [`IdentityResolver`].
356356+#[derive(Clone)]
357357+pub struct IdentityDocumentKeyResolver {
358358+ identity_resolver: Arc<dyn IdentityResolver>,
359359+}
360360+361361+impl IdentityDocumentKeyResolver {
362362+ /// Creates a new key resolver backed by an [`IdentityResolver`].
363363+ pub fn new(identity_resolver: Arc<dyn IdentityResolver>) -> Self {
364364+ Self { identity_resolver }
365365+ }
366366+}
367367+368368+#[async_trait::async_trait]
369369+impl KeyResolver for IdentityDocumentKeyResolver {
370370+ async fn resolve(&self, key: &str) -> Result<KeyData> {
371371+ if let Some(did_key) = key.split('#').next() {
372372+ if let Ok(key_data) = identify_key(did_key) {
373373+ return Ok(key_data);
374374+ }
375375+ } else if let Ok(key_data) = identify_key(key) {
376376+ return Ok(key_data);
377377+ }
378378+379379+ let (did, fragment) = key
380380+ .split_once('#')
381381+ .context("Key reference must contain a DID fragment (e.g., did:example#key)")?;
382382+383383+ if did.is_empty() || fragment.is_empty() {
384384+ return Err(anyhow!(
385385+ "Key reference must include both DID and fragment (received `{key}`)"
386386+ ));
387387+ }
388388+389389+ let document = self.identity_resolver.resolve(did).await?;
390390+ let fragment_with_hash = format!("#{fragment}");
391391+392392+ let public_key_multibase = document
393393+ .verification_method
394394+ .iter()
395395+ .find_map(|method| match method {
396396+ VerificationMethod::Multikey {
397397+ id,
398398+ public_key_multibase,
399399+ ..
400400+ } if id == key || *id == fragment_with_hash => Some(public_key_multibase.clone()),
401401+ _ => None,
402402+ })
403403+ .context(format!(
404404+ "Verification method `{key}` not found in DID document `{did}`"
405405+ ))?;
406406+407407+ let full_key = if public_key_multibase.starts_with("did:key:") {
408408+ public_key_multibase
409409+ } else {
410410+ format!("did:key:{}", public_key_multibase)
411411+ };
412412+413413+ identify_key(&full_key).context("Failed to parse key data from verification method")
365414 }
366415}
367416
+1-1
crates/atproto-identity/src/lib.rs
···1919pub mod model;
2020pub mod plc;
2121pub mod resolve;
2222-pub mod storage;
2322#[cfg(feature = "lru")]
2423pub mod storage_lru;
2424+pub mod traits;
2525pub mod url;
2626pub mod validation;
2727pub mod web;
+95-29
crates/atproto-identity/src/resolve.rs
···3232use crate::validation::{is_valid_did_method_plc, is_valid_handle};
3333use crate::web::query as web_query;
34343535-/// Trait for AT Protocol identity resolution.
3636-///
3737-/// Implementations must be thread-safe (Send + Sync) and usable in async environments.
3838-/// This trait provides the core functionality for resolving AT Protocol subjects
3939-/// (handles or DIDs) to their corresponding DID documents.
4040-#[async_trait::async_trait]
4141-pub trait IdentityResolver: Send + Sync {
4242- /// Resolves an AT Protocol subject to its DID document.
4343- ///
4444- /// Takes a handle or DID, resolves it to a canonical DID, then retrieves
4545- /// the corresponding DID document from the appropriate source (PLC directory or web).
4646- ///
4747- /// # Arguments
4848- /// * `subject` - The AT Protocol handle or DID to resolve
4949- ///
5050- /// # Returns
5151- /// * `Ok(Document)` - The resolved DID document
5252- /// * `Err(anyhow::Error)` - Resolution error with detailed context
5353- async fn resolve(&self, subject: &str) -> Result<Document>;
5454-}
5555-5656-/// Trait for DNS resolution operations.
5757-/// Provides async DNS TXT record lookups for handle resolution.
5858-#[async_trait::async_trait]
5959-pub trait DnsResolver: Send + Sync {
6060- /// Resolves TXT records for a given domain name.
6161- /// Returns a vector of strings representing the TXT record values.
6262- async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>;
6363-}
3535+pub use crate::traits::{DnsResolver, IdentityResolver};
64366537/// Hickory DNS implementation of the DnsResolver trait.
6638/// Wraps hickory_resolver::TokioResolver for TXT record resolution.
···196168 is_valid_handle(trimmed)
197169 .map(InputType::Handle)
198170 .ok_or(ResolveError::InvalidInput)
171171+ }
172172+}
173173+174174+#[cfg(test)]
175175+mod tests {
176176+ use super::*;
177177+ use crate::key::{
178178+ IdentityDocumentKeyResolver, KeyResolver, KeyType, generate_key, identify_key, to_public,
179179+ };
180180+ use crate::model::{DocumentBuilder, VerificationMethod};
181181+ use std::collections::HashMap;
182182+183183+ struct StubIdentityResolver {
184184+ expected: String,
185185+ document: Document,
186186+ }
187187+188188+ #[async_trait::async_trait]
189189+ impl IdentityResolver for StubIdentityResolver {
190190+ async fn resolve(&self, subject: &str) -> Result<Document> {
191191+ if !self.expected.is_empty() {
192192+ assert_eq!(self.expected, subject);
193193+ }
194194+ Ok(self.document.clone())
195195+ }
196196+ }
197197+198198+ #[tokio::test]
199199+ async fn resolves_direct_did_key() -> Result<()> {
200200+ let private_key = generate_key(KeyType::K256Private)?;
201201+ let public_key = to_public(&private_key)?;
202202+ let key_reference = format!("{}", &public_key);
203203+204204+ let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver {
205205+ expected: String::new(),
206206+ document: Document::builder()
207207+ .id("did:plc:placeholder")
208208+ .build()
209209+ .unwrap(),
210210+ }));
211211+212212+ let key_data = resolver.resolve(&key_reference).await?;
213213+ assert_eq!(key_data.bytes(), public_key.bytes());
214214+ Ok(())
215215+ }
216216+217217+ #[tokio::test]
218218+ async fn resolves_literal_did_key_reference() -> Result<()> {
219219+ let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver {
220220+ expected: String::new(),
221221+ document: Document::builder()
222222+ .id("did:example:unused".to_string())
223223+ .build()
224224+ .unwrap(),
225225+ }));
226226+227227+ let sample = "did:key:zDnaezRmyM3NKx9NCphGiDFNBEMyR2sTZhhMGTseXCU2iXn53";
228228+ let expected = identify_key(sample)?;
229229+ let resolved = resolver.resolve(sample).await?;
230230+ assert_eq!(resolved.bytes(), expected.bytes());
231231+ Ok(())
232232+ }
233233+234234+ #[tokio::test]
235235+ async fn resolves_via_identity_document() -> Result<()> {
236236+ let private_key = generate_key(KeyType::P256Private)?;
237237+ let public_key = to_public(&private_key)?;
238238+ let public_key_multibase = format!("{}", &public_key)
239239+ .strip_prefix("did:key:")
240240+ .unwrap()
241241+ .to_string();
242242+243243+ let did = "did:web:example.com";
244244+ let method_id = format!("{did}#atproto");
245245+246246+ let document = DocumentBuilder::new()
247247+ .id(did.to_string())
248248+ .add_verification_method(VerificationMethod::Multikey {
249249+ id: method_id.clone(),
250250+ controller: did.to_string(),
251251+ public_key_multibase,
252252+ extra: HashMap::new(),
253253+ })
254254+ .build()
255255+ .unwrap();
256256+257257+ let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver {
258258+ expected: did.to_string(),
259259+ document,
260260+ }));
261261+262262+ let key_data = resolver.resolve(&method_id).await?;
263263+ assert_eq!(key_data.bytes(), public_key.bytes());
264264+ Ok(())
199265 }
200266}
201267
-212
crates/atproto-identity/src/storage.rs
···11-//! DID document storage abstraction.
22-//!
33-//! Storage trait for DID document CRUD operations supporting multiple
44-//! backends (database, file system, memory) with consistent interface.
55-66-use anyhow::Result;
77-88-use crate::model::Document;
99-1010-/// Trait for implementing DID document CRUD operations across different storage backends.
1111-///
1212-/// This trait provides an abstraction layer for storing and retrieving DID documents,
1313-/// allowing different implementations for various storage systems such as databases, file systems,
1414-/// in-memory stores, or cloud storage services.
1515-///
1616-/// All methods return `anyhow::Result` to allow implementations to use their own error types
1717-/// while providing a consistent interface for callers. Implementations should handle their
1818-/// specific error conditions and convert them to appropriate error messages.
1919-///
2020-/// ## Thread Safety
2121-///
2222-/// This trait requires implementations to be thread-safe (`Send + Sync`), meaning:
2323-/// - `Send`: The storage implementation can be moved between threads
2424-/// - `Sync`: The storage implementation can be safely accessed from multiple threads simultaneously
2525-///
2626-/// This is essential for async applications where the storage might be accessed from different
2727-/// async tasks running on different threads. Implementations should use appropriate
2828-/// synchronization primitives (like `Arc<Mutex<>>`, `RwLock`, or database connection pools)
2929-/// to ensure thread safety.
3030-///
3131-/// ## Usage
3232-///
3333-/// Implementors of this trait can provide storage for AT Protocol DID documents in any backend:
3434-///
3535-/// ```rust,ignore
3636-/// use atproto_identity::storage::DidDocumentStorage;
3737-/// use atproto_identity::model::Document;
3838-/// use anyhow::Result;
3939-/// use std::sync::Arc;
4040-/// use tokio::sync::RwLock;
4141-/// use std::collections::HashMap;
4242-///
4343-/// // Thread-safe in-memory storage using Arc<RwLock<>>
4444-/// #[derive(Clone)]
4545-/// struct InMemoryStorage {
4646-/// data: Arc<RwLock<HashMap<String, Document>>>, // DID -> Document mapping
4747-/// }
4848-///
4949-/// #[async_trait::async_trait]
5050-/// impl DidDocumentStorage for InMemoryStorage {
5151-/// async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> {
5252-/// let data = self.data.read().await;
5353-/// Ok(data.get(did).cloned())
5454-/// }
5555-///
5656-/// async fn store_document(&self, document: Document) -> Result<()> {
5757-/// let mut data = self.data.write().await;
5858-/// data.insert(document.id.clone(), document);
5959-/// Ok(())
6060-/// }
6161-///
6262-/// async fn delete_document_by_did(&self, did: &str) -> Result<()> {
6363-/// let mut data = self.data.write().await;
6464-/// data.remove(did);
6565-/// Ok(())
6666-/// }
6767-/// }
6868-///
6969-/// // Database storage with thread-safe connection pool
7070-/// struct DatabaseStorage {
7171-/// pool: sqlx::Pool<sqlx::Postgres>, // Thread-safe connection pool
7272-/// }
7373-///
7474-/// #[async_trait::async_trait]
7575-/// impl DidDocumentStorage for DatabaseStorage {
7676-/// async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> {
7777-/// // Database connection pools are thread-safe
7878-/// let row: Option<(serde_json::Value,)> = sqlx::query_as(
7979-/// "SELECT document FROM did_documents WHERE did = $1"
8080-/// )
8181-/// .bind(did)
8282-/// .fetch_optional(&self.pool)
8383-/// .await?;
8484-///
8585-/// if let Some((doc_json,)) = row {
8686-/// let document: Document = serde_json::from_value(doc_json)?;
8787-/// Ok(Some(document))
8888-/// } else {
8989-/// Ok(None)
9090-/// }
9191-/// }
9292-///
9393-/// async fn store_document(&self, document: Document) -> Result<()> {
9494-/// let doc_json = serde_json::to_value(&document)?;
9595-/// sqlx::query("INSERT INTO did_documents (did, document) VALUES ($1, $2) ON CONFLICT (did) DO UPDATE SET document = $2")
9696-/// .bind(&document.id)
9797-/// .bind(doc_json)
9898-/// .execute(&self.pool)
9999-/// .await?;
100100-/// Ok(())
101101-/// }
102102-///
103103-/// async fn delete_document_by_did(&self, did: &str) -> Result<()> {
104104-/// sqlx::query("DELETE FROM did_documents WHERE did = $1")
105105-/// .bind(did)
106106-/// .execute(&self.pool)
107107-/// .await?;
108108-/// Ok(())
109109-/// }
110110-/// }
111111-/// ```
112112-#[async_trait::async_trait]
113113-pub trait DidDocumentStorage: Send + Sync {
114114- /// Retrieves a DID document associated with the given DID.
115115- ///
116116- /// This method looks up the complete DID document that is currently stored for the provided
117117- /// DID (Decentralized Identifier). The document contains services, verification methods,
118118- /// and other identity information for the DID.
119119- ///
120120- /// # Arguments
121121- /// * `did` - The DID (Decentralized Identifier) to look up. Should be in the format
122122- /// `did:method:identifier` (e.g., "did:plc:bv6ggog3tya2z3vxsub7hnal")
123123- ///
124124- /// # Returns
125125- /// * `Ok(Some(document))` - If a document is found for the given DID
126126- /// * `Ok(None)` - If no document is currently stored for the DID
127127- /// * `Err(error)` - If an error occurs during retrieval (storage failure, invalid DID format, etc.)
128128- ///
129129- /// # Examples
130130- ///
131131- /// ```rust,ignore
132132- /// let storage = MyStorage::new();
133133- /// let document = storage.get_document_by_did("did:plc:bv6ggog3tya2z3vxsub7hnal").await?;
134134- /// match document {
135135- /// Some(doc) => {
136136- /// println!("Found document for DID: {}", doc.id);
137137- /// if let Some(handle) = doc.handles() {
138138- /// println!("Primary handle: {}", handle);
139139- /// }
140140- /// },
141141- /// None => println!("No document found for this DID"),
142142- /// }
143143- /// ```
144144- async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>>;
145145-146146- /// Stores or updates a DID document.
147147- ///
148148- /// This method creates a new DID document entry or updates an existing one.
149149- /// In the AT Protocol ecosystem, this operation typically occurs when a DID document
150150- /// is resolved from the network, updated by the identity owner, or cached for performance.
151151- ///
152152- /// Implementations should ensure that:
153153- /// - The document's DID (`document.id`) is used as the key for storage
154154- /// - The operation is atomic (either fully succeeds or fully fails)
155155- /// - Any existing document for the same DID is properly replaced
156156- /// - The complete document structure is preserved
157157- ///
158158- /// # Arguments
159159- /// * `document` - The complete DID document to store. The document's `id` field
160160- /// will be used as the storage key.
161161- ///
162162- /// # Returns
163163- /// * `Ok(())` - If the document was successfully stored or updated
164164- /// * `Err(error)` - If an error occurs during the operation (storage failure,
165165- /// serialization failure, constraint violation, etc.)
166166- ///
167167- /// # Examples
168168- ///
169169- /// ```rust,ignore
170170- /// let storage = MyStorage::new();
171171- /// let document = Document {
172172- /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(),
173173- /// also_known_as: vec!["at://alice.bsky.social".to_string()],
174174- /// service: vec![/* services */],
175175- /// verification_method: vec![/* verification methods */],
176176- /// extra: HashMap::new(),
177177- /// };
178178- /// storage.store_document(document).await?;
179179- /// println!("Document successfully stored");
180180- /// ```
181181- async fn store_document(&self, document: Document) -> Result<()>;
182182-183183- /// Deletes a DID document by its DID.
184184- ///
185185- /// This method removes a DID document from storage using the DID as the identifier.
186186- /// This operation is typically used when cleaning up expired cache entries, removing
187187- /// invalid documents, or when an identity is deactivated.
188188- ///
189189- /// Implementations should:
190190- /// - Handle the case where the DID doesn't exist gracefully (return Ok(()))
191191- /// - Ensure the deletion is atomic
192192- /// - Clean up any related data or indexes
193193- /// - Preserve referential integrity if applicable
194194- ///
195195- /// # Arguments
196196- /// * `did` - The DID identifying the document to delete.
197197- /// Should be in the format `did:method:identifier`
198198- /// (e.g., "did:plc:bv6ggog3tya2z3vxsub7hnal")
199199- ///
200200- /// # Returns
201201- /// * `Ok(())` - If the document was successfully deleted or didn't exist
202202- /// * `Err(error)` - If an error occurs during deletion (storage failure, etc.)
203203- ///
204204- /// # Examples
205205- ///
206206- /// ```rust,ignore
207207- /// let storage = MyStorage::new();
208208- /// storage.delete_document_by_did("did:plc:bv6ggog3tya2z3vxsub7hnal").await?;
209209- /// println!("Document deleted");
210210- /// ```
211211- async fn delete_document_by_did(&self, did: &str) -> Result<()>;
212212-}
+8-7
crates/atproto-identity/src/storage_lru.rs
···11111212use crate::errors::StorageError;
1313use crate::model::Document;
1414-use crate::storage::DidDocumentStorage;
1414+use crate::traits::DidDocumentStorage;
15151616/// An LRU-based implementation of `DidDocumentStorage` that maintains a fixed-size cache of DID documents.
1717///
···5454///
5555/// ```rust
5656/// use atproto_identity::storage_lru::LruDidDocumentStorage;
5757-/// use atproto_identity::storage::DidDocumentStorage;
5757+/// use atproto_identity::traits::DidDocumentStorage;
5858/// use atproto_identity::model::Document;
5959/// use std::num::NonZeroUsize;
6060/// use std::collections::HashMap;
···164164 ///
165165 /// ```rust
166166 /// use atproto_identity::storage_lru::LruDidDocumentStorage;
167167- /// use atproto_identity::storage::DidDocumentStorage;
167167+ /// use atproto_identity::traits::DidDocumentStorage;
168168 /// use atproto_identity::model::Document;
169169 /// use std::num::NonZeroUsize;
170170 /// use std::collections::HashMap;
···251251 ///
252252 /// ```rust
253253 /// use atproto_identity::storage_lru::LruDidDocumentStorage;
254254- /// use atproto_identity::storage::DidDocumentStorage;
254254+ /// use atproto_identity::traits::DidDocumentStorage;
255255 /// use atproto_identity::model::Document;
256256 /// use std::num::NonZeroUsize;
257257 /// use std::collections::HashMap;
···305305 ///
306306 /// ```rust
307307 /// use atproto_identity::storage_lru::LruDidDocumentStorage;
308308- /// use atproto_identity::storage::DidDocumentStorage;
308308+ /// use atproto_identity::traits::DidDocumentStorage;
309309 /// use atproto_identity::model::Document;
310310 /// use std::num::NonZeroUsize;
311311 /// use std::collections::HashMap;
···370370 ///
371371 /// ```rust
372372 /// use atproto_identity::storage_lru::LruDidDocumentStorage;
373373- /// use atproto_identity::storage::DidDocumentStorage;
373373+ /// use atproto_identity::traits::DidDocumentStorage;
374374 /// use atproto_identity::model::Document;
375375 /// use std::num::NonZeroUsize;
376376 /// use std::collections::HashMap;
···460460 ///
461461 /// ```rust
462462 /// use atproto_identity::storage_lru::LruDidDocumentStorage;
463463- /// use atproto_identity::storage::DidDocumentStorage;
463463+ /// use atproto_identity::traits::DidDocumentStorage;
464464 /// use atproto_identity::model::Document;
465465 /// use std::num::NonZeroUsize;
466466 /// use std::collections::HashMap;
···507507#[cfg(test)]
508508mod tests {
509509 use super::*;
510510+ use crate::traits::DidDocumentStorage;
510511 use std::collections::HashMap;
511512 use std::num::NonZeroUsize;
512513
+49
crates/atproto-identity/src/traits.rs
···11+//! Shared trait definitions for AT Protocol identity operations.
22+//!
33+//! This module centralizes async traits used across the identity crate so they can
44+//! be implemented without introducing circular module dependencies.
55+66+use anyhow::Result;
77+use async_trait::async_trait;
88+99+use crate::errors::ResolveError;
1010+use crate::key::KeyData;
1111+use crate::model::Document;
1212+1313+/// Trait for AT Protocol identity resolution.
1414+///
1515+/// Implementations must resolve handles or DIDs to canonical DID documents.
1616+#[async_trait]
1717+pub trait IdentityResolver: Send + Sync {
1818+ /// Resolves an AT Protocol subject to its DID document.
1919+ async fn resolve(&self, subject: &str) -> Result<Document>;
2020+}
2121+2222+/// Trait for DNS resolution operations used during handle lookups.
2323+#[async_trait]
2424+pub trait DnsResolver: Send + Sync {
2525+ /// Resolves TXT records for a given domain name.
2626+ async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>;
2727+}
2828+2929+/// Trait for retrieving private keys by identifier.
3030+#[async_trait]
3131+/// Trait for resolving key references (e.g., DID verification methods) to [`KeyData`].
3232+#[async_trait]
3333+pub trait KeyResolver: Send + Sync {
3434+ /// Resolves a key reference string into key material.
3535+ async fn resolve(&self, key: &str) -> Result<KeyData>;
3636+}
3737+3838+/// Trait for DID document storage backends.
3939+#[async_trait]
4040+pub trait DidDocumentStorage: Send + Sync {
4141+ /// Retrieves a DID document if present.
4242+ async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>>;
4343+4444+ /// Stores or updates a DID document.
4545+ async fn store_document(&self, document: Document) -> Result<()>;
4646+4747+ /// Deletes a DID document by DID.
4848+ async fn delete_document_by_did(&self, did: &str) -> Result<()>;
4949+}
+48-119
crates/atproto-identity/src/url.rs
···11-//! URL construction utilities for HTTP endpoints.
11+//! URL construction utilities leveraging the `url` crate.
22//!
33-//! Build well-formed HTTP request URLs with parameter encoding
44-//! and query string generation.
55-66-/// A single query parameter as a key-value pair.
77-pub type QueryParam<'a> = (&'a str, &'a str);
88-/// A collection of query parameters.
99-pub type QueryParams<'a> = Vec<QueryParam<'a>>;
1010-1111-/// Builds a query string from a collection of query parameters.
1212-///
1313-/// # Arguments
1414-///
1515-/// * `query` - Collection of key-value pairs to build into a query string
1616-///
1717-/// # Returns
1818-///
1919-/// A formatted query string with URL-encoded parameters
2020-pub fn build_querystring(query: QueryParams) -> String {
2121- query.iter().fold(String::new(), |acc, &tuple| {
2222- acc + tuple.0 + "=" + tuple.1 + "&"
2323- })
2424-}
33+//! Provides helpers for building URLs and appending query parameters
44+//! without manual string concatenation.
2552626-/// Builder for constructing URLs with host, path, and query parameters.
2727-pub struct URLBuilder {
2828- host: String,
2929- path: String,
3030- params: Vec<(String, String)>,
3131-}
66+use url::{ParseError, Url};
3273333-/// Convenience function to build a URL with optional parameters.
3434-///
3535-/// # Arguments
3636-///
3737-/// * `host` - The hostname (will be prefixed with https:// if needed)
3838-/// * `path` - The URL path
3939-/// * `params` - Vector of optional key-value pairs for query parameters
4040-///
4141-/// # Returns
4242-///
4343-/// A fully constructed URL string
4444-pub fn build_url(host: &str, path: &str, params: Vec<Option<(&str, &str)>>) -> String {
4545- let mut url_builder = URLBuilder::new(host);
4646- url_builder.path(path);
88+/// Builds a URL from the provided components.
99+/// Returns `Result<Url, ParseError>` to surface parsing errors.
1010+pub fn build_url<K, V, I>(host: &str, path: &str, params: I) -> Result<Url, ParseError>
1111+where
1212+ I: IntoIterator<Item = (K, V)>,
1313+ K: AsRef<str>,
1414+ V: AsRef<str>,
1515+{
1616+ let mut base = if host.starts_with("http://") || host.starts_with("https://") {
1717+ Url::parse(host)?
1818+ } else {
1919+ Url::parse(&format!("https://{}", host))?
2020+ };
47214848- for (key, value) in params.iter().filter_map(|x| *x) {
4949- url_builder.param(key, value);
2222+ if !base.path().ends_with('/') {
2323+ let mut new_path = base.path().to_string();
2424+ if !new_path.ends_with('/') {
2525+ new_path.push('/');
2626+ }
2727+ if new_path.is_empty() {
2828+ new_path.push('/');
2929+ }
3030+ base.set_path(&new_path);
5031 }
51325252- url_builder.build()
5353-}
5454-5555-impl URLBuilder {
5656- /// Creates a new URLBuilder with the specified host.
5757- ///
5858- /// # Arguments
5959- ///
6060- /// * `host` - The hostname (will be prefixed with https:// if needed and trailing slash removed)
6161- ///
6262- /// # Returns
6363- ///
6464- /// A new URLBuilder instance
6565- pub fn new(host: &str) -> URLBuilder {
6666- let host = if host.starts_with("https://") {
6767- host.to_string()
6868- } else {
6969- format!("https://{}", host)
7070- };
7171-7272- let host = if let Some(trimmed) = host.strip_suffix('/') {
7373- trimmed.to_string()
7474- } else {
7575- host
7676- };
7777-7878- URLBuilder {
7979- host: host.to_string(),
8080- params: vec![],
8181- path: "/".to_string(),
3333+ let mut url = base.join(path.trim_start_matches('/'))?;
3434+ {
3535+ let mut pairs = url.query_pairs_mut();
3636+ for (key, value) in params {
3737+ pairs.append_pair(key.as_ref(), value.as_ref());
8238 }
8339 }
4040+ Ok(url)
4141+}
84428585- /// Adds a query parameter to the URL.
8686- ///
8787- /// # Arguments
8888- ///
8989- /// * `key` - The parameter key
9090- /// * `value` - The parameter value (will be URL-encoded)
9191- ///
9292- /// # Returns
9393- ///
9494- /// A mutable reference to self for method chaining
9595- pub fn param(&mut self, key: &str, value: &str) -> &mut Self {
9696- self.params
9797- .push((key.to_owned(), urlencoding::encode(value).to_string()));
9898- self
9999- }
4343+#[cfg(test)]
4444+mod tests {
4545+ use super::*;
10046101101- /// Sets the URL path.
102102- ///
103103- /// # Arguments
104104- ///
105105- /// * `path` - The URL path
106106- ///
107107- /// # Returns
108108- ///
109109- /// A mutable reference to self for method chaining
110110- pub fn path(&mut self, path: &str) -> &mut Self {
111111- path.clone_into(&mut self.path);
112112- self
113113- }
4747+ #[test]
4848+ fn builds_url_with_params() {
4949+ let url = build_url(
5050+ "example.com/api",
5151+ "resource",
5252+ [("id", "123"), ("status", "active")],
5353+ )
5454+ .expect("url build failed");
11455115115- /// Constructs the final URL string.
116116- ///
117117- /// # Returns
118118- ///
119119- /// The complete URL with host, path, and query parameters
120120- pub fn build(self) -> String {
121121- let mut url_params = String::new();
122122-123123- if !self.params.is_empty() {
124124- url_params.push('?');
125125-126126- let qs_args = self.params.iter().map(|(k, v)| (&**k, &**v)).collect();
127127- url_params.push_str(build_querystring(qs_args).as_str());
128128- }
129129-130130- format!("{}{}{}", self.host, self.path, url_params)
5656+ assert_eq!(
5757+ url.as_str(),
5858+ "https://example.com/api/resource?id=123&status=active"
5959+ );
13160 }
13261}
+17-11
crates/atproto-oauth-aip/src/workflow.rs
···112112//! and protocol violations.
113113114114use anyhow::Result;
115115-use atproto_identity::url::URLBuilder;
115115+use atproto_identity::url::build_url;
116116use atproto_oauth::{
117117 jwk::WrappedJsonWebKey,
118118 workflow::{OAuthRequest, OAuthRequestState, ParResponse, TokenResponse},
119119};
120120use serde::Deserialize;
121121+use std::iter;
121122122123use crate::errors::OAuthWorkflowError;
123124···522523 access_token_type: &Option<&str>,
523524 subject: &Option<&str>,
524525) -> Result<ATProtocolSession> {
525525- let mut url_builder = URLBuilder::new(protected_resource_base);
526526- url_builder.path("/api/atprotocol/session");
526526+ let mut url = build_url(
527527+ protected_resource_base,
528528+ "/api/atprotocol/session",
529529+ iter::empty::<(&str, &str)>(),
530530+ )?;
531531+ {
532532+ let mut pairs = url.query_pairs_mut();
533533+ if let Some(value) = access_token_type {
534534+ pairs.append_pair("access_token_type", value);
535535+ }
527536528528- if let Some(value) = access_token_type {
529529- url_builder.param("access_token_type", value);
530530- }
531531-532532- if let Some(value) = subject {
533533- url_builder.param("sub", value);
537537+ if let Some(value) = subject {
538538+ pairs.append_pair("sub", value);
539539+ }
534540 }
535541536536- let url = url_builder.build();
542542+ let url: String = url.into();
537543538544 let response = http_client
539539- .get(url)
545545+ .get(&url)
540546 .bearer_auth(access_token)
541547 .send()
542548 .await
···183183/// * `false` if no DPoP error is found or the header format is invalid
184184///
185185/// # Examples
186186-/// ```
186186+/// ```no_run
187187/// use atproto_oauth::dpop::is_dpop_error;
188188///
189189/// // Valid DPoP error: invalid_dpop_proof
···516516/// - HTTP method or URI don't match expected values
517517///
518518/// # Examples
519519-/// ```
519519+/// ```no_run
520520/// use atproto_oauth::dpop::{validate_dpop_jwt, DpopValidationConfig};
521521///
522522/// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
···11# atproto-record
2233-Cryptographic signature operations and utilities for AT Protocol records.
33+Utilities for working with AT Protocol records.
4455## Overview
6677-A comprehensive Rust library for working with AT Protocol records, providing cryptographic signature creation and verification, AT-URI parsing, and datetime utilities. Built on IPLD DAG-CBOR serialization with support for P-256, P-384, and K-256 elliptic curve cryptography.
77+A Rust library for working with AT Protocol records, providing AT-URI parsing, TID generation, datetime formatting, and CID generation. Built on IPLD DAG-CBOR serialization for deterministic content addressing.
8899## Features
10101111-- **Record signing**: Create cryptographic signatures on AT Protocol records following community.lexicon.attestation.signature specification
1212-- **Signature verification**: Verify record signatures against public keys with issuer validation
1311- **AT-URI parsing**: Parse and validate AT Protocol URIs (at://authority/collection/record_key) with robust error handling
1414-- **IPLD serialization**: DAG-CBOR serialization ensuring deterministic and verifiable record encoding
1515-- **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curve signatures
1212+- **TID generation**: Timestamp-based identifiers for AT Protocol records with microsecond precision
1313+- **CID generation**: Content Identifier generation using DAG-CBOR serialization and SHA-256 hashing
1614- **DateTime utilities**: RFC 3339 datetime serialization with millisecond precision for consistent timestamp handling
1515+- **Typed records**: Type-safe record handling with lexicon type validation
1616+- **Bytes handling**: Base64 encoding/decoding for binary data in AT Protocol records
1717- **Structured errors**: Type-safe error handling following project conventions with detailed error messages
18181919## CLI Tools
20202121-The following command-line tools are available when built with the `clap` feature:
2121+The following command-line tool is available when built with the `clap` feature:
22222323-- **`atproto-record-sign`**: Sign AT Protocol records with private keys, supporting flexible argument ordering
2424-- **`atproto-record-verify`**: Verify AT Protocol record signatures by validating cryptographic signatures against issuer DIDs and public keys
2323+- **`atproto-record-cid`**: Generate CID (Content Identifier) for AT Protocol records from JSON input
25242625## Library Usage
27262828-### Creating Signatures
2727+### Generating CIDs
29283029```rust
3131-use atproto_record::signature;
3232-use atproto_identity::key::identify_key;
3330use serde_json::json;
3434-3535-// Parse the signing key from a did:key
3636-let key_data = identify_key("did:key:zQ3sh...")?;
3737-3838-// The record to sign
3939-let record = json!({"$type": "app.bsky.feed.post", "text": "Hello world!"});
3131+use cid::Cid;
3232+use sha2::{Digest, Sha256};
3333+use multihash::Multihash;
40344141-// Signature metadata (issuer is required, other fields are optional)
4242-let signature_object = json!({
4343- "issuer": "did:plc:issuer"
4444- // Optional: "issuedAt", "purpose", "expiry", etc.
3535+// Serialize a record to DAG-CBOR and generate its CID
3636+let record = json!({
3737+ "$type": "app.bsky.feed.post",
3838+ "text": "Hello world!",
3939+ "createdAt": "2024-01-01T00:00:00.000Z"
4540});
46414747-// Create the signed record with embedded signatures array
4848-let signed_record = signature::create(
4949- &key_data,
5050- &record,
5151- "did:plc:repository",
5252- "app.bsky.feed.post",
5353- signature_object
5454-).await?;
4242+let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(&record)?;
4343+let hash = Sha256::digest(&dag_cbor_bytes);
4444+let multihash = Multihash::wrap(0x12, &hash)?;
4545+let cid = Cid::new_v1(0x71, multihash);
4646+4747+println!("Record CID: {}", cid);
5548```
56495757-### Verifying Signatures
5050+### Generating TIDs
58515952```rust
6060-use atproto_record::signature;
6161-use atproto_identity::key::identify_key;
5353+use atproto_record::tid::Tid;
62546363-// Parse the public key for verification
6464-let issuer_key = identify_key("did:key:zQ3sh...")?;
5555+// Generate a new timestamp-based identifier
5656+let tid = Tid::new();
5757+println!("TID: {}", tid); // e.g., "3l2k4j5h6g7f8d9s"
65586666-// Verify the signature (throws error if invalid)
6767-signature::verify(
6868- "did:plc:issuer", // Expected issuer DID
6969- &issuer_key, // Public key for verification
7070- signed_record, // The signed record
7171- "did:plc:repository", // Repository context
7272- "app.bsky.feed.post" // Collection context
7373-).await?;
5959+// TIDs are sortable by creation time
6060+let tid1 = Tid::new();
6161+std::thread::sleep(std::time::Duration::from_millis(1));
6262+let tid2 = Tid::new();
6363+assert!(tid1 < tid2);
7464```
75657666### AT-URI Parsing
···110100111101## Command Line Usage
112102113113-All CLI tools require the `clap` feature:
103103+The CLI tool requires the `clap` feature:
114104115105```bash
116106# Build with CLI support
117107cargo build --features clap --bins
118108119119-# Sign a record
120120-cargo run --features clap --bin atproto-record-sign -- \
121121- did:key:zQ3sh... # Signing key (did:key format)
122122- did:plc:issuer # Issuer DID
123123- record.json # Record file (or use -- for stdin)
124124- repository=did:plc:repo # Repository context
125125- collection=app.bsky.feed.post # Collection type
109109+# Generate CID from JSON file
110110+cat record.json | cargo run --features clap --bin atproto-record-cid
126111127127-# Sign with custom fields (e.g., issuedAt, purpose, expiry)
128128-cargo run --features clap --bin atproto-record-sign -- \
129129- did:key:zQ3sh... did:plc:issuer record.json \
130130- repository=did:plc:repo collection=app.bsky.feed.post \
131131- issuedAt="2024-01-01T00:00:00.000Z" purpose="attestation"
112112+# Generate CID from inline JSON
113113+echo '{"$type":"app.bsky.feed.post","text":"Hello!"}' | cargo run --features clap --bin atproto-record-cid
132114133133-# Verify a signature
134134-cargo run --features clap --bin atproto-record-verify -- \
135135- did:plc:issuer # Expected issuer DID
136136- did:key:zQ3sh... # Verification key
137137- signed.json # Signed record file
138138- repository=did:plc:repo # Repository context (must match signing)
139139- collection=app.bsky.feed.post # Collection type (must match signing)
115115+# Example with a complete AT Protocol record
116116+cat <<EOF | cargo run --features clap --bin atproto-record-cid
117117+{
118118+ "$type": "app.bsky.feed.post",
119119+ "text": "Hello AT Protocol!",
120120+ "createdAt": "2024-01-01T00:00:00.000Z"
121121+}
122122+EOF
123123+```
140124141141-# Read from stdin
142142-echo '{"text":"Hello"}' | cargo run --features clap --bin atproto-record-sign -- \
143143- did:key:zQ3sh... did:plc:issuer -- \
144144- repository=did:plc:repo collection=app.bsky.feed.post
125125+The tool outputs the CID in base32 format:
126126+```
127127+bafyreibjzlvhtyxnhbvvzl3gj4qmg2ufl2jbhh5qr3gvvxlm7ksf3qwxqq
145128```
146129147130## License
148131149149-MIT License132132+MIT License
···11-//! Command-line tool for verifying cryptographic signatures on AT Protocol records.
22-//!
33-//! This tool validates signatures on AT Protocol records by reconstructing the
44-//! signed content and verifying ECDSA signatures against public keys. It ensures
55-//! that records have valid signatures from specified issuers.
66-77-use anyhow::Result;
88-use atproto_identity::{
99- key::{KeyData, identify_key},
1010- resolve::{InputType, parse_input},
1111-};
1212-use atproto_record::errors::CliError;
1313-use atproto_record::signature::verify;
1414-use clap::Parser;
1515-use std::{
1616- fs,
1717- io::{self, Read},
1818-};
1919-2020-/// AT Protocol Record Verification CLI
2121-#[derive(Parser)]
2222-#[command(
2323- name = "atproto-record-verify",
2424- version,
2525- about = "Verify cryptographic signatures of AT Protocol records",
2626- long_about = "
2727-A command-line tool for verifying cryptographic signatures of AT Protocol records.
2828-Reads a signed JSON record from a file or stdin, validates the embedded signatures
2929-using a public key, and reports verification success or failure.
3030-3131-The tool accepts flexible argument ordering with issuer DIDs, verification keys,
3232-record inputs, and key=value parameters for repository and collection context.
3333-3434-REQUIRED PARAMETERS:
3535- repository=<DID> Repository context used during signing
3636- collection=<name> Collection type context used during signing
3737-3838-EXAMPLES:
3939- # Basic verification:
4040- atproto-record-verify \\
4141- did:plc:tgudj2fjm77pzkuawquqhsxm \\
4242- did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\
4343- ./signed_post.json \\
4444- repository=did:plc:4zutorghlchjxzgceklue4la \\
4545- collection=app.bsky.feed.post
4646-4747- # Verify from stdin:
4848- echo '{\"signatures\":[...]}' | atproto-record-verify \\
4949- did:plc:issuer... did:key:z42tv1pb3... -- \\
5050- repository=did:plc:repo... collection=app.bsky.feed.post
5151-5252-VERIFICATION PROCESS:
5353- - Extracts signatures from the signatures array
5454- - Finds signatures matching the specified issuer DID
5555- - Reconstructs $sig object with repository and collection context
5656- - Validates ECDSA signatures using P-256 or K-256 curves
5757-"
5858-)]
5959-struct Args {
6060- /// All arguments - flexible parsing handles issuer DIDs, verification keys, files, and key=value pairs
6161- args: Vec<String>,
6262-}
6363-#[tokio::main]
6464-async fn main() -> Result<()> {
6565- let args = Args::parse();
6666-6767- let arguments = args.args.into_iter();
6868-6969- let mut collection: Option<String> = None;
7070- let mut repository: Option<String> = None;
7171- let mut record: Option<serde_json::Value> = None;
7272- let mut issuer: Option<String> = None;
7373- let mut key_data: Option<KeyData> = None;
7474-7575- for argument in arguments {
7676- if let Some((key, value)) = argument.split_once("=") {
7777- match key {
7878- "collection" => {
7979- collection = Some(value.to_string());
8080- }
8181- "repository" => {
8282- repository = Some(value.to_string());
8383- }
8484- _ => {}
8585- }
8686- } else if argument.starts_with("did:key:") {
8787- // Parse the did:key to extract key data for verification
8888- key_data = Some(identify_key(&argument)?);
8989- } else if argument.starts_with("did:") {
9090- match parse_input(&argument) {
9191- Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => {
9292- issuer = Some(did);
9393- }
9494- Ok(_) => {
9595- return Err(CliError::UnsupportedDidMethod {
9696- method: argument.clone(),
9797- }
9898- .into());
9999- }
100100- Err(_) => {
101101- return Err(CliError::DidParseFailed {
102102- did: argument.clone(),
103103- }
104104- .into());
105105- }
106106- }
107107- } else if argument == "--" {
108108- // Read record from stdin
109109- if record.is_none() {
110110- let mut stdin_content = String::new();
111111- io::stdin()
112112- .read_to_string(&mut stdin_content)
113113- .map_err(|_| CliError::StdinReadFailed)?;
114114- record = Some(
115115- serde_json::from_str(&stdin_content)
116116- .map_err(|_| CliError::StdinJsonParseFailed)?,
117117- );
118118- } else {
119119- return Err(CliError::UnexpectedArgument {
120120- argument: argument.clone(),
121121- }
122122- .into());
123123- }
124124- } else {
125125- // Assume it's a file path to read the record from
126126- if record.is_none() {
127127- let file_content =
128128- fs::read_to_string(&argument).map_err(|_| CliError::FileReadFailed {
129129- path: argument.clone(),
130130- })?;
131131- record = Some(serde_json::from_str(&file_content).map_err(|_| {
132132- CliError::FileJsonParseFailed {
133133- path: argument.clone(),
134134- }
135135- })?);
136136- } else {
137137- return Err(CliError::UnexpectedArgument {
138138- argument: argument.clone(),
139139- }
140140- .into());
141141- }
142142- }
143143- }
144144-145145- let collection = collection.ok_or(CliError::MissingRequiredValue {
146146- name: "collection".to_string(),
147147- })?;
148148- let repository = repository.ok_or(CliError::MissingRequiredValue {
149149- name: "repository".to_string(),
150150- })?;
151151- let record = record.ok_or(CliError::MissingRequiredValue {
152152- name: "record".to_string(),
153153- })?;
154154- let issuer = issuer.ok_or(CliError::MissingRequiredValue {
155155- name: "issuer".to_string(),
156156- })?;
157157- let key_data = key_data.ok_or(CliError::MissingRequiredValue {
158158- name: "key".to_string(),
159159- })?;
160160-161161- verify(&issuer, &key_data, record, &repository, &collection)?;
162162-163163- println!("OK");
164164-165165- Ok(())
166166-}
+46-1
crates/atproto-record/src/errors.rs
···1414//! Errors occurring during AT-URI parsing and validation.
1515//! Error codes: aturi-1 through aturi-9
1616//!
1717+//! ### `TidError` (Domain: tid)
1818+//! Errors occurring during TID (Timestamp Identifier) parsing and decoding.
1919+//! Error codes: tid-1 through tid-3
2020+//!
1721//! ### `CliError` (Domain: cli)
1822//! Command-line interface specific errors for file I/O, argument parsing, and DID validation.
1919-//! Error codes: cli-1 through cli-8
2323+//! Error codes: cli-1 through cli-10
2024//!
2125//! ## Error Format
2226//!
···220224 /// record key component, which is not valid.
221225 #[error("error-atproto-record-aturi-9 Record key component cannot be empty")]
222226 EmptyRecordKey,
227227+}
228228+229229+/// Errors that can occur during TID (Timestamp Identifier) operations.
230230+///
231231+/// This enum covers all validation failures when parsing and decoding TIDs,
232232+/// including format violations, invalid characters, and encoding errors.
233233+#[derive(Debug, Error)]
234234+pub enum TidError {
235235+ /// Error when TID string length is invalid.
236236+ ///
237237+ /// This error occurs when a TID string is not exactly 13 characters long,
238238+ /// which is required by the TID specification.
239239+ #[error("error-atproto-record-tid-1 Invalid TID length: expected {expected}, got {actual}")]
240240+ InvalidLength {
241241+ /// Expected length (always 13)
242242+ expected: usize,
243243+ /// Actual length of the provided string
244244+ actual: usize,
245245+ },
246246+247247+ /// Error when TID contains an invalid character.
248248+ ///
249249+ /// This error occurs when a TID string contains a character outside the
250250+ /// base32-sortable character set (234567abcdefghijklmnopqrstuvwxyz).
251251+ #[error("error-atproto-record-tid-2 Invalid character '{character}' at position {position}")]
252252+ InvalidCharacter {
253253+ /// The invalid character
254254+ character: char,
255255+ /// Position in the string (0-indexed)
256256+ position: usize,
257257+ },
258258+259259+ /// Error when TID format is invalid.
260260+ ///
261261+ /// This error occurs when the TID violates structural requirements,
262262+ /// such as having the top bit set (which must always be 0).
263263+ #[error("error-atproto-record-tid-3 Invalid TID format: {reason}")]
264264+ InvalidFormat {
265265+ /// Reason for the format violation
266266+ reason: String,
267267+ },
223268}
224269225270/// Errors specific to command-line interface operations.
+40-19
crates/atproto-record/src/lib.rs
···1616//! ## Example Usage
1717//!
1818//! ```ignore
1919-//! use atproto_record::signature;
2020-//! use atproto_identity::key::identify_key;
1919+//! use atproto_record::attestation;
2020+//! use atproto_identity::key::{identify_key, sign, to_public};
2121+//! use base64::engine::general_purpose::STANDARD;
2122//! use serde_json::json;
2223//!
2323-//! // Sign a record
2424-//! let key_data = identify_key("did:key:...")?;
2525-//! let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"});
2626-//! let sig_obj = json!({"issuer": "did:plc:..."});
2424+//! let private_key = identify_key("did:key:zPrivate...")?;
2525+//! let public_key = to_public(&private_key)?;
2626+//! let key_reference = format!("{}", &public_key);
2727//!
2828-//! let signed = signature::create(&key_data, &record, "did:plc:repo",
2929-//! "app.bsky.feed.post", sig_obj).await?;
2828+//! let record = json!({
2929+//! "$type": "app.example.record",
3030+//! "text": "Hello from attestation helpers!"
3131+//! });
3232+//!
3333+//! let sig_metadata = json!({
3434+//! "$type": "com.example.inlineSignature",
3535+//! "key": &key_reference,
3636+//! "purpose": "demo"
3737+//! });
3838+//!
3939+//! let signing_record = attestation::prepare_signing_record(&record, &sig_metadata)?;
4040+//! let cid = attestation::create_cid(&signing_record)?;
4141+//! let signature_bytes = sign(&private_key, &cid.to_bytes())?;
4242+//!
4343+//! let inline_attestation = json!({
4444+//! "$type": "com.example.inlineSignature",
4545+//! "key": key_reference,
4646+//! "purpose": "demo",
4747+//! "signature": {"$bytes": STANDARD.encode(signature_bytes)}
4848+//! });
3049//!
3131-//! // Verify a signature
3232-//! signature::verify("did:plc:issuer", &key_data, signed,
3333-//! "did:plc:repo", "app.bsky.feed.post").await?;
5050+//! let signed = attestation::create_inline_attestation_reference(&record, &inline_attestation)?;
5151+//! let reports = tokio_test::block_on(async {
5252+//! attestation::verify_all_signatures(&signed, None).await
5353+//! })?;
5454+//! assert!(matches!(reports[0].status, attestation::VerificationStatus::Valid { .. }));
3455//! ```
35563657#![forbid(unsafe_code)]
···4263/// and CLI operations. All errors follow the project's standardized format:
4364/// `error-atproto-record-{domain}-{number} {message}: {details}`
4465pub mod errors;
4545-4646-/// Core signature creation and verification.
4747-///
4848-/// Provides functions for creating and verifying cryptographic signatures on
4949-/// AT Protocol records using IPLD DAG-CBOR serialization. Supports the
5050-/// community.lexicon.attestation.signature specification with proper $sig
5151-/// object handling and multiple signature support.
5252-pub mod signature;
53665467/// AT-URI parsing and validation.
5568///
···8497/// in many AT Protocol lexicon structures. The wrapper can automatically add type
8598/// fields during serialization and validate them during deserialization.
8699pub mod typed;
100100+101101+/// Timestamp Identifier (TID) generation and parsing.
102102+///
103103+/// TIDs are sortable, distributed identifiers combining microsecond timestamps
104104+/// with random clock identifiers. They provide a collision-resistant, monotonically
105105+/// increasing identifier scheme for AT Protocol records encoded as 13-character
106106+/// base32-sortable strings.
107107+pub mod tid;
-672
crates/atproto-record/src/signature.rs
···11-//! AT Protocol record signature creation and verification.
22-//!
33-//! This module provides comprehensive functionality for creating and verifying
44-//! cryptographic signatures on AT Protocol records following the
55-//! community.lexicon.attestation.signature specification.
66-//!
77-//! ## Signature Process
88-//!
99-//! 1. **Signing**: Records are augmented with a `$sig` object containing issuer,
1010-//! timestamp, and context information, then serialized using IPLD DAG-CBOR
1111-//! for deterministic encoding before signing with ECDSA.
1212-//!
1313-//! 2. **Storage**: Signatures are stored in a `signatures` array within the record,
1414-//! allowing multiple signatures from different issuers.
1515-//!
1616-//! 3. **Verification**: The original signed content is reconstructed by replacing
1717-//! the signatures array with the appropriate `$sig` object, then verified
1818-//! using the issuer's public key.
1919-//!
2020-//! ## Supported Curves
2121-//!
2222-//! - P-256 (NIST P-256 / secp256r1)
2323-//! - P-384 (NIST P-384 / secp384r1)
2424-//! - K-256 (secp256k1)
2525-//!
2626-//! ## Example
2727-//!
2828-//! ```ignore
2929-//! use atproto_record::signature::{create, verify};
3030-//! use atproto_identity::key::identify_key;
3131-//! use serde_json::json;
3232-//!
3333-//! // Create a signature
3434-//! let key = identify_key("did:key:...")?;
3535-//! let record = json!({"text": "Hello!"});
3636-//! let sig_obj = json!({
3737-//! "issuer": "did:plc:issuer"
3838-//! // Optional: any additional fields like "issuedAt", "purpose", etc.
3939-//! });
4040-//!
4141-//! let signed = create(&key, &record, "did:plc:repo",
4242-//! "app.bsky.feed.post", sig_obj)?;
4343-//!
4444-//! // Verify the signature
4545-//! verify("did:plc:issuer", &key, signed,
4646-//! "did:plc:repo", "app.bsky.feed.post")?;
4747-//! ```
4848-4949-use atproto_identity::key::{KeyData, sign, validate};
5050-use base64::{Engine, engine::general_purpose::STANDARD};
5151-use serde_json::json;
5252-5353-use crate::errors::VerificationError;
5454-5555-/// Creates a cryptographic signature for an AT Protocol record.
5656-///
5757-/// This function generates a signature following the community.lexicon.attestation.signature
5858-/// specification. The record is augmented with a `$sig` object containing context information,
5959-/// serialized using IPLD DAG-CBOR, signed with the provided key, and the signature is added
6060-/// to a `signatures` array in the returned record.
6161-///
6262-/// # Parameters
6363-///
6464-/// * `key_data` - The signing key (private key) wrapped in KeyData
6565-/// * `record` - The JSON record to be signed (will not be modified)
6666-/// * `repository` - The repository DID where this record will be stored
6767-/// * `collection` - The collection type (NSID) for this record
6868-/// * `signature_object` - Metadata for the signature, must include:
6969-/// - `issuer`: The DID of the entity creating the signature (required)
7070-/// - Additional custom fields are preserved in the signature (optional)
7171-///
7272-/// # Returns
7373-///
7474-/// Returns a new record containing:
7575-/// - All original record fields
7676-/// - A `signatures` array with the new signature appended
7777-/// - No `$sig` field (only used during signing)
7878-///
7979-/// # Errors
8080-///
8181-/// Returns [`VerificationError`] if:
8282-/// - Required field `issuer` is missing from signature_object
8383-/// - IPLD DAG-CBOR serialization fails
8484-/// - Cryptographic signing operation fails
8585-/// - JSON structure manipulation fails
8686-pub fn create(
8787- key_data: &KeyData,
8888- record: &serde_json::Value,
8989- repository: &str,
9090- collection: &str,
9191- signature_object: serde_json::Value,
9292-) -> Result<serde_json::Value, VerificationError> {
9393- if let Some(record_map) = signature_object.as_object() {
9494- if !record_map.contains_key("issuer") {
9595- return Err(VerificationError::SignatureObjectMissingField {
9696- field: "issuer".to_string(),
9797- });
9898- }
9999- } else {
100100- return Err(VerificationError::InvalidSignatureObjectType);
101101- };
102102-103103- // Prepare the $sig object.
104104- let mut sig = signature_object.clone();
105105- if let Some(record_map) = sig.as_object_mut() {
106106- record_map.insert("repository".to_string(), json!(repository));
107107- record_map.insert("collection".to_string(), json!(collection));
108108- record_map.insert(
109109- "$type".to_string(),
110110- json!("community.lexicon.attestation.signature"),
111111- );
112112- }
113113-114114- // Create a copy of the record with the $sig object for signing.
115115- let mut signing_record = record.clone();
116116- if let Some(record_map) = signing_record.as_object_mut() {
117117- record_map.remove("signatures");
118118- record_map.remove("$sig");
119119- record_map.insert("$sig".to_string(), sig);
120120- }
121121-122122- // Create a signature.
123123- let serialized_signing_record = serde_ipld_dagcbor::to_vec(&signing_record)?;
124124-125125- let signature: Vec<u8> = sign(key_data, &serialized_signing_record)?;
126126- let encoded_signature = STANDARD.encode(&signature);
127127-128128- // Compose the proof object
129129- let mut proof = signature_object.clone();
130130- if let Some(record_map) = proof.as_object_mut() {
131131- record_map.remove("repository");
132132- record_map.remove("collection");
133133- record_map.insert(
134134- "signature".to_string(),
135135- json!({"$bytes": json!(encoded_signature)}),
136136- );
137137- record_map.insert(
138138- "$type".to_string(),
139139- json!("community.lexicon.attestation.signature"),
140140- );
141141- }
142142-143143- // Add the signature to the original record
144144- let mut signed_record = record.clone();
145145-146146- if let Some(record_map) = signed_record.as_object_mut() {
147147- let mut signatures: Vec<serde_json::Value> = record
148148- .get("signatures")
149149- .and_then(|v| v.as_array().cloned())
150150- .unwrap_or_default();
151151-152152- signatures.push(proof);
153153-154154- record_map.remove("$sig");
155155- record_map.remove("signatures");
156156-157157- // Add the $sig field
158158- record_map.insert("signatures".to_string(), json!(signatures));
159159- }
160160-161161- Ok(signed_record)
162162-}
163163-164164-/// Verifies a cryptographic signature on an AT Protocol record.
165165-///
166166-/// This function validates signatures by reconstructing the original signed content
167167-/// (record with `$sig` object) and verifying the ECDSA signature against it.
168168-/// It searches through all signatures in the record to find one matching the
169169-/// specified issuer, then verifies it with the provided public key.
170170-///
171171-/// # Parameters
172172-///
173173-/// * `issuer` - The DID of the expected signature issuer to verify
174174-/// * `key_data` - The public key for signature verification
175175-/// * `record` - The signed record containing a `signatures` or `sigs` array
176176-/// * `repository` - The repository DID used during signing (must match)
177177-/// * `collection` - The collection type used during signing (must match)
178178-///
179179-/// # Returns
180180-///
181181-/// Returns `Ok(())` if a valid signature from the specified issuer is found
182182-/// and successfully verified against the reconstructed signed content.
183183-///
184184-/// # Errors
185185-///
186186-/// Returns [`VerificationError`] if:
187187-/// - No `signatures` or `sigs` field exists in the record
188188-/// - No signature from the specified issuer is found
189189-/// - The issuer's signature is malformed or missing required fields
190190-/// - The signature is not in the expected `{"$bytes": "..."}` format
191191-/// - Base64 decoding of the signature fails
192192-/// - IPLD DAG-CBOR serialization of reconstructed content fails
193193-/// - Cryptographic verification fails (invalid signature)
194194-///
195195-/// # Note
196196-///
197197-/// This function supports both `signatures` and `sigs` field names for
198198-/// backward compatibility with different AT Protocol implementations.
199199-pub fn verify(
200200- issuer: &str,
201201- key_data: &KeyData,
202202- record: serde_json::Value,
203203- repository: &str,
204204- collection: &str,
205205-) -> Result<(), VerificationError> {
206206- let signatures = record
207207- .get("sigs")
208208- .or_else(|| record.get("signatures"))
209209- .and_then(|v| v.as_array())
210210- .ok_or(VerificationError::NoSignaturesField)?;
211211-212212- for sig_obj in signatures {
213213- // Extract the issuer from the signature object
214214- let signature_issuer = sig_obj
215215- .get("issuer")
216216- .and_then(|v| v.as_str())
217217- .ok_or(VerificationError::MissingIssuerField)?;
218218-219219- let signature_value = sig_obj
220220- .get("signature")
221221- .and_then(|v| v.as_object())
222222- .and_then(|obj| obj.get("$bytes"))
223223- .and_then(|b| b.as_str())
224224- .ok_or(VerificationError::MissingSignatureField)?;
225225-226226- if issuer != signature_issuer {
227227- continue;
228228- }
229229-230230- let mut sig_variable = sig_obj.clone();
231231-232232- if let Some(sig_map) = sig_variable.as_object_mut() {
233233- sig_map.remove("signature");
234234- sig_map.insert("repository".to_string(), json!(repository));
235235- sig_map.insert("collection".to_string(), json!(collection));
236236- }
237237-238238- let mut signed_record = record.clone();
239239- if let Some(record_map) = signed_record.as_object_mut() {
240240- record_map.remove("signatures");
241241- record_map.remove("sigs");
242242- record_map.insert("$sig".to_string(), sig_variable);
243243- }
244244-245245- let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record)
246246- .map_err(|error| VerificationError::RecordSerializationFailed { error })?;
247247-248248- let signature_bytes = STANDARD
249249- .decode(signature_value)
250250- .map_err(|error| VerificationError::SignatureDecodingFailed { error })?;
251251-252252- validate(key_data, &signature_bytes, &serialized_record)
253253- .map_err(|error| VerificationError::CryptographicValidationFailed { error })?;
254254-255255- return Ok(());
256256- }
257257-258258- Err(VerificationError::NoValidSignatureForIssuer {
259259- issuer: issuer.to_string(),
260260- })
261261-}
262262-263263-#[cfg(test)]
264264-mod tests {
265265- use super::*;
266266- use atproto_identity::key::{KeyType, generate_key, to_public};
267267- use serde_json::json;
268268-269269- #[test]
270270- fn test_create_sign_and_verify_record_p256() -> Result<(), Box<dyn std::error::Error>> {
271271- // Step 1: Generate a P-256 key pair
272272- let private_key = generate_key(KeyType::P256Private)?;
273273- let public_key = to_public(&private_key)?;
274274-275275- // Step 2: Create a sample record
276276- let record = json!({
277277- "text": "Hello AT Protocol!",
278278- "createdAt": "2025-01-19T10:00:00Z",
279279- "langs": ["en"]
280280- });
281281-282282- // Step 3: Define signature metadata
283283- let issuer_did = "did:plc:test123";
284284- let repository = "did:plc:repo456";
285285- let collection = "app.bsky.feed.post";
286286-287287- let signature_object = json!({
288288- "issuer": issuer_did,
289289- "issuedAt": "2025-01-19T10:00:00Z",
290290- "purpose": "attestation"
291291- });
292292-293293- // Step 4: Sign the record
294294- let signed_record = create(
295295- &private_key,
296296- &record,
297297- repository,
298298- collection,
299299- signature_object.clone(),
300300- )?;
301301-302302- // Verify that the signed record contains signatures array
303303- assert!(signed_record.get("signatures").is_some());
304304- let signatures = signed_record
305305- .get("signatures")
306306- .and_then(|v| v.as_array())
307307- .expect("signatures should be an array");
308308- assert_eq!(signatures.len(), 1);
309309-310310- // Verify signature object structure
311311- let sig = &signatures[0];
312312- assert_eq!(sig.get("issuer").and_then(|v| v.as_str()), Some(issuer_did));
313313- assert!(sig.get("signature").is_some());
314314- assert_eq!(
315315- sig.get("$type").and_then(|v| v.as_str()),
316316- Some("community.lexicon.attestation.signature")
317317- );
318318-319319- // Step 5: Verify the signature
320320- verify(
321321- issuer_did,
322322- &public_key,
323323- signed_record.clone(),
324324- repository,
325325- collection,
326326- )?;
327327-328328- Ok(())
329329- }
330330-331331- #[test]
332332- fn test_create_sign_and_verify_record_k256() -> Result<(), Box<dyn std::error::Error>> {
333333- // Test with K-256 curve
334334- let private_key = generate_key(KeyType::K256Private)?;
335335- let public_key = to_public(&private_key)?;
336336-337337- let record = json!({
338338- "subject": "at://did:plc:example/app.bsky.feed.post/123",
339339- "likedAt": "2025-01-19T10:00:00Z"
340340- });
341341-342342- let issuer_did = "did:plc:issuer789";
343343- let repository = "did:plc:repo789";
344344- let collection = "app.bsky.feed.like";
345345-346346- let signature_object = json!({
347347- "issuer": issuer_did,
348348- "issuedAt": "2025-01-19T10:00:00Z"
349349- });
350350-351351- let signed_record = create(
352352- &private_key,
353353- &record,
354354- repository,
355355- collection,
356356- signature_object,
357357- )?;
358358-359359- verify(
360360- issuer_did,
361361- &public_key,
362362- signed_record,
363363- repository,
364364- collection,
365365- )?;
366366-367367- Ok(())
368368- }
369369-370370- #[test]
371371- fn test_create_sign_and_verify_record_p384() -> Result<(), Box<dyn std::error::Error>> {
372372- // Test with P-384 curve
373373- let private_key = generate_key(KeyType::P384Private)?;
374374- let public_key = to_public(&private_key)?;
375375-376376- let record = json!({
377377- "displayName": "Test User",
378378- "description": "Testing P-384 signatures"
379379- });
380380-381381- let issuer_did = "did:web:example.com";
382382- let repository = "did:plc:profile123";
383383- let collection = "app.bsky.actor.profile";
384384-385385- let signature_object = json!({
386386- "issuer": issuer_did,
387387- "issuedAt": "2025-01-19T10:00:00Z",
388388- "expiresAt": "2025-01-20T10:00:00Z",
389389- "customField": "custom value"
390390- });
391391-392392- let signed_record = create(
393393- &private_key,
394394- &record,
395395- repository,
396396- collection,
397397- signature_object.clone(),
398398- )?;
399399-400400- // Verify custom fields are preserved in signature
401401- let signatures = signed_record
402402- .get("signatures")
403403- .and_then(|v| v.as_array())
404404- .expect("signatures should exist");
405405- let sig = &signatures[0];
406406- assert_eq!(
407407- sig.get("customField").and_then(|v| v.as_str()),
408408- Some("custom value")
409409- );
410410-411411- verify(
412412- issuer_did,
413413- &public_key,
414414- signed_record,
415415- repository,
416416- collection,
417417- )?;
418418-419419- Ok(())
420420- }
421421-422422- #[test]
423423- fn test_multiple_signatures() -> Result<(), Box<dyn std::error::Error>> {
424424- // Create a record with multiple signatures from different issuers
425425- let private_key1 = generate_key(KeyType::P256Private)?;
426426- let public_key1 = to_public(&private_key1)?;
427427-428428- let private_key2 = generate_key(KeyType::K256Private)?;
429429- let public_key2 = to_public(&private_key2)?;
430430-431431- let record = json!({
432432- "text": "Multi-signed content",
433433- "important": true
434434- });
435435-436436- let repository = "did:plc:repo_multi";
437437- let collection = "app.example.document";
438438-439439- // First signature
440440- let issuer1 = "did:plc:issuer1";
441441- let sig_obj1 = json!({
442442- "issuer": issuer1,
443443- "issuedAt": "2025-01-19T09:00:00Z",
444444- "role": "author"
445445- });
446446-447447- let signed_once = create(&private_key1, &record, repository, collection, sig_obj1)?;
448448-449449- // Second signature on already signed record
450450- let issuer2 = "did:plc:issuer2";
451451- let sig_obj2 = json!({
452452- "issuer": issuer2,
453453- "issuedAt": "2025-01-19T10:00:00Z",
454454- "role": "reviewer"
455455- });
456456-457457- let signed_twice = create(
458458- &private_key2,
459459- &signed_once,
460460- repository,
461461- collection,
462462- sig_obj2,
463463- )?;
464464-465465- // Verify we have two signatures
466466- let signatures = signed_twice
467467- .get("signatures")
468468- .and_then(|v| v.as_array())
469469- .expect("signatures should exist");
470470- assert_eq!(signatures.len(), 2);
471471-472472- // Verify both signatures independently
473473- verify(
474474- issuer1,
475475- &public_key1,
476476- signed_twice.clone(),
477477- repository,
478478- collection,
479479- )?;
480480- verify(
481481- issuer2,
482482- &public_key2,
483483- signed_twice.clone(),
484484- repository,
485485- collection,
486486- )?;
487487-488488- Ok(())
489489- }
490490-491491- #[test]
492492- fn test_verify_wrong_issuer_fails() -> Result<(), Box<dyn std::error::Error>> {
493493- let private_key = generate_key(KeyType::P256Private)?;
494494- let public_key = to_public(&private_key)?;
495495-496496- let record = json!({"test": "data"});
497497- let repository = "did:plc:repo";
498498- let collection = "app.test";
499499-500500- let sig_obj = json!({
501501- "issuer": "did:plc:correct_issuer"
502502- });
503503-504504- let signed = create(&private_key, &record, repository, collection, sig_obj)?;
505505-506506- // Try to verify with wrong issuer
507507- let result = verify(
508508- "did:plc:wrong_issuer",
509509- &public_key,
510510- signed,
511511- repository,
512512- collection,
513513- );
514514-515515- assert!(result.is_err());
516516- assert!(matches!(
517517- result.unwrap_err(),
518518- VerificationError::NoValidSignatureForIssuer { .. }
519519- ));
520520-521521- Ok(())
522522- }
523523-524524- #[test]
525525- fn test_verify_wrong_key_fails() -> Result<(), Box<dyn std::error::Error>> {
526526- let private_key = generate_key(KeyType::P256Private)?;
527527- let wrong_private_key = generate_key(KeyType::P256Private)?;
528528- let wrong_public_key = to_public(&wrong_private_key)?;
529529-530530- let record = json!({"test": "data"});
531531- let repository = "did:plc:repo";
532532- let collection = "app.test";
533533- let issuer = "did:plc:issuer";
534534-535535- let sig_obj = json!({ "issuer": issuer });
536536-537537- let signed = create(&private_key, &record, repository, collection, sig_obj)?;
538538-539539- // Try to verify with wrong key
540540- let result = verify(issuer, &wrong_public_key, signed, repository, collection);
541541-542542- assert!(result.is_err());
543543- assert!(matches!(
544544- result.unwrap_err(),
545545- VerificationError::CryptographicValidationFailed { .. }
546546- ));
547547-548548- Ok(())
549549- }
550550-551551- #[test]
552552- fn test_verify_tampered_record_fails() -> Result<(), Box<dyn std::error::Error>> {
553553- let private_key = generate_key(KeyType::P256Private)?;
554554- let public_key = to_public(&private_key)?;
555555-556556- let record = json!({"text": "original"});
557557- let repository = "did:plc:repo";
558558- let collection = "app.test";
559559- let issuer = "did:plc:issuer";
560560-561561- let sig_obj = json!({ "issuer": issuer });
562562-563563- let mut signed = create(&private_key, &record, repository, collection, sig_obj)?;
564564-565565- // Tamper with the record content
566566- if let Some(obj) = signed.as_object_mut() {
567567- obj.insert("text".to_string(), json!("tampered"));
568568- }
569569-570570- // Verification should fail
571571- let result = verify(issuer, &public_key, signed, repository, collection);
572572-573573- assert!(result.is_err());
574574- assert!(matches!(
575575- result.unwrap_err(),
576576- VerificationError::CryptographicValidationFailed { .. }
577577- ));
578578-579579- Ok(())
580580- }
581581-582582- #[test]
583583- fn test_create_missing_issuer_fails() -> Result<(), Box<dyn std::error::Error>> {
584584- let private_key = generate_key(KeyType::P256Private)?;
585585-586586- let record = json!({"test": "data"});
587587- let repository = "did:plc:repo";
588588- let collection = "app.test";
589589-590590- // Signature object without issuer field
591591- let sig_obj = json!({
592592- "issuedAt": "2025-01-19T10:00:00Z"
593593- });
594594-595595- let result = create(&private_key, &record, repository, collection, sig_obj);
596596-597597- assert!(result.is_err());
598598- assert!(matches!(
599599- result.unwrap_err(),
600600- VerificationError::SignatureObjectMissingField { field } if field == "issuer"
601601- ));
602602-603603- Ok(())
604604- }
605605-606606- #[test]
607607- fn test_verify_supports_sigs_field() -> Result<(), Box<dyn std::error::Error>> {
608608- // Test backward compatibility with "sigs" field name
609609- let private_key = generate_key(KeyType::P256Private)?;
610610- let public_key = to_public(&private_key)?;
611611-612612- let record = json!({"test": "data"});
613613- let repository = "did:plc:repo";
614614- let collection = "app.test";
615615- let issuer = "did:plc:issuer";
616616-617617- let sig_obj = json!({ "issuer": issuer });
618618-619619- let mut signed = create(&private_key, &record, repository, collection, sig_obj)?;
620620-621621- // Rename "signatures" to "sigs"
622622- if let Some(obj) = signed.as_object_mut()
623623- && let Some(signatures) = obj.remove("signatures")
624624- {
625625- obj.insert("sigs".to_string(), signatures);
626626- }
627627-628628- // Should still verify successfully
629629- verify(issuer, &public_key, signed, repository, collection)?;
630630-631631- Ok(())
632632- }
633633-634634- #[test]
635635- fn test_signature_preserves_original_record() -> Result<(), Box<dyn std::error::Error>> {
636636- let private_key = generate_key(KeyType::P256Private)?;
637637-638638- let original_record = json!({
639639- "text": "Original content",
640640- "metadata": {
641641- "author": "Test",
642642- "version": 1
643643- },
644644- "tags": ["test", "sample"]
645645- });
646646-647647- let repository = "did:plc:repo";
648648- let collection = "app.test";
649649-650650- let sig_obj = json!({
651651- "issuer": "did:plc:issuer"
652652- });
653653-654654- let signed = create(
655655- &private_key,
656656- &original_record,
657657- repository,
658658- collection,
659659- sig_obj,
660660- )?;
661661-662662- // All original fields should be preserved
663663- assert_eq!(signed.get("text"), original_record.get("text"));
664664- assert_eq!(signed.get("metadata"), original_record.get("metadata"));
665665- assert_eq!(signed.get("tags"), original_record.get("tags"));
666666-667667- // Plus the new signatures field
668668- assert!(signed.get("signatures").is_some());
669669-670670- Ok(())
671671- }
672672-}
+492
crates/atproto-record/src/tid.rs
···11+//! Timestamp Identifier (TID) generation and parsing.
22+//!
33+//! TIDs are 64-bit integers encoded as 13-character base32-sortable strings, combining
44+//! a microsecond timestamp with a random clock identifier for collision resistance.
55+//! They provide a sortable, distributed identifier scheme for AT Protocol records.
66+//!
77+//! ## Format
88+//!
99+//! - **Length**: Always 13 ASCII characters
1010+//! - **Encoding**: Base32-sortable character set (`234567abcdefghijklmnopqrstuvwxyz`)
1111+//! - **Structure**: 64-bit big-endian integer with:
1212+//! - Bit 0 (top): Always 0
1313+//! - Bits 1-53: Microseconds since UNIX epoch
1414+//! - Bits 54-63: Random 10-bit clock identifier
1515+//!
1616+//! ## Example
1717+//!
1818+//! ```
1919+//! use atproto_record::tid::Tid;
2020+//!
2121+//! // Generate a new TID
2222+//! let tid = Tid::new();
2323+//! let tid_str = tid.to_string();
2424+//! assert_eq!(tid_str.len(), 13);
2525+//!
2626+//! // Parse a TID string
2727+//! let parsed = tid_str.parse::<Tid>().unwrap();
2828+//! assert_eq!(tid, parsed);
2929+//!
3030+//! // TIDs are sortable by timestamp
3131+//! let tid1 = Tid::new();
3232+//! std::thread::sleep(std::time::Duration::from_micros(10));
3333+//! let tid2 = Tid::new();
3434+//! assert!(tid1 < tid2);
3535+//! ```
3636+3737+use std::fmt;
3838+use std::str::FromStr;
3939+use std::sync::Mutex;
4040+use std::time::{SystemTime, UNIX_EPOCH};
4141+4242+use crate::errors::TidError;
4343+4444+/// Base32-sortable character set for TID encoding.
4545+///
4646+/// This character set maintains lexicographic sort order when encoded TIDs
4747+/// are compared as strings, ensuring timestamp ordering is preserved.
4848+const BASE32_SORTABLE: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
4949+5050+/// Reverse lookup table for base32-sortable decoding.
5151+///
5252+/// Maps ASCII character values to their corresponding 5-bit values.
5353+/// Invalid characters are marked with 0xFF.
5454+const BASE32_DECODE: [u8; 256] = {
5555+ let mut table = [0xFF; 256];
5656+ table[b'2' as usize] = 0;
5757+ table[b'3' as usize] = 1;
5858+ table[b'4' as usize] = 2;
5959+ table[b'5' as usize] = 3;
6060+ table[b'6' as usize] = 4;
6161+ table[b'7' as usize] = 5;
6262+ table[b'a' as usize] = 6;
6363+ table[b'b' as usize] = 7;
6464+ table[b'c' as usize] = 8;
6565+ table[b'd' as usize] = 9;
6666+ table[b'e' as usize] = 10;
6767+ table[b'f' as usize] = 11;
6868+ table[b'g' as usize] = 12;
6969+ table[b'h' as usize] = 13;
7070+ table[b'i' as usize] = 14;
7171+ table[b'j' as usize] = 15;
7272+ table[b'k' as usize] = 16;
7373+ table[b'l' as usize] = 17;
7474+ table[b'm' as usize] = 18;
7575+ table[b'n' as usize] = 19;
7676+ table[b'o' as usize] = 20;
7777+ table[b'p' as usize] = 21;
7878+ table[b'q' as usize] = 22;
7979+ table[b'r' as usize] = 23;
8080+ table[b's' as usize] = 24;
8181+ table[b't' as usize] = 25;
8282+ table[b'u' as usize] = 26;
8383+ table[b'v' as usize] = 27;
8484+ table[b'w' as usize] = 28;
8585+ table[b'x' as usize] = 29;
8686+ table[b'y' as usize] = 30;
8787+ table[b'z' as usize] = 31;
8888+ table
8989+};
9090+9191+/// Timestamp Identifier (TID) for AT Protocol records.
9292+///
9393+/// A TID combines a microsecond-precision timestamp with a random clock identifier
9494+/// to create a sortable, collision-resistant identifier. TIDs are represented as
9595+/// 13-character base32-sortable strings.
9696+///
9797+/// ## Monotonicity
9898+///
9999+/// The TID generator ensures monotonically increasing values even when the system
100100+/// clock moves backwards or multiple TIDs are generated within the same microsecond.
101101+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
102102+pub struct Tid(u64);
103103+104104+/// Thread-local state for monotonic TID generation.
105105+///
106106+/// Tracks the last generated timestamp and clock identifier to ensure
107107+/// monotonically increasing TID values.
108108+static LAST_TID: Mutex<Option<(u64, u16)>> = Mutex::new(None);
109109+110110+impl Tid {
111111+ /// The length of a TID string in characters.
112112+ pub const LENGTH: usize = 13;
113113+114114+ /// Maximum valid timestamp value (53 bits).
115115+ const MAX_TIMESTAMP: u64 = (1u64 << 53) - 1;
116116+117117+ /// Bitmask for extracting the 10-bit clock identifier.
118118+ const CLOCK_ID_MASK: u64 = 0x3FF;
119119+120120+ /// Creates a new TID with the current timestamp and a random clock identifier.
121121+ ///
122122+ /// This function ensures monotonically increasing TID values by tracking the
123123+ /// last generated TID and incrementing the clock identifier when necessary.
124124+ ///
125125+ /// # Example
126126+ ///
127127+ /// ```
128128+ /// use atproto_record::tid::Tid;
129129+ ///
130130+ /// let tid = Tid::new();
131131+ /// println!("Generated TID: {}", tid);
132132+ /// ```
133133+ pub fn new() -> Self {
134134+ Self::new_with_time(Self::current_timestamp_micros())
135135+ }
136136+137137+ /// Creates a new TID with a specific timestamp (for testing).
138138+ ///
139139+ /// # Arguments
140140+ ///
141141+ /// * `timestamp_micros` - Microseconds since UNIX epoch
142142+ ///
143143+ /// # Panics
144144+ ///
145145+ /// Panics if the timestamp exceeds 53 bits (year 2255+).
146146+ pub fn new_with_time(timestamp_micros: u64) -> Self {
147147+ assert!(
148148+ timestamp_micros <= Self::MAX_TIMESTAMP,
149149+ "Timestamp exceeds 53-bit maximum"
150150+ );
151151+152152+ let mut last = LAST_TID.lock().unwrap();
153153+154154+ let clock_id = if let Some((last_timestamp, last_clock)) = *last {
155155+ if timestamp_micros > last_timestamp {
156156+ // New timestamp, generate random clock ID
157157+ Self::random_clock_id()
158158+ } else if timestamp_micros == last_timestamp {
159159+ // Same timestamp, increment clock ID
160160+ if last_clock == Self::CLOCK_ID_MASK as u16 {
161161+ // Clock ID overflow, use random
162162+ Self::random_clock_id()
163163+ } else {
164164+ last_clock + 1
165165+ }
166166+ } else {
167167+ // Clock moved backwards, use last timestamp + 1
168168+ let adjusted_timestamp = last_timestamp + 1;
169169+ let adjusted_clock = Self::random_clock_id();
170170+ *last = Some((adjusted_timestamp, adjusted_clock));
171171+ return Self::from_parts(adjusted_timestamp, adjusted_clock);
172172+ }
173173+ } else {
174174+ // First TID, generate random clock ID
175175+ Self::random_clock_id()
176176+ };
177177+178178+ *last = Some((timestamp_micros, clock_id));
179179+ Self::from_parts(timestamp_micros, clock_id)
180180+ }
181181+182182+ /// Creates a TID from timestamp and clock identifier components.
183183+ ///
184184+ /// # Arguments
185185+ ///
186186+ /// * `timestamp_micros` - Microseconds since UNIX epoch (53 bits max)
187187+ /// * `clock_id` - Random clock identifier (10 bits max)
188188+ ///
189189+ /// # Panics
190190+ ///
191191+ /// Panics if timestamp exceeds 53 bits or clock_id exceeds 10 bits.
192192+ pub fn from_parts(timestamp_micros: u64, clock_id: u16) -> Self {
193193+ assert!(
194194+ timestamp_micros <= Self::MAX_TIMESTAMP,
195195+ "Timestamp exceeds 53-bit maximum"
196196+ );
197197+ assert!(
198198+ clock_id <= Self::CLOCK_ID_MASK as u16,
199199+ "Clock ID exceeds 10-bit maximum"
200200+ );
201201+202202+ // Combine: top bit 0, 53 bits timestamp, 10 bits clock ID
203203+ let value = (timestamp_micros << 10) | (clock_id as u64);
204204+ Tid(value)
205205+ }
206206+207207+ /// Returns the timestamp component in microseconds since UNIX epoch.
208208+ ///
209209+ /// # Example
210210+ ///
211211+ /// ```
212212+ /// use atproto_record::tid::Tid;
213213+ ///
214214+ /// let tid = Tid::new();
215215+ /// let timestamp = tid.timestamp_micros();
216216+ /// println!("Timestamp: {} μs", timestamp);
217217+ /// ```
218218+ pub fn timestamp_micros(&self) -> u64 {
219219+ self.0 >> 10
220220+ }
221221+222222+ /// Returns the clock identifier component (10 bits).
223223+ ///
224224+ /// # Example
225225+ ///
226226+ /// ```
227227+ /// use atproto_record::tid::Tid;
228228+ ///
229229+ /// let tid = Tid::new();
230230+ /// let clock_id = tid.clock_id();
231231+ /// println!("Clock ID: {}", clock_id);
232232+ /// ```
233233+ pub fn clock_id(&self) -> u16 {
234234+ (self.0 & Self::CLOCK_ID_MASK) as u16
235235+ }
236236+237237+ /// Returns the raw 64-bit integer value.
238238+ pub fn as_u64(&self) -> u64 {
239239+ self.0
240240+ }
241241+242242+ /// Encodes the TID as a 13-character base32-sortable string.
243243+ ///
244244+ /// # Example
245245+ ///
246246+ /// ```
247247+ /// use atproto_record::tid::Tid;
248248+ ///
249249+ /// let tid = Tid::new();
250250+ /// let encoded = tid.encode();
251251+ /// assert_eq!(encoded.len(), 13);
252252+ /// ```
253253+ pub fn encode(&self) -> String {
254254+ let mut chars = [0u8; Self::LENGTH];
255255+ let mut value = self.0;
256256+257257+ // Encode from right to left (least significant to most significant)
258258+ for i in (0..Self::LENGTH).rev() {
259259+ chars[i] = BASE32_SORTABLE[(value & 0x1F) as usize];
260260+ value >>= 5;
261261+ }
262262+263263+ // BASE32_SORTABLE only contains valid UTF-8 ASCII characters
264264+ String::from_utf8(chars.to_vec()).expect("base32-sortable encoding is always valid UTF-8")
265265+ }
266266+267267+ /// Decodes a base32-sortable string into a TID.
268268+ ///
269269+ /// # Errors
270270+ ///
271271+ /// Returns [`TidError::InvalidLength`] if the string is not exactly 13 characters.
272272+ /// Returns [`TidError::InvalidCharacter`] if the string contains invalid characters.
273273+ /// Returns [`TidError::InvalidFormat`] if the decoded value has the top bit set.
274274+ ///
275275+ /// # Example
276276+ ///
277277+ /// ```
278278+ /// use atproto_record::tid::Tid;
279279+ ///
280280+ /// let tid_str = "3jzfcijpj2z2a";
281281+ /// let tid = Tid::decode(tid_str).unwrap();
282282+ /// assert_eq!(tid.to_string(), tid_str);
283283+ /// ```
284284+ pub fn decode(s: &str) -> Result<Self, TidError> {
285285+ if s.len() != Self::LENGTH {
286286+ return Err(TidError::InvalidLength {
287287+ expected: Self::LENGTH,
288288+ actual: s.len(),
289289+ });
290290+ }
291291+292292+ let bytes = s.as_bytes();
293293+ let mut value: u64 = 0;
294294+295295+ for (i, &byte) in bytes.iter().enumerate() {
296296+ let decoded = BASE32_DECODE[byte as usize];
297297+ if decoded == 0xFF {
298298+ return Err(TidError::InvalidCharacter {
299299+ character: byte as char,
300300+ position: i,
301301+ });
302302+ }
303303+ value = (value << 5) | (decoded as u64);
304304+ }
305305+306306+ // Verify top bit is 0
307307+ if value & (1u64 << 63) != 0 {
308308+ return Err(TidError::InvalidFormat {
309309+ reason: "Top bit must be 0".to_string(),
310310+ });
311311+ }
312312+313313+ Ok(Tid(value))
314314+ }
315315+316316+ /// Gets the current timestamp in microseconds since UNIX epoch.
317317+ fn current_timestamp_micros() -> u64 {
318318+ SystemTime::now()
319319+ .duration_since(UNIX_EPOCH)
320320+ .expect("System time before UNIX epoch")
321321+ .as_micros() as u64
322322+ }
323323+324324+ /// Generates a random 10-bit clock identifier.
325325+ fn random_clock_id() -> u16 {
326326+ use rand::RngCore;
327327+ let mut rng = rand::thread_rng();
328328+ (rng.next_u32() as u16) & (Self::CLOCK_ID_MASK as u16)
329329+ }
330330+}
331331+332332+impl Default for Tid {
333333+ fn default() -> Self {
334334+ Self::new()
335335+ }
336336+}
337337+338338+impl fmt::Display for Tid {
339339+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340340+ write!(f, "{}", self.encode())
341341+ }
342342+}
343343+344344+impl FromStr for Tid {
345345+ type Err = TidError;
346346+347347+ fn from_str(s: &str) -> Result<Self, Self::Err> {
348348+ Self::decode(s)
349349+ }
350350+}
351351+352352+impl serde::Serialize for Tid {
353353+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
354354+ where
355355+ S: serde::Serializer,
356356+ {
357357+ serializer.serialize_str(&self.encode())
358358+ }
359359+}
360360+361361+impl<'de> serde::Deserialize<'de> for Tid {
362362+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363363+ where
364364+ D: serde::Deserializer<'de>,
365365+ {
366366+ let s = String::deserialize(deserializer)?;
367367+ Self::decode(&s).map_err(serde::de::Error::custom)
368368+ }
369369+}
370370+371371+#[cfg(test)]
372372+mod tests {
373373+ use super::*;
374374+375375+ #[test]
376376+ fn test_tid_encode_decode() {
377377+ let tid = Tid::new();
378378+ let encoded = tid.encode();
379379+ assert_eq!(encoded.len(), Tid::LENGTH);
380380+381381+ let decoded = Tid::decode(&encoded).unwrap();
382382+ assert_eq!(tid, decoded);
383383+ }
384384+385385+ #[test]
386386+ fn test_tid_from_parts() {
387387+ let timestamp = 1234567890123456u64;
388388+ let clock_id = 42u16;
389389+ let tid = Tid::from_parts(timestamp, clock_id);
390390+391391+ assert_eq!(tid.timestamp_micros(), timestamp);
392392+ assert_eq!(tid.clock_id(), clock_id);
393393+ }
394394+395395+ #[test]
396396+ fn test_tid_monotonic() {
397397+ let tid1 = Tid::new();
398398+ std::thread::sleep(std::time::Duration::from_micros(10));
399399+ let tid2 = Tid::new();
400400+401401+ assert!(tid1 < tid2);
402402+ }
403403+404404+ #[test]
405405+ fn test_tid_same_timestamp() {
406406+ let timestamp = 1234567890123456u64;
407407+ let tid1 = Tid::new_with_time(timestamp);
408408+ let tid2 = Tid::new_with_time(timestamp);
409409+410410+ // Should have different clock IDs or incremented clock ID
411411+ assert!(tid1 < tid2 || tid1.clock_id() + 1 == tid2.clock_id());
412412+ }
413413+414414+ #[test]
415415+ fn test_tid_string_roundtrip() {
416416+ let tid = Tid::new();
417417+ let s = tid.to_string();
418418+ let parsed: Tid = s.parse().unwrap();
419419+ assert_eq!(tid, parsed);
420420+ }
421421+422422+ #[test]
423423+ fn test_tid_serde() {
424424+ let tid = Tid::new();
425425+ let json = serde_json::to_string(&tid).unwrap();
426426+ let parsed: Tid = serde_json::from_str(&json).unwrap();
427427+ assert_eq!(tid, parsed);
428428+ }
429429+430430+ #[test]
431431+ fn test_tid_valid_examples() {
432432+ // Examples from the specification
433433+ let examples = ["3jzfcijpj2z2a", "7777777777777", "2222222222222"];
434434+435435+ for example in &examples {
436436+ let tid = Tid::decode(example).unwrap();
437437+ assert_eq!(&tid.encode(), example);
438438+ }
439439+ }
440440+441441+ #[test]
442442+ fn test_tid_invalid_length() {
443443+ let result = Tid::decode("123");
444444+ assert!(matches!(result, Err(TidError::InvalidLength { .. })));
445445+ }
446446+447447+ #[test]
448448+ fn test_tid_invalid_character() {
449449+ let result = Tid::decode("123456789012!");
450450+ assert!(matches!(result, Err(TidError::InvalidCharacter { .. })));
451451+ }
452452+453453+ #[test]
454454+ fn test_tid_first_char_range() {
455455+ // First character must be in valid range per spec
456456+ let tid = Tid::new();
457457+ let encoded = tid.encode();
458458+ let first_char = encoded.chars().next().unwrap();
459459+460460+ // First char must be 234567abcdefghij (values 0-15 in base32-sortable)
461461+ assert!("234567abcdefghij".contains(first_char));
462462+ }
463463+464464+ #[test]
465465+ fn test_tid_sortability() {
466466+ // TIDs with increasing timestamps should sort correctly as strings
467467+ let tid1 = Tid::from_parts(1000000, 0);
468468+ let tid2 = Tid::from_parts(2000000, 0);
469469+ let tid3 = Tid::from_parts(3000000, 0);
470470+471471+ let s1 = tid1.to_string();
472472+ let s2 = tid2.to_string();
473473+ let s3 = tid3.to_string();
474474+475475+ assert!(s1 < s2);
476476+ assert!(s2 < s3);
477477+ assert!(s1 < s3);
478478+ }
479479+480480+ #[test]
481481+ fn test_tid_clock_backward() {
482482+ // Simulate clock moving backwards
483483+ let timestamp1 = 2000000u64;
484484+ let tid1 = Tid::new_with_time(timestamp1);
485485+486486+ let timestamp2 = 1000000u64; // Earlier timestamp
487487+ let tid2 = Tid::new_with_time(timestamp2);
488488+489489+ // TID should still be monotonically increasing
490490+ assert!(tid2 > tid1);
491491+ }
492492+}