A library for ATProtocol identities.

feature: attestation crate

+16 -8
CLAUDE.md
··· 27 27 - **Sign data**: `cargo run --features clap --bin atproto-identity-sign -- <did_key> <json_file>` 28 28 - **Validate signatures**: `cargo run --features clap --bin atproto-identity-validate -- <did_key> <json_file> <signature>` 29 29 30 + #### Attestation Operations 31 + - **Sign records (inline)**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- inline <source_record> <signing_key> <metadata_record>` 32 + - **Sign records (remote)**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- remote <source_record> <repository_did> <metadata_record>` 33 + - **Verify records**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record>` (verifies all signatures) 34 + - **Verify attestation**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record> <attestation>` (verifies specific attestation) 35 + 30 36 #### Record Operations 31 - - **Sign records**: `cargo run --features clap --bin atproto-record-sign -- <issuer_did> <signing_key> <record_input> repository=<repo> collection=<collection>` 32 - - **Verify records**: `cargo run --features clap --bin atproto-record-verify -- <issuer_did> <key> <record_input> repository=<repo> collection=<collection>` 33 37 - **Generate CID**: `cat record.json | cargo run --features clap --bin atproto-record-cid` (reads JSON from stdin, outputs CID) 34 38 35 39 #### Client Tools ··· 45 49 ## Architecture 46 50 47 51 A comprehensive Rust workspace with multiple crates: 48 - - **atproto-identity**: Core identity management with 10 modules (resolve, plc, web, model, validation, config, errors, key, storage, storage_lru) 49 - - **atproto-record**: Record signature operations and validation 52 + - **atproto-identity**: Core identity management with 11 modules (resolve, plc, web, model, validation, config, errors, key, storage_lru, traits, url) 53 + - **atproto-attestation**: CID-first attestation utilities for creating and verifying record signatures 54 + - **atproto-record**: Record utilities including TID generation, AT-URI parsing, and CID generation 50 55 - **atproto-client**: HTTP client with OAuth and identity integration 51 56 - **atproto-jetstream**: WebSocket event streaming with compression 52 57 - **atproto-oauth**: OAuth workflow implementation with DPoP, PKCE, JWT, and storage abstractions ··· 137 142 ### Core Library Modules (atproto-identity) 138 143 - **`src/lib.rs`**: Main library exports 139 144 - **`src/resolve.rs`**: Core resolution logic for handles and DIDs, DNS/HTTP resolution 140 - - **`src/plc.rs`**: PLC directory client for did:plc resolution 145 + - **`src/plc.rs`**: PLC directory client for did:plc resolution 141 146 - **`src/web.rs`**: Web DID client for did:web resolution and URL conversion 142 147 - **`src/model.rs`**: Data structures for DID documents and AT Protocol entities 143 148 - **`src/validation.rs`**: Input validation for handles and DIDs 144 149 - **`src/config.rs`**: Configuration management and environment variable handling 145 150 - **`src/errors.rs`**: Structured error types following project conventions 146 151 - **`src/key.rs`**: Cryptographic key operations including signature validation and key identification for P-256, P-384, and K-256 curves 147 - - **`src/storage.rs`**: Storage abstraction interface for DID document caching 148 152 - **`src/storage_lru.rs`**: LRU-based storage implementation (requires `lru` feature) 153 + - **`src/traits.rs`**: Core trait definitions for identity resolution and key resolution 154 + - **`src/url.rs`**: URL utilities for AT Protocol services 149 155 150 156 ### CLI Tools (require --features clap) 151 157 ··· 155 161 - **`src/bin/atproto-identity-sign.rs`**: Create cryptographic signatures of JSON data 156 162 - **`src/bin/atproto-identity-validate.rs`**: Validate cryptographic signatures 157 163 164 + #### Attestation Operations (atproto-attestation) 165 + - **`src/bin/atproto-attestation-sign.rs`**: Sign AT Protocol records with inline or remote attestations using CID-first specification 166 + - **`src/bin/atproto-attestation-verify.rs`**: Verify cryptographic signatures on AT Protocol records with attestation validation 167 + 158 168 #### Record Operations (atproto-record) 159 - - **`src/bin/atproto-record-sign.rs`**: Sign AT Protocol records with cryptographic signatures 160 - - **`src/bin/atproto-record-verify.rs`**: Verify AT Protocol record signatures 161 169 - **`src/bin/atproto-record-cid.rs`**: Generate CID (Content Identifier) for AT Protocol records using DAG-CBOR serialization 162 170 163 171 #### Client Tools (atproto-client)
+29
Cargo.lock
··· 106 106 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 107 107 108 108 [[package]] 109 + name = "atproto-attestation" 110 + version = "0.13.0" 111 + dependencies = [ 112 + "anyhow", 113 + "async-trait", 114 + "atproto-client", 115 + "atproto-identity", 116 + "atproto-record", 117 + "base64", 118 + "cid", 119 + "clap", 120 + "elliptic-curve", 121 + "k256", 122 + "multihash", 123 + "p256", 124 + "reqwest", 125 + "serde", 126 + "serde_ipld_dagcbor", 127 + "serde_json", 128 + "sha2", 129 + "thiserror 2.0.12", 130 + "tokio", 131 + ] 132 + 133 + [[package]] 109 134 name = "atproto-client" 110 135 version = "0.13.0" 111 136 dependencies = [ 112 137 "anyhow", 138 + "async-trait", 113 139 "atproto-identity", 114 140 "atproto-oauth", 115 141 "atproto-record", ··· 151 177 "thiserror 2.0.12", 152 178 "tokio", 153 179 "tracing", 180 + "url", 154 181 "urlencoding", 155 182 "zeroize", 156 183 ] ··· 277 304 version = "0.13.0" 278 305 dependencies = [ 279 306 "anyhow", 307 + "async-trait", 280 308 "atproto-identity", 281 309 "base64", 282 310 "chrono", 283 311 "cid", 284 312 "clap", 285 313 "multihash", 314 + "rand 0.8.5", 286 315 "serde", 287 316 "serde_ipld_dagcbor", 288 317 "serde_json",
+4 -1
Cargo.toml
··· 10 10 "crates/atproto-xrpcs-helloworld", 11 11 "crates/atproto-xrpcs", 12 12 "crates/atproto-lexicon", 13 + "crates/atproto-attestation", 13 14 ] 14 15 resolver = "3" 15 16 ··· 31 32 atproto-record = { version = "0.13.0", path = "crates/atproto-record" } 32 33 atproto-xrpcs = { version = "0.13.0", path = "crates/atproto-xrpcs" } 33 34 atproto-jetstream = { version = "0.13.0", path = "crates/atproto-jetstream" } 35 + atproto-attestation = { version = "0.13.0", path = "crates/atproto-attestation" } 34 36 35 37 anyhow = "1.0" 36 38 async-trait = "0.1.88" ··· 63 65 tokio-util = "0.7" 64 66 tracing = { version = "0.1", features = ["async-await"] } 65 67 ulid = "1.2.1" 68 + zstd = "0.13" 69 + url = "2.5" 66 70 urlencoding = "2.1" 67 - zstd = "0.13" 68 71 69 72 zeroize = { version = "1.8.1", features = ["zeroize_derive"] } 70 73
+12 -8
Dockerfile
··· 1 1 # Multi-stage build for atproto-identity-rs workspace 2 - # Builds and installs all 13 binaries from the workspace 2 + # Builds and installs all 15 binaries from the workspace 3 3 4 - # Build stage - use 1.89 to support resolver = "3" and edition = "2024" 4 + # Build stage - use 1.90 to support resolver = "3" and edition = "2024" 5 5 FROM rust:1.90-slim-bookworm AS builder 6 6 7 7 # Install system dependencies needed for building ··· 19 19 # Build all binaries in release mode 20 20 # This will build all binaries defined in the workspace: 21 21 # - atproto-identity: 4 binaries (resolve, key, sign, validate) 22 - # - atproto-record: 2 binaries (sign, verify) 22 + # - atproto-attestation: 2 binaries (attestation-sign, attestation-verify) 23 + # - atproto-record: 1 binary (record-cid) 23 24 # - atproto-client: 3 binaries (auth, app-password, dpop) 24 25 # - atproto-oauth: 1 binary (service-token) 25 26 # - atproto-oauth-axum: 1 binary (oauth-tool) ··· 40 41 COPY --from=builder /usr/src/app/target/release/atproto-identity-key . 41 42 COPY --from=builder /usr/src/app/target/release/atproto-identity-sign . 42 43 COPY --from=builder /usr/src/app/target/release/atproto-identity-validate . 43 - COPY --from=builder /usr/src/app/target/release/atproto-record-sign . 44 - COPY --from=builder /usr/src/app/target/release/atproto-record-verify . 44 + COPY --from=builder /usr/src/app/target/release/atproto-attestation-sign . 45 + COPY --from=builder /usr/src/app/target/release/atproto-attestation-verify . 46 + COPY --from=builder /usr/src/app/target/release/atproto-record-cid . 45 47 COPY --from=builder /usr/src/app/target/release/atproto-client-auth . 46 48 COPY --from=builder /usr/src/app/target/release/atproto-client-app-password . 47 49 COPY --from=builder /usr/src/app/target/release/atproto-client-dpop . ··· 53 55 54 56 # Default to the main resolution tool 55 57 # Users can override with specific binary: docker run <image> atproto-identity-resolve --help 56 - # Or run other tools: 58 + # Or run other tools: 57 59 # docker run <image> atproto-identity-key --help 58 - # docker run <image> atproto-record-sign --help 60 + # docker run <image> atproto-attestation-sign --help 61 + # docker run <image> atproto-attestation-verify --help 62 + # docker run <image> atproto-record-cid --help 59 63 # docker run <image> atproto-client-auth --help 60 64 # docker run <image> atproto-oauth-service-token --help 61 65 # docker run <image> atproto-oauth-tool --help ··· 73 77 LABEL org.opencontainers.image.licenses="MIT" 74 78 75 79 # Document available binaries 76 - LABEL binaries="atproto-identity-resolve,atproto-identity-key,atproto-identity-sign,atproto-identity-validate,atproto-record-sign,atproto-record-verify,atproto-client-auth,atproto-client-app-password,atproto-client-dpop,atproto-oauth-service-token,atproto-oauth-tool,atproto-jetstream-consumer,atproto-xrpcs-helloworld,atproto-lexicon-resolve" 80 + LABEL binaries="atproto-identity-resolve,atproto-identity-key,atproto-identity-sign,atproto-identity-validate,atproto-attestation-sign,atproto-attestation-verify,atproto-record-cid,atproto-client-auth,atproto-client-app-password,atproto-client-dpop,atproto-oauth-service-token,atproto-oauth-tool,atproto-jetstream-consumer,atproto-xrpcs-helloworld,atproto-lexicon-resolve"
+22 -15
README.md
··· 11 11 ### Identity & Cryptography 12 12 13 13 - **[`atproto-identity`](crates/atproto-identity/)** - Core identity management with multi-method DID resolution (plc, web, key), DNS/HTTP handle resolution, and P-256/P-384/K-256 cryptographic operations. *Includes 4 CLI tools.* 14 - - **[`atproto-record`](crates/atproto-record/)** - Cryptographic signature operations for AT Protocol records using IPLD DAG-CBOR serialization with AT-URI parsing support. *Includes 2 CLI tools.* 14 + - **[`atproto-attestation`](crates/atproto-attestation/)** - CID-first attestation utilities for creating and verifying cryptographic signatures on AT Protocol records, supporting both inline and remote attestation workflows. *Includes 2 CLI tools.* 15 + - **[`atproto-record`](crates/atproto-record/)** - Record utilities including TID generation, AT-URI parsing, datetime formatting, and CID generation using IPLD DAG-CBOR serialization. *Includes 1 CLI tool.* 15 16 - **[`atproto-lexicon`](crates/atproto-lexicon/)** - Lexicon schema resolution and validation for AT Protocol, supporting recursive resolution, NSID validation, and DNS-based lexicon discovery. *Includes 1 CLI tool.* 16 17 17 18 ### Authentication & Authorization ··· 37 38 ```toml 38 39 [dependencies] 39 40 atproto-identity = "0.13.0" 41 + atproto-attestation = "0.13.0" 40 42 atproto-record = "0.13.0" 41 43 atproto-lexicon = "0.13.0" 42 44 atproto-oauth = "0.13.0" ··· 85 87 ### Record Signing 86 88 87 89 ```rust 88 - use atproto_identity::key::identify_key; 89 - use atproto_record::signature; 90 + use atproto_identity::key::{identify_key, to_public}; 91 + use atproto_attestation::{create_inline_attestation, verify_all_signatures, VerificationStatus}; 90 92 use serde_json::json; 91 93 92 94 #[tokio::main] 93 95 async fn main() -> anyhow::Result<()> { 94 - let signing_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 96 + let private_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 97 + let public_key = to_public(&private_key)?; 98 + let key_reference = format!("{}", &public_key); 95 99 96 100 let record = json!({ 97 101 "$type": "app.bsky.feed.post", ··· 99 103 "createdAt": "2024-01-01T00:00:00.000Z" 100 104 }); 101 105 102 - let signature_object = json!({ 106 + let sig_metadata = json!({ 107 + "$type": "com.example.inlineSignature", 108 + "key": &key_reference, 103 109 "issuer": "did:plc:issuer123", 104 110 "issuedAt": "2024-01-01T00:00:00.000Z" 105 111 }); 106 112 107 - let signed_record = signature::create( 108 - &signing_key, 109 - &record, 110 - "did:plc:user123", 111 - "app.bsky.feed.post", 112 - signature_object, 113 - ).await?; 113 + let signed_record = 114 + create_inline_attestation(&record, &sig_metadata, &private_key)?; 115 + 116 + let reports = verify_all_signatures(&signed_record, None).await?; 117 + assert!(reports.iter().all(|report| matches!(report.status, VerificationStatus::Valid { .. }))); 114 118 115 119 Ok(()) 116 120 } ··· 212 216 cargo run --features clap --bin atproto-identity-sign -- did:key:... data.json 213 217 cargo run --features clap --bin atproto-identity-validate -- did:key:... data.json signature 214 218 219 + # Attestation operations (atproto-attestation crate) 220 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- inline record.json did:key:... metadata.json 221 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- signed_record.json 222 + 215 223 # Record operations (atproto-record crate) 216 - cargo run --features clap --bin atproto-record-sign -- did:key:... did:plc:issuer record.json repository=did:plc:user collection=app.bsky.feed.post 217 - cargo run --features clap --bin atproto-record-verify -- did:plc:issuer did:key:... signed_record.json repository=did:plc:user collection=app.bsky.feed.post 224 + cat record.json | cargo run --features clap --bin atproto-record-cid 218 225 219 226 # Lexicon operations (atproto-lexicon crate) 220 227 cargo run --features clap,hickory-dns --bin atproto-lexicon-resolve -- app.bsky.feed.post ··· 289 296 290 297 ## Acknowledgments 291 298 292 - Parts of this project were extracted from the [smokesignal.events](https://tangled.sh/@smokesignal.events/smokesignal) project, an open-source AT Protocol event and RSVP management application. This extraction enables broader community use and contribution to AT Protocol tooling in Rust. 299 + Parts of this project were extracted from the [smokesignal.events](https://tangled.sh/@smokesignal.events/smokesignal) project, an open-source AT Protocol event and RSVP management application. This extraction enables broader community use and contribution to AT Protocol tooling in Rust.
+63
crates/atproto-attestation/Cargo.toml
··· 1 + [package] 2 + name = "atproto-attestation" 3 + version = "0.13.0" 4 + description = "AT Protocol attestation utilities for creating and verifying record signatures" 5 + readme = "README.md" 6 + homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs" 7 + documentation = "https://docs.rs/atproto-attestation" 8 + edition.workspace = true 9 + rust-version.workspace = true 10 + repository.workspace = true 11 + authors.workspace = true 12 + license.workspace = true 13 + keywords.workspace = true 14 + categories.workspace = true 15 + 16 + [[bin]] 17 + name = "atproto-attestation-sign" 18 + test = false 19 + bench = false 20 + doc = true 21 + required-features = ["clap", "tokio"] 22 + 23 + [[bin]] 24 + name = "atproto-attestation-verify" 25 + test = false 26 + bench = false 27 + doc = true 28 + required-features = ["clap", "tokio"] 29 + 30 + [dependencies] 31 + atproto-client.workspace = true 32 + atproto-identity.workspace = true 33 + atproto-record.workspace = true 34 + anyhow.workspace = true 35 + base64.workspace = true 36 + serde.workspace = true 37 + serde_json.workspace = true 38 + serde_ipld_dagcbor.workspace = true 39 + sha2.workspace = true 40 + thiserror.workspace = true 41 + 42 + cid = "0.11" 43 + elliptic-curve = { version = "0.13", default-features = false, features = ["std"] } 44 + k256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] } 45 + multihash = "0.19" 46 + p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] } 47 + 48 + async-trait = { workspace = true, optional = true } 49 + clap = { workspace = true, optional = true } 50 + reqwest = { workspace = true, optional = true } 51 + tokio = { workspace = true, optional = true } 52 + 53 + [dev-dependencies] 54 + async-trait = "0.1" 55 + tokio = { workspace = true, features = ["macros", "rt"] } 56 + 57 + [features] 58 + default = [] 59 + clap = ["dep:clap"] 60 + tokio = ["dep:tokio", "dep:reqwest", "dep:async-trait"] 61 + 62 + [lints] 63 + workspace = true
+421
crates/atproto-attestation/README.md
··· 1 + # atproto-attestation 2 + 3 + Utilities for preparing, signing, and verifying AT Protocol record attestations using the CID-first workflow. 4 + 5 + ## Overview 6 + 7 + A Rust library implementing the CID-first attestation specification for AT Protocol records. This crate provides cryptographic signature creation and verification for records, supporting both inline attestations (signatures embedded directly in records) and remote attestations (separate proof records with strongRef references). 8 + 9 + The attestation workflow ensures deterministic signing payloads by: 10 + 1. Preparing records with `$sig` metadata 11 + 2. Generating content identifiers (CIDs) using DAG-CBOR serialization 12 + 3. Signing CID bytes with elliptic curve cryptography 13 + 4. Embedding or referencing signatures in records 14 + 5. Verifying signatures against resolved public keys 15 + 16 + ## Features 17 + 18 + - **Inline attestations**: Embed cryptographic signatures directly in record structures 19 + - **Remote attestations**: Create separate proof records with CID-based strongRef references 20 + - **CID-first workflow**: Deterministic signing based on content identifiers 21 + - **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curves 22 + - **Signature normalization**: Automatic low-S normalization for ECDSA signatures 23 + - **Key resolution**: Resolve verification keys from DID documents or did:key identifiers 24 + - **Flexible verification**: Verify individual signatures or all signatures in a record 25 + - **Structured reporting**: Detailed verification reports with success/failure status 26 + 27 + ## CLI Tools 28 + 29 + The following command-line tools are available when built with the `clap` and `tokio` features: 30 + 31 + - **`atproto-attestation-sign`**: Sign AT Protocol records with inline or remote attestations 32 + - **`atproto-attestation-verify`**: Verify cryptographic signatures on AT Protocol records 33 + 34 + ## Library Usage 35 + 36 + ### Creating Inline Attestations 37 + 38 + Inline attestations embed the signature bytes directly in the record: 39 + 40 + ```rust 41 + use atproto_identity::key::{identify_key, to_public}; 42 + use atproto_attestation::create_inline_attestation; 43 + use serde_json::json; 44 + 45 + #[tokio::main] 46 + async fn main() -> anyhow::Result<()> { 47 + // Parse the signing key from a did:key 48 + let private_key = identify_key("did:key:zQ3sh...")?; 49 + let public_key = to_public(&private_key)?; 50 + let key_reference = format!("{}", &public_key); 51 + 52 + // The record to sign 53 + let record = json!({ 54 + "$type": "app.bsky.feed.post", 55 + "text": "Hello world!", 56 + "createdAt": "2024-01-01T00:00:00.000Z" 57 + }); 58 + 59 + // Attestation metadata (required fields: $type, key) 60 + let sig_metadata = json!({ 61 + "$type": "com.example.inlineSignature", 62 + "key": &key_reference, 63 + "issuer": "did:plc:issuer123", 64 + "issuedAt": "2024-01-01T00:00:00.000Z" 65 + }); 66 + 67 + // Create inline attestation 68 + let signed_record = create_inline_attestation(&record, &sig_metadata, &private_key)?; 69 + 70 + println!("{}", serde_json::to_string_pretty(&signed_record)?); 71 + 72 + Ok(()) 73 + } 74 + ``` 75 + 76 + The resulting record will have a `signatures` array: 77 + 78 + ```json 79 + { 80 + "$type": "app.bsky.feed.post", 81 + "text": "Hello world!", 82 + "createdAt": "2024-01-01T00:00:00.000Z", 83 + "signatures": [ 84 + { 85 + "$type": "com.example.inlineSignature", 86 + "key": "did:key:zQ3sh...", 87 + "issuer": "did:plc:issuer123", 88 + "issuedAt": "2024-01-01T00:00:00.000Z", 89 + "signature": { 90 + "$bytes": "base64-encoded-signature-bytes" 91 + } 92 + } 93 + ] 94 + } 95 + ``` 96 + 97 + ### Creating Remote Attestations 98 + 99 + Remote attestations create a separate proof record that must be stored in a repository: 100 + 101 + ```rust 102 + use atproto_attestation::{create_remote_attestation, create_remote_attestation_reference}; 103 + use serde_json::json; 104 + 105 + let record = json!({ 106 + "$type": "app.bsky.feed.post", 107 + "text": "Hello world!" 108 + }); 109 + 110 + let metadata = json!({ 111 + "$type": "com.example.attestation", 112 + "issuer": "did:plc:issuer123", 113 + "purpose": "verification" 114 + }); 115 + 116 + // Create the proof record (contains the CID) 117 + let proof_record = create_remote_attestation(&record, &metadata)?; 118 + 119 + // Create the source record with strongRef 120 + let repository_did = "did:plc:repo123"; 121 + let attested_record = create_remote_attestation_reference( 122 + &record, 123 + &proof_record, 124 + repository_did 125 + )?; 126 + 127 + // The proof_record should be stored in the repository 128 + // The attested_record contains the strongRef reference 129 + ``` 130 + 131 + ### Verifying Signatures 132 + 133 + Verify signatures embedded in records: 134 + 135 + ```rust 136 + use atproto_attestation::{verify_all_signatures, VerificationStatus}; 137 + 138 + #[tokio::main] 139 + async fn main() -> anyhow::Result<()> { 140 + // Signed record with signatures array 141 + let signed_record = /* ... */; 142 + 143 + // Verify all signatures (remote attestations will be unverified) 144 + let reports = verify_all_signatures(&signed_record, None).await?; 145 + 146 + for report in reports { 147 + match report.status { 148 + VerificationStatus::Valid { cid } => { 149 + println!("✓ Signature {} is valid (CID: {})", report.index, cid); 150 + } 151 + VerificationStatus::Invalid { error } => { 152 + println!("✗ Signature {} is invalid: {}", report.index, error); 153 + } 154 + VerificationStatus::Unverified { reason } => { 155 + println!("? Signature {} unverified: {}", report.index, reason); 156 + } 157 + } 158 + } 159 + 160 + Ok(()) 161 + } 162 + ``` 163 + 164 + ### Verifying with Custom Key Resolver 165 + 166 + For signatures that reference DID document keys (not did:key), provide a key resolver: 167 + 168 + ```rust 169 + use atproto_attestation::verify_all_signatures; 170 + use atproto_identity::key::IdentityDocumentKeyResolver; 171 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 172 + use std::sync::Arc; 173 + 174 + #[tokio::main] 175 + async fn main() -> anyhow::Result<()> { 176 + let http_client = reqwest::Client::new(); 177 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 178 + 179 + // Create identity and key resolvers 180 + let identity_resolver = Arc::new(InnerIdentityResolver { 181 + http_client: http_client.clone(), 182 + dns_resolver: Arc::new(dns_resolver), 183 + plc_hostname: "plc.directory".to_string(), 184 + }); 185 + let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver); 186 + 187 + let signed_record = /* ... */; 188 + 189 + // Verify with key resolver for DID document keys 190 + let reports = verify_all_signatures(&signed_record, Some(&key_resolver)).await?; 191 + 192 + Ok(()) 193 + } 194 + ``` 195 + 196 + ### Verifying Remote Attestations 197 + 198 + To verify remote attestations (strongRef), use `verify_all_signatures_with_resolver` and provide a `RecordResolver` that can fetch proof records: 199 + 200 + ```rust 201 + use atproto_attestation::verify_all_signatures_with_resolver; 202 + use atproto_client::record_resolver::RecordResolver; 203 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 204 + use atproto_identity::traits::IdentityResolver; 205 + use std::sync::Arc; 206 + 207 + // Custom record resolver that resolves DIDs to find PDS endpoints 208 + struct MyRecordResolver { 209 + http_client: reqwest::Client, 210 + identity_resolver: InnerIdentityResolver, 211 + } 212 + 213 + #[async_trait::async_trait] 214 + impl RecordResolver for MyRecordResolver { 215 + async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T> 216 + where 217 + T: serde::de::DeserializeOwned + Send, 218 + { 219 + // Parse AT-URI, resolve DID to PDS, fetch record 220 + // See atproto-attestation-verify.rs for full implementation 221 + todo!() 222 + } 223 + } 224 + 225 + #[tokio::main] 226 + async fn main() -> anyhow::Result<()> { 227 + let http_client = reqwest::Client::new(); 228 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 229 + 230 + let identity_resolver = InnerIdentityResolver { 231 + http_client: http_client.clone(), 232 + dns_resolver: Arc::new(dns_resolver), 233 + plc_hostname: "plc.directory".to_string(), 234 + }; 235 + 236 + let record_resolver = MyRecordResolver { 237 + http_client, 238 + identity_resolver, 239 + }; 240 + 241 + let signed_record = /* ... */; 242 + 243 + // Verify all signatures including remote attestations 244 + let reports = verify_all_signatures_with_resolver(&signed_record, None, Some(&record_resolver)).await?; 245 + 246 + Ok(()) 247 + } 248 + ``` 249 + 250 + ### Manual CID Generation 251 + 252 + For advanced use cases, manually generate CIDs: 253 + 254 + ```rust 255 + use atproto_attestation::{prepare_signing_record, create_cid}; 256 + use serde_json::json; 257 + 258 + let record = json!({ 259 + "$type": "app.bsky.feed.post", 260 + "text": "Manual CID generation" 261 + }); 262 + 263 + let metadata = json!({ 264 + "$type": "com.example.signature", 265 + "key": "did:key:z..." 266 + }); 267 + 268 + // Prepare the signing record (adds $sig, removes signatures) 269 + let signing_record = prepare_signing_record(&record, &metadata)?; 270 + 271 + // Generate the CID 272 + let cid = create_cid(&signing_record)?; 273 + println!("CID: {}", cid); 274 + ``` 275 + 276 + ## Command Line Usage 277 + 278 + ### Signing Records 279 + 280 + #### Inline Attestation 281 + 282 + ```bash 283 + # Sign with inline attestation (signature embedded in record) 284 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \ 285 + inline \ 286 + record.json \ 287 + did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA \ 288 + metadata.json 289 + 290 + # Using JSON strings instead of files 291 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \ 292 + inline \ 293 + '{"$type":"app.bsky.feed.post","text":"Hello!"}' \ 294 + did:key:zQ3sh... \ 295 + '{"$type":"com.example.sig","key":"did:key:zQ3sh..."}' 296 + 297 + # Read record from stdin 298 + cat record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \ 299 + inline \ 300 + - \ 301 + did:key:zQ3sh... \ 302 + metadata.json 303 + ``` 304 + 305 + #### Remote Attestation 306 + 307 + ```bash 308 + # Create remote attestation (generates proof record + strongRef) 309 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \ 310 + remote \ 311 + record.json \ 312 + did:plc:repo123... \ 313 + metadata.json 314 + 315 + # This outputs TWO JSON objects: 316 + # 1. Proof record (store this in the repository) 317 + # 2. Source record with strongRef attestation 318 + ``` 319 + 320 + ### Verifying Signatures 321 + 322 + #### Verify All Signatures in a Record 323 + 324 + ```bash 325 + # Verify all signatures in a record from file 326 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 327 + ./signed_record.json 328 + 329 + # Verify all signatures from AT-URI (fetches from PDS) 330 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 331 + at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g 332 + 333 + # Verify from stdin 334 + cat signed_record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- - 335 + 336 + # Verify from inline JSON 337 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 338 + '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' 339 + 340 + # Output shows each signature status: 341 + # ✓ Signature 0 valid (key: did:key:zQ3sh...pb3) [CID: bafyrei...] 342 + # ? Signature 1 unverified: Remote attestations require fetching the proof record via strongRef. 343 + # 344 + # Summary: 2 total, 1 valid 345 + ``` 346 + 347 + #### Verify Specific Attestation Against Record 348 + 349 + ```bash 350 + # Verify a specific attestation record (both from files) 351 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 352 + ./record.json \ 353 + ./attestation.json 354 + 355 + # Verify attestation from AT-URI against local record 356 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 357 + ./record.json \ 358 + at://did:plc:xyz/com.example.attestation/abc123 359 + 360 + # On success, outputs: 361 + # OK 362 + # CID: bafyrei... 363 + ``` 364 + 365 + ## Attestation Specification 366 + 367 + This crate implements the CID-first attestation specification, which ensures: 368 + 369 + 1. **Deterministic signing**: Records are serialized to DAG-CBOR with `$sig` metadata, producing consistent CIDs 370 + 2. **Content addressing**: Signatures are over CID bytes, not the full record 371 + 3. **Flexible metadata**: Custom fields in `$sig` are preserved and included in the CID calculation 372 + 4. **Signature normalization**: ECDSA signatures are normalized to low-S form 373 + 5. **Multiple attestations**: Records can have multiple signatures in the `signatures` array 374 + 375 + ### Signature Structure 376 + 377 + Inline attestation entry: 378 + ```json 379 + { 380 + "$type": "com.example.signature", 381 + "key": "did:key:z...", 382 + "issuer": "did:plc:...", 383 + "signature": { 384 + "$bytes": "base64-signature" 385 + } 386 + } 387 + ``` 388 + 389 + Remote attestation entry (strongRef): 390 + ```json 391 + { 392 + "$type": "com.atproto.repo.strongRef", 393 + "uri": "at://did:plc:repo/com.example.attestation/tid", 394 + "cid": "bafyrei..." 395 + } 396 + ``` 397 + 398 + ## Error Handling 399 + 400 + The crate provides structured error types via `AttestationError`: 401 + 402 + - `RecordMustBeObject`: Input must be a JSON object 403 + - `MetadataMustBeObject`: Attestation metadata must be a JSON object 404 + - `SigMetadataMissing`: No `$sig` field found in prepared record 405 + - `SignatureCreationFailed`: Key signing operation failed 406 + - `SignatureValidationFailed`: Signature verification failed 407 + - `SignatureNotNormalized`: ECDSA signature not in low-S form 408 + - `KeyResolutionFailed`: Could not resolve verification key 409 + - `UnsupportedKeyType`: Key type not supported for signing/verification 410 + 411 + ## Security Considerations 412 + 413 + - **Key management**: Private keys should be protected and never logged or transmitted 414 + - **Signature normalization**: All signatures are normalized to low-S form to prevent malleability 415 + - **CID verification**: Always verify signatures against the reconstructed CID, not the record content 416 + - **Key resolution**: Use trusted key resolvers to prevent key substitution attacks 417 + - **Timestamp validation**: Check `issuedAt` and `expiry` fields if present in metadata 418 + 419 + ## License 420 + 421 + MIT License
+298
crates/atproto-attestation/src/bin/atproto-attestation-sign.rs
··· 1 + //! Command-line tool for signing AT Protocol records with inline or remote attestations. 2 + //! 3 + //! This tool creates cryptographic signatures for AT Protocol records using the CID-first 4 + //! attestation specification. It supports both inline attestations (embedding signatures 5 + //! directly in records) and remote attestations (creating separate proof records). 6 + //! 7 + //! ## Usage Patterns 8 + //! 9 + //! ### Remote Attestation 10 + //! ```bash 11 + //! atproto-attestation-sign remote <source_record> <repository_did> <metadata_record> 12 + //! ``` 13 + //! 14 + //! ### Inline Attestation 15 + //! ```bash 16 + //! atproto-attestation-sign inline <source_record> <signing_key> <metadata_record> 17 + //! ``` 18 + //! 19 + //! ## Arguments 20 + //! 21 + //! - `source_record`: JSON string or path to JSON file containing the record being attested 22 + //! - `repository_did`: (Remote mode) DID of the repository that will contain the remote attestation record 23 + //! - `signing_key`: (Inline mode) Private key string (did:key format) used to sign the attestation 24 + //! - `metadata_record`: JSON string or path to JSON file with attestation metadata used during CID creation 25 + //! 26 + //! ## Examples 27 + //! 28 + //! ```bash 29 + //! # Remote attestation - creates proof record and strongRef 30 + //! atproto-attestation-sign remote \ 31 + //! record.json \ 32 + //! did:plc:xyz123... \ 33 + //! metadata.json 34 + //! 35 + //! # Inline attestation - embeds signature in record 36 + //! atproto-attestation-sign inline \ 37 + //! record.json \ 38 + //! did:key:z42tv1pb3... \ 39 + //! '{"$type":"com.example.attestation","purpose":"demo"}' 40 + //! 41 + //! # Read from stdin 42 + //! cat record.json | atproto-attestation-sign inline \ 43 + //! - \ 44 + //! did:key:z42tv1pb3... \ 45 + //! metadata.json 46 + //! ``` 47 + 48 + use anyhow::{Context, Result, anyhow}; 49 + use atproto_attestation::{ 50 + create_inline_attestation, create_remote_attestation, create_remote_attestation_reference, 51 + }; 52 + use atproto_identity::key::identify_key; 53 + use clap::{Parser, Subcommand}; 54 + use serde_json::Value; 55 + use std::{ 56 + fs, 57 + io::{self, Read}, 58 + path::Path, 59 + }; 60 + 61 + /// Command-line tool for signing AT Protocol records with cryptographic attestations. 62 + /// 63 + /// Creates inline or remote attestations following the CID-first specification. 64 + /// Inline attestations embed signatures directly in records, while remote attestations 65 + /// generate separate proof records with strongRef references. 66 + #[derive(Parser)] 67 + #[command( 68 + name = "atproto-attestation-sign", 69 + version, 70 + about = "Sign AT Protocol records with cryptographic attestations", 71 + long_about = " 72 + A command-line tool for signing AT Protocol records using the CID-first attestation 73 + specification. Supports both inline attestations (signatures embedded in the record) 74 + and remote attestations (separate proof records with CID references). 75 + 76 + MODES: 77 + remote Creates a separate proof record with strongRef reference 78 + Syntax: remote <source_record> <repository_did> <metadata_record> 79 + 80 + inline Embeds signature bytes directly in the record 81 + Syntax: inline <source_record> <signing_key> <metadata_record> 82 + 83 + ARGUMENTS: 84 + source_record JSON string or file path to the record being attested 85 + repository_did (Remote) DID of repository containing the attestation record 86 + signing_key (Inline) Private key in did:key format for signing 87 + metadata_record JSON string or file path with attestation metadata 88 + 89 + EXAMPLES: 90 + # Remote attestation (creates proof record + strongRef): 91 + atproto-attestation-sign remote \\ 92 + record.json \\ 93 + did:plc:xyz123abc... \\ 94 + metadata.json 95 + 96 + # Inline attestation (embeds signature): 97 + atproto-attestation-sign inline \\ 98 + record.json \\ 99 + did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\ 100 + '{\"$type\":\"com.example.attestation\",\"purpose\":\"demo\"}' 101 + 102 + # Read source record from stdin: 103 + cat record.json | atproto-attestation-sign inline \\ 104 + - \\ 105 + did:key:z42tv1pb3... \\ 106 + metadata.json 107 + 108 + OUTPUT: 109 + Remote mode outputs TWO JSON objects: 110 + 1. The proof record (to be stored in the repository) 111 + 2. The source record with strongRef attestation appended 112 + 113 + Inline mode outputs ONE JSON object: 114 + - The source record with inline attestation embedded 115 + " 116 + )] 117 + struct Args { 118 + #[command(subcommand)] 119 + command: Commands, 120 + } 121 + 122 + #[derive(Subcommand)] 123 + enum Commands { 124 + /// Create a remote attestation with separate proof record 125 + /// 126 + /// Generates a proof record containing the CID and returns both the proof 127 + /// record (to be stored in the repository) and the source record with a 128 + /// strongRef attestation reference. 129 + #[command(visible_alias = "r")] 130 + Remote { 131 + /// Source record JSON string or file path (use '-' for stdin) 132 + source_record: String, 133 + 134 + /// Repository DID that will contain the remote attestation record 135 + repository_did: String, 136 + 137 + /// Attestation metadata JSON string or file path 138 + metadata_record: String, 139 + }, 140 + 141 + /// Create an inline attestation with embedded signature 142 + /// 143 + /// Signs the record with the provided private key and embeds the signature 144 + /// directly in the record's attestation structure. 145 + #[command(visible_alias = "i")] 146 + Inline { 147 + /// Source record JSON string or file path (use '-' for stdin) 148 + source_record: String, 149 + 150 + /// Private signing key in did:key format (e.g., did:key:z...) 151 + signing_key: String, 152 + 153 + /// Attestation metadata JSON string or file path 154 + metadata_record: String, 155 + }, 156 + } 157 + 158 + #[tokio::main] 159 + async fn main() -> Result<()> { 160 + let args = Args::parse(); 161 + 162 + match args.command { 163 + Commands::Remote { 164 + source_record, 165 + repository_did, 166 + metadata_record, 167 + } => handle_remote_attestation(&source_record, &repository_did, &metadata_record)?, 168 + 169 + Commands::Inline { 170 + source_record, 171 + signing_key, 172 + metadata_record, 173 + } => handle_inline_attestation(&source_record, &signing_key, &metadata_record)?, 174 + } 175 + 176 + Ok(()) 177 + } 178 + 179 + /// Handle remote attestation mode. 180 + /// 181 + /// Creates a proof record and appends a strongRef to the source record. 182 + /// Outputs both the proof record and the updated source record. 183 + fn handle_remote_attestation( 184 + source_record: &str, 185 + repository_did: &str, 186 + metadata_record: &str, 187 + ) -> Result<()> { 188 + // Load source record and metadata 189 + let record_json = load_json_input(source_record)?; 190 + let metadata_json = load_json_input(metadata_record)?; 191 + 192 + // Validate inputs 193 + if !record_json.is_object() { 194 + return Err(anyhow!("Source record must be a JSON object")); 195 + } 196 + 197 + if !metadata_json.is_object() { 198 + return Err(anyhow!("Metadata record must be a JSON object")); 199 + } 200 + 201 + // Validate repository DID 202 + if !repository_did.starts_with("did:") { 203 + return Err(anyhow!( 204 + "Repository DID must start with 'did:' prefix, got: {}", 205 + repository_did 206 + )); 207 + } 208 + 209 + // Create the remote attestation proof record 210 + let proof_record = create_remote_attestation(&record_json, &metadata_json) 211 + .context("Failed to create remote attestation proof record")?; 212 + 213 + // Create the source record with strongRef reference 214 + let attested_record = 215 + create_remote_attestation_reference(&record_json, &proof_record, repository_did) 216 + .context("Failed to create remote attestation reference")?; 217 + 218 + // Output both records 219 + println!("=== Proof Record (store in repository) ==="); 220 + println!("{}", serde_json::to_string_pretty(&proof_record)?); 221 + println!(); 222 + println!("=== Attested Record (with strongRef) ==="); 223 + println!("{}", serde_json::to_string_pretty(&attested_record)?); 224 + 225 + Ok(()) 226 + } 227 + 228 + /// Handle inline attestation mode. 229 + /// 230 + /// Signs the record with the provided key and embeds the signature. 231 + /// Outputs the record with inline attestation. 232 + fn handle_inline_attestation( 233 + source_record: &str, 234 + signing_key: &str, 235 + metadata_record: &str, 236 + ) -> Result<()> { 237 + // Load source record and metadata 238 + let record_json = load_json_input(source_record)?; 239 + let metadata_json = load_json_input(metadata_record)?; 240 + 241 + // Validate inputs 242 + if !record_json.is_object() { 243 + return Err(anyhow!("Source record must be a JSON object")); 244 + } 245 + 246 + if !metadata_json.is_object() { 247 + return Err(anyhow!("Metadata record must be a JSON object")); 248 + } 249 + 250 + // Parse the signing key 251 + let key_data = identify_key(signing_key) 252 + .with_context(|| format!("Failed to parse signing key: {}", signing_key))?; 253 + 254 + // Create inline attestation 255 + let signed_record = create_inline_attestation(&record_json, &metadata_json, &key_data) 256 + .context("Failed to create inline attestation")?; 257 + 258 + // Output the signed record 259 + println!("{}", serde_json::to_string_pretty(&signed_record)?); 260 + 261 + Ok(()) 262 + } 263 + 264 + /// Load JSON input from various sources. 265 + /// 266 + /// Accepts: 267 + /// - "-" for stdin 268 + /// - File paths (if the file exists) 269 + /// - Direct JSON strings 270 + /// 271 + /// Returns the parsed JSON value or an error. 272 + fn load_json_input(argument: &str) -> Result<Value> { 273 + // Handle stdin input 274 + if argument == "-" { 275 + let mut input = String::new(); 276 + io::stdin() 277 + .read_to_string(&mut input) 278 + .context("Failed to read from stdin")?; 279 + return serde_json::from_str(&input).context("Failed to parse JSON from stdin"); 280 + } 281 + 282 + // Try as file path first 283 + let path = Path::new(argument); 284 + if path.is_file() { 285 + let file_content = fs::read_to_string(path) 286 + .with_context(|| format!("Failed to read file: {}", argument))?; 287 + return serde_json::from_str(&file_content) 288 + .with_context(|| format!("Failed to parse JSON from file: {}", argument)); 289 + } 290 + 291 + // Try as direct JSON string 292 + serde_json::from_str(argument).with_context(|| { 293 + format!( 294 + "Argument is neither valid JSON nor a readable file: {}", 295 + argument 296 + ) 297 + }) 298 + }
+440
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
··· 1 + //! Command-line tool for verifying cryptographic signatures on AT Protocol records. 2 + //! 3 + //! This tool validates attestation signatures on AT Protocol records by reconstructing 4 + //! the signed content and verifying ECDSA signatures against public keys embedded in the 5 + //! attestation metadata. 6 + //! 7 + //! ## Usage Patterns 8 + //! 9 + //! ### Verify all signatures in a record 10 + //! ```bash 11 + //! atproto-attestation-verify <record> 12 + //! ``` 13 + //! 14 + //! ### Verify a specific attestation against a record 15 + //! ```bash 16 + //! atproto-attestation-verify <record> <attestation> 17 + //! ``` 18 + //! 19 + //! ## Parameter Formats 20 + //! 21 + //! Both `record` and `attestation` parameters accept: 22 + //! - **JSON string**: Direct JSON payload (e.g., `'{"$type":"...","text":"..."}'`) 23 + //! - **File path**: Path to a JSON file (e.g., `./record.json`) 24 + //! - **AT-URI**: AT Protocol URI to fetch the record (e.g., `at://did:plc:abc/app.bsky.feed.post/123`) 25 + //! 26 + //! ## Examples 27 + //! 28 + //! ```bash 29 + //! # Verify all signatures in a record from file 30 + //! atproto-attestation-verify ./signed_post.json 31 + //! 32 + //! # Verify all signatures in a record from AT-URI 33 + //! atproto-attestation-verify at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g 34 + //! 35 + //! # Verify specific attestation against a record (both from files) 36 + //! atproto-attestation-verify ./record.json ./attestation.json 37 + //! 38 + //! # Verify specific attestation (from AT-URI) against record (from file) 39 + //! atproto-attestation-verify ./record.json at://did:plc:xyz/com.example.attestation/abc123 40 + //! 41 + //! # Read record from stdin, verify all signatures 42 + //! cat signed.json | atproto-attestation-verify - 43 + //! 44 + //! # Verify inline JSON 45 + //! atproto-attestation-verify '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' 46 + //! ``` 47 + 48 + use anyhow::{Context, Result, anyhow}; 49 + use atproto_attestation::{VerificationStatus, verify_all_signatures_with_resolver}; 50 + use clap::Parser; 51 + use serde_json::Value; 52 + use std::{ 53 + fs, 54 + io::{self, Read}, 55 + path::Path, 56 + }; 57 + 58 + /// Command-line tool for verifying cryptographic signatures on AT Protocol records. 59 + /// 60 + /// Validates attestation signatures by reconstructing signed content and checking 61 + /// ECDSA signatures against embedded public keys. Supports verifying all signatures 62 + /// in a record or validating a specific attestation record. 63 + #[derive(Parser)] 64 + #[command( 65 + name = "atproto-attestation-verify", 66 + version, 67 + about = "Verify cryptographic signatures of AT Protocol records", 68 + long_about = " 69 + A command-line tool for verifying cryptographic signatures of AT Protocol records. 70 + 71 + USAGE: 72 + atproto-attestation-verify <record> Verify all signatures in record 73 + atproto-attestation-verify <record> <attestation> Verify specific attestation 74 + 75 + PARAMETER FORMATS: 76 + Each parameter accepts JSON strings, file paths, or AT-URIs: 77 + - JSON string: '{\"$type\":\"...\",\"text\":\"...\"}' 78 + - File path: ./record.json 79 + - AT-URI: at://did:plc:abc/app.bsky.feed.post/123 80 + - Stdin: - (for record parameter only) 81 + 82 + EXAMPLES: 83 + # Verify all signatures in a record: 84 + atproto-attestation-verify ./signed_post.json 85 + atproto-attestation-verify at://did:plc:abc/app.bsky.feed.post/123 86 + 87 + # Verify specific attestation: 88 + atproto-attestation-verify ./record.json ./attestation.json 89 + atproto-attestation-verify ./record.json at://did:plc:xyz/com.example.attestation/abc 90 + 91 + # Read from stdin: 92 + cat signed.json | atproto-attestation-verify - 93 + 94 + OUTPUT: 95 + Single record mode: Reports each signature with ✓ (valid), ✗ (invalid), or ? (unverified) 96 + Attestation mode: Outputs 'OK' on success, error message on failure 97 + 98 + VERIFICATION: 99 + - Inline signatures are verified by reconstructing $sig and validating against embedded keys 100 + - Remote attestations (strongRef) are reported as unverified (require fetching proof record) 101 + - Keys are resolved from did:key identifiers or require a key resolver for DID document keys 102 + " 103 + )] 104 + struct Args { 105 + /// Record to verify - JSON string, file path, AT-URI, or '-' for stdin 106 + record: String, 107 + 108 + /// Optional attestation record to verify against the record - JSON string, file path, or AT-URI 109 + attestation: Option<String>, 110 + } 111 + 112 + #[tokio::main] 113 + async fn main() -> Result<()> { 114 + let args = Args::parse(); 115 + 116 + // Load the record 117 + let record = load_input(&args.record, true) 118 + .await 119 + .context("Failed to load record")?; 120 + 121 + if !record.is_object() { 122 + return Err(anyhow!("Record must be a JSON object")); 123 + } 124 + 125 + // Determine verification mode 126 + match args.attestation { 127 + None => { 128 + // Mode 1: Verify all signatures in the record 129 + verify_all_mode(&record).await 130 + } 131 + Some(attestation_input) => { 132 + // Mode 2: Verify specific attestation against record 133 + let attestation = load_input(&attestation_input, false) 134 + .await 135 + .context("Failed to load attestation")?; 136 + 137 + if !attestation.is_object() { 138 + return Err(anyhow!("Attestation must be a JSON object")); 139 + } 140 + 141 + verify_attestation_mode(&record, &attestation).await 142 + } 143 + } 144 + } 145 + 146 + /// Mode 1: Verify all signatures contained in the record. 147 + /// 148 + /// Reports each signature with status indicators: 149 + /// - ✓ Valid signature 150 + /// - ✗ Invalid signature 151 + /// - ? Unverified (e.g., remote attestations requiring proof record fetch) 152 + async fn verify_all_mode(record: &Value) -> Result<()> { 153 + // Create an identity resolver for fetching remote attestations 154 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 155 + use std::sync::Arc; 156 + 157 + let http_client = reqwest::Client::new(); 158 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 159 + 160 + let identity_resolver = InnerIdentityResolver { 161 + http_client: http_client.clone(), 162 + dns_resolver: Arc::new(dns_resolver), 163 + plc_hostname: "plc.directory".to_string(), 164 + }; 165 + 166 + // Create record resolver that can fetch remote attestation proof records 167 + let record_resolver = RemoteAttestationResolver { 168 + http_client, 169 + identity_resolver, 170 + }; 171 + 172 + let reports = verify_all_signatures_with_resolver(record, None, Some(&record_resolver)) 173 + .await 174 + .context("Failed to verify signatures")?; 175 + 176 + if reports.is_empty() { 177 + return Err(anyhow!("No signatures found in record")); 178 + } 179 + 180 + let mut all_valid = true; 181 + let mut has_errors = false; 182 + 183 + for report in &reports { 184 + match &report.status { 185 + VerificationStatus::Valid { cid } => { 186 + let key_info = report 187 + .key 188 + .as_deref() 189 + .map(|k| format!(" (key: {})", truncate_did(k))) 190 + .unwrap_or_default(); 191 + println!( 192 + "✓ Signature {} valid{} [CID: {}]", 193 + report.index, key_info, cid 194 + ); 195 + } 196 + VerificationStatus::Invalid { error } => { 197 + println!("✗ Signature {} invalid: {}", report.index, error); 198 + all_valid = false; 199 + has_errors = true; 200 + } 201 + VerificationStatus::Unverified { reason } => { 202 + println!("? Signature {} unverified: {}", report.index, reason); 203 + all_valid = false; 204 + } 205 + } 206 + } 207 + 208 + println!(); 209 + println!( 210 + "Summary: {} total, {} valid", 211 + reports.len(), 212 + reports 213 + .iter() 214 + .filter(|r| matches!(r.status, VerificationStatus::Valid { .. })) 215 + .count() 216 + ); 217 + 218 + if has_errors { 219 + Err(anyhow!("One or more signatures are invalid")) 220 + } else if !all_valid { 221 + Err(anyhow!("One or more signatures could not be verified")) 222 + } else { 223 + Ok(()) 224 + } 225 + } 226 + 227 + /// Mode 2: Verify a specific attestation record against the provided record. 228 + /// 229 + /// The attestation should be a standalone attestation object (e.g., from a remote proof record) 230 + /// that will be verified against the record's content. 231 + async fn verify_attestation_mode(record: &Value, attestation: &Value) -> Result<()> { 232 + // The attestation should have a CID field that we can use to verify 233 + let attestation_obj = attestation 234 + .as_object() 235 + .ok_or_else(|| anyhow!("Attestation must be a JSON object"))?; 236 + 237 + // Get the CID from the attestation 238 + let cid_str = attestation_obj 239 + .get("cid") 240 + .and_then(Value::as_str) 241 + .ok_or_else(|| anyhow!("Attestation must contain a 'cid' field"))?; 242 + 243 + // Prepare the signing record with the attestation metadata 244 + let mut signing_metadata = attestation_obj.clone(); 245 + signing_metadata.remove("cid"); 246 + signing_metadata.remove("signature"); 247 + 248 + let signing_record = 249 + atproto_attestation::prepare_signing_record(record, &Value::Object(signing_metadata)) 250 + .context("Failed to prepare signing record")?; 251 + 252 + // Generate the CID from the signing record 253 + let computed_cid = 254 + atproto_attestation::create_cid(&signing_record).context("Failed to generate CID")?; 255 + 256 + // Compare CIDs 257 + if computed_cid.to_string() != cid_str { 258 + return Err(anyhow!( 259 + "CID mismatch: attestation claims {}, but computed {}", 260 + cid_str, 261 + computed_cid 262 + )); 263 + } 264 + 265 + println!("OK"); 266 + println!("CID: {}", computed_cid); 267 + 268 + Ok(()) 269 + } 270 + 271 + /// Load input from various sources: JSON string, file path, AT-URI, or stdin. 272 + /// 273 + /// The `allow_stdin` parameter controls whether "-" is interpreted as stdin. 274 + async fn load_input(input: &str, allow_stdin: bool) -> Result<Value> { 275 + // Handle stdin 276 + if input == "-" { 277 + if !allow_stdin { 278 + return Err(anyhow!( 279 + "Stdin ('-') is only supported for the record parameter" 280 + )); 281 + } 282 + 283 + let mut buffer = String::new(); 284 + io::stdin() 285 + .read_to_string(&mut buffer) 286 + .context("Failed to read from stdin")?; 287 + 288 + return serde_json::from_str(&buffer).context("Failed to parse JSON from stdin"); 289 + } 290 + 291 + // Check if it's an AT-URI 292 + if input.starts_with("at://") { 293 + return load_from_aturi(input) 294 + .await 295 + .with_context(|| format!("Failed to fetch record from AT-URI: {}", input)); 296 + } 297 + 298 + // Try as file path 299 + let path = Path::new(input); 300 + if path.exists() && path.is_file() { 301 + let content = 302 + fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", input))?; 303 + 304 + return serde_json::from_str(&content) 305 + .with_context(|| format!("Failed to parse JSON from file: {}", input)); 306 + } 307 + 308 + // Try as direct JSON string 309 + serde_json::from_str(input).with_context(|| { 310 + format!( 311 + "Input is not valid JSON, an existing file, or an AT-URI: {}", 312 + input 313 + ) 314 + }) 315 + } 316 + 317 + /// Load a record from an AT-URI by fetching it from a PDS. 318 + /// 319 + /// This requires resolving the DID to find the PDS endpoint, then fetching the record. 320 + async fn load_from_aturi(aturi: &str) -> Result<Value> { 321 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 322 + use atproto_record::aturi::ATURI; 323 + use std::str::FromStr; 324 + use std::sync::Arc; 325 + 326 + // Parse the AT-URI 327 + let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?; 328 + 329 + // Create resolver components 330 + let http_client = reqwest::Client::new(); 331 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 332 + 333 + // Create identity resolver 334 + let identity_resolver = InnerIdentityResolver { 335 + http_client: http_client.clone(), 336 + dns_resolver: Arc::new(dns_resolver), 337 + plc_hostname: "plc.directory".to_string(), 338 + }; 339 + 340 + // Resolve the DID to get the PDS endpoint 341 + let document = identity_resolver 342 + .resolve(&parsed.authority) 343 + .await 344 + .with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?; 345 + 346 + // Find the PDS endpoint 347 + let pds_endpoint = document 348 + .service 349 + .iter() 350 + .find(|s| s.r#type == "AtprotoPersonalDataServer") 351 + .map(|s| s.service_endpoint.as_str()) 352 + .ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?; 353 + 354 + // Fetch the record using the XRPC client 355 + let response = atproto_client::com::atproto::repo::get_record( 356 + &http_client, 357 + &atproto_client::client::Auth::None, 358 + pds_endpoint, 359 + &parsed.authority, 360 + &parsed.collection, 361 + &parsed.record_key, 362 + None, 363 + ) 364 + .await 365 + .with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?; 366 + 367 + match response { 368 + atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => Ok(value), 369 + atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 370 + Err(anyhow!("Failed to fetch record: {}", error.error_message())) 371 + } 372 + } 373 + } 374 + 375 + /// Truncate a DID or did:key for display purposes. 376 + fn truncate_did(did: &str) -> String { 377 + if did.len() > 40 { 378 + format!("{}...{}", &did[..20], &did[did.len() - 12..]) 379 + } else { 380 + did.to_string() 381 + } 382 + } 383 + 384 + /// Record resolver for remote attestations that resolves DIDs to find PDS endpoints. 385 + struct RemoteAttestationResolver { 386 + http_client: reqwest::Client, 387 + identity_resolver: atproto_identity::resolve::InnerIdentityResolver, 388 + } 389 + 390 + #[async_trait::async_trait] 391 + impl atproto_client::record_resolver::RecordResolver for RemoteAttestationResolver { 392 + async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T> 393 + where 394 + T: serde::de::DeserializeOwned + Send, 395 + { 396 + use atproto_record::aturi::ATURI; 397 + use std::str::FromStr; 398 + 399 + // Parse the AT-URI 400 + let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?; 401 + 402 + // Resolve the DID to get the PDS endpoint 403 + let document = self 404 + .identity_resolver 405 + .resolve(&parsed.authority) 406 + .await 407 + .with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?; 408 + 409 + // Find the PDS endpoint 410 + let pds_endpoint = document 411 + .service 412 + .iter() 413 + .find(|s| s.r#type == "AtprotoPersonalDataServer") 414 + .map(|s| s.service_endpoint.as_str()) 415 + .ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?; 416 + 417 + // Fetch the record using the XRPC client 418 + let response = atproto_client::com::atproto::repo::get_record( 419 + &self.http_client, 420 + &atproto_client::client::Auth::None, 421 + pds_endpoint, 422 + &parsed.authority, 423 + &parsed.collection, 424 + &parsed.record_key, 425 + None, 426 + ) 427 + .await 428 + .with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?; 429 + 430 + match response { 431 + atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => { 432 + serde_json::from_value(value) 433 + .map_err(|e| anyhow!("Failed to deserialize record: {}", e)) 434 + } 435 + atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 436 + Err(anyhow!("Failed to fetch record: {}", error.error_message())) 437 + } 438 + } 439 + } 440 + }
+194
crates/atproto-attestation/src/errors.rs
··· 1 + //! Errors that can occur during attestation preparation and verification. 2 + //! 3 + //! Covers CID construction, `$sig` metadata validation, inline attestation 4 + //! structure checks, and identity/key resolution failures. 5 + 6 + use thiserror::Error; 7 + 8 + /// Errors that can occur during attestation preparation and verification. 9 + #[derive(Debug, Error)] 10 + pub enum AttestationError { 11 + /// Error when the record value is not a JSON object. 12 + #[error("error-atproto-attestation-1 Record must be a JSON object")] 13 + RecordMustBeObject, 14 + 15 + /// Error when attestation metadata is not a JSON object. 16 + #[error("error-atproto-attestation-2 Attestation metadata must be a JSON object")] 17 + MetadataMustBeObject, 18 + 19 + /// Error when attestation metadata is missing a required field. 20 + #[error("error-atproto-attestation-3 Attestation metadata missing required field: {field}")] 21 + MetadataMissingField { 22 + /// Name of the missing field. 23 + field: String, 24 + }, 25 + 26 + /// Error when attestation metadata omits the `$type` discriminator. 27 + #[error("error-atproto-attestation-4 Attestation metadata must include a string `$type` field")] 28 + MetadataMissingSigType, 29 + 30 + /// Error when the record does not contain a signatures array. 31 + #[error("error-atproto-attestation-5 Signatures array not found on record")] 32 + SignaturesArrayMissing, 33 + 34 + /// Error when the signatures field exists but is not an array. 35 + #[error("error-atproto-attestation-6 Signatures field must be an array")] 36 + SignaturesFieldInvalid, 37 + 38 + /// Error when attempting to verify a signature at an invalid index. 39 + #[error("error-atproto-attestation-7 Signature index {index} out of bounds")] 40 + SignatureIndexOutOfBounds { 41 + /// Index that was requested. 42 + index: usize, 43 + }, 44 + 45 + /// Error when a signature object is missing a required field. 46 + #[error("error-atproto-attestation-8 Signature object missing required field: {field}")] 47 + SignatureMissingField { 48 + /// Field name that was expected. 49 + field: String, 50 + }, 51 + 52 + /// Error when a signature object uses an invalid `$type` for inline attestations. 53 + #[error( 54 + "error-atproto-attestation-9 Inline attestation `$type` cannot be `com.atproto.repo.strongRef`" 55 + )] 56 + InlineAttestationTypeInvalid, 57 + 58 + /// Error when a remote attestation entry does not use the strongRef type. 59 + #[error( 60 + "error-atproto-attestation-10 Remote attestation entries must use `com.atproto.repo.strongRef`" 61 + )] 62 + RemoteAttestationTypeInvalid, 63 + 64 + /// Error when a remote attestation entry is missing a CID. 65 + #[error( 66 + "error-atproto-attestation-11 Remote attestation entries must include a string `cid` field" 67 + )] 68 + RemoteAttestationMissingCid, 69 + 70 + /// Error when signature bytes are not provided using the `$bytes` wrapper. 71 + #[error( 72 + "error-atproto-attestation-12 Signature bytes must be encoded as `{{\"$bytes\": \"...\"}}`" 73 + )] 74 + SignatureBytesFormatInvalid, 75 + 76 + /// Error when record serialization to DAG-CBOR fails. 77 + #[error("error-atproto-attestation-13 Record serialization failed: {error}")] 78 + RecordSerializationFailed { 79 + /// Underlying serialization error. 80 + #[from] 81 + error: serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>, 82 + }, 83 + 84 + /// Error when `$sig` metadata is missing from the record before CID creation. 85 + #[error("error-atproto-attestation-14 `$sig` metadata must be present before generating a CID")] 86 + SigMetadataMissing, 87 + 88 + /// Error when `$sig` metadata is not an object. 89 + #[error("error-atproto-attestation-15 `$sig` metadata must be a JSON object")] 90 + SigMetadataNotObject, 91 + 92 + /// Error when `$sig` metadata omits the `$type` discriminator. 93 + #[error("error-atproto-attestation-16 `$sig` metadata must include a string `$type` field")] 94 + SigMetadataMissingType, 95 + 96 + /// Error when a key resolver is required but not provided. 97 + #[error("error-atproto-attestation-17 Key resolver required to resolve key reference: {key}")] 98 + KeyResolverRequired { 99 + /// Key reference that required resolution. 100 + key: String, 101 + }, 102 + 103 + /// Error when key resolution using the provided resolver fails. 104 + #[error("error-atproto-attestation-18 Failed to resolve key reference {key}: {error}")] 105 + KeyResolutionFailed { 106 + /// Key reference that was being resolved. 107 + key: String, 108 + /// Underlying resolution error. 109 + #[source] 110 + error: anyhow::Error, 111 + }, 112 + 113 + /// Error when the key type is unsupported for inline attestations. 114 + #[error("error-atproto-attestation-21 Unsupported key type for attestation: {key_type}")] 115 + UnsupportedKeyType { 116 + /// Unsupported key type. 117 + key_type: atproto_identity::key::KeyType, 118 + }, 119 + 120 + /// Error when signature decoding fails. 121 + #[error("error-atproto-attestation-22 Signature decoding failed: {error}")] 122 + SignatureDecodingFailed { 123 + /// Underlying base64 decoding error. 124 + #[from] 125 + error: base64::DecodeError, 126 + }, 127 + 128 + /// Error when signature length does not match the expected size. 129 + #[error( 130 + "error-atproto-attestation-23 Signature length invalid: expected {expected} bytes, found {actual}" 131 + )] 132 + SignatureLengthInvalid { 133 + /// Expected signature length. 134 + expected: usize, 135 + /// Actual signature length. 136 + actual: usize, 137 + }, 138 + 139 + /// Error when signature is not normalized to low-S form. 140 + #[error("error-atproto-attestation-24 Signature must be normalized to low-S form")] 141 + SignatureNotNormalized, 142 + 143 + /// Error when cryptographic verification fails. 144 + #[error("error-atproto-attestation-25 Signature verification failed: {error}")] 145 + SignatureValidationFailed { 146 + /// Underlying key validation error. 147 + #[source] 148 + error: atproto_identity::errors::KeyError, 149 + }, 150 + 151 + /// Error when multihash construction for CID generation fails. 152 + #[error("error-atproto-attestation-26 Failed to construct CID multihash: {error}")] 153 + MultihashWrapFailed { 154 + /// Underlying multihash error. 155 + #[source] 156 + error: multihash::Error, 157 + }, 158 + 159 + /// Error when signature creation fails during inline attestation. 160 + #[error("error-atproto-attestation-27 Signature creation failed: {error}")] 161 + SignatureCreationFailed { 162 + /// Underlying signing error. 163 + #[source] 164 + error: atproto_identity::errors::KeyError, 165 + }, 166 + 167 + /// Error when fetching a remote attestation proof record fails. 168 + #[error("error-atproto-attestation-28 Failed to fetch remote attestation from {uri}: {error}")] 169 + RemoteAttestationFetchFailed { 170 + /// AT-URI that failed to resolve. 171 + uri: String, 172 + /// Underlying fetch error. 173 + #[source] 174 + error: anyhow::Error, 175 + }, 176 + 177 + /// Error when the CID of a remote attestation proof record doesn't match expected. 178 + #[error( 179 + "error-atproto-attestation-29 Remote attestation CID mismatch: expected {expected}, got {actual}" 180 + )] 181 + RemoteAttestationCidMismatch { 182 + /// Expected CID. 183 + expected: String, 184 + /// Actual CID. 185 + actual: String, 186 + }, 187 + 188 + /// Error when parsing a CID string fails. 189 + #[error("error-atproto-attestation-30 Invalid CID format: {cid}")] 190 + InvalidCid { 191 + /// Invalid CID string. 192 + cid: String, 193 + }, 194 + }
+1021
crates/atproto-attestation/src/lib.rs
··· 1 + //! AT Protocol record attestation utilities based on the CID-first specification. 2 + //! 3 + //! This crate implements helpers for constructing deterministic signing payloads, 4 + //! creating inline and remote attestation references, and verifying signatures 5 + //! against DID verification methods. It follows the requirements documented in 6 + //! `bluesky-attestation-tee/documentation/spec/attestation.md`. 7 + //! 8 + //! The workflow for inline attestations is: 9 + //! 1. Prepare a signing record with [`prepare_signing_record`]. 10 + //! 2. Generate the content identifier using [`create_cid`]. 11 + //! 3. Sign the CID bytes externally and embed the attestation with 12 + //! [`create_inline_attestation_reference`]. 13 + //! 4. Verify signatures with [`verify_signature`] or [`verify_all_signatures`]. 14 + //! 15 + //! Remote attestations follow the same `$sig` preparation process but store the 16 + //! generated CID in a proof record and reference it with 17 + //! [`create_remote_attestation_reference`]. 18 + 19 + #![forbid(unsafe_code)] 20 + #![warn(missing_docs)] 21 + 22 + pub mod errors; 23 + 24 + use atproto_record::tid::Tid; 25 + pub use errors::AttestationError; 26 + 27 + use atproto_identity::key::{KeyData, KeyResolver, KeyType, identify_key, sign, validate}; 28 + use base64::{ 29 + Engine, 30 + alphabet::STANDARD as STANDARD_ALPHABET, 31 + engine::{ 32 + DecodePaddingMode, 33 + general_purpose::{GeneralPurpose, GeneralPurposeConfig}, 34 + }, 35 + }; 36 + use cid::Cid; 37 + use elliptic_curve::scalar::IsHigh; 38 + use k256::ecdsa::Signature as K256Signature; 39 + use multihash::Multihash; 40 + use p256::ecdsa::Signature as P256Signature; 41 + use serde_json::{Map, Value, json}; 42 + use sha2::{Digest, Sha256}; 43 + 44 + // Base64 engine that accepts both padded and unpadded input for maximum compatibility 45 + // with various AT Protocol implementations. Uses standard encoding with padding for output, 46 + // but accepts any padding format for decoding. 47 + const BASE64: GeneralPurpose = GeneralPurpose::new( 48 + &STANDARD_ALPHABET, 49 + GeneralPurposeConfig::new() 50 + .with_encode_padding(true) 51 + .with_decode_padding_mode(DecodePaddingMode::Indifferent), 52 + ); 53 + 54 + const STRONG_REF_TYPE: &str = "com.atproto.repo.strongRef"; 55 + 56 + /// Resolver trait for retrieving remote attestation records by AT URI. 57 + /// 58 + /// Kind of attestation represented within the `signatures` array. 59 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 60 + pub enum AttestationKind { 61 + /// Inline attestation containing signature bytes. 62 + Inline, 63 + /// Remote attestation referencing a proof record via strongRef. 64 + Remote, 65 + } 66 + 67 + /// Result of verifying a single attestation entry. 68 + #[derive(Debug)] 69 + pub enum VerificationStatus { 70 + /// Signature is valid for the reconstructed signing payload. 71 + Valid { 72 + /// CID produced for the reconstructed record. 73 + cid: Cid, 74 + }, 75 + /// Signature verification or metadata validation failed. 76 + Invalid { 77 + /// Failure reason. 78 + error: AttestationError, 79 + }, 80 + /// Attestation cannot be verified locally (e.g., remote references). 81 + Unverified { 82 + /// Explanation for why verification was skipped. 83 + reason: String, 84 + }, 85 + } 86 + 87 + /// Structured verification report for a single attestation entry. 88 + #[derive(Debug)] 89 + pub struct VerificationReport { 90 + /// Zero-based index of the signature in the record's `signatures` array. 91 + pub index: usize, 92 + /// Detected attestation kind. 93 + pub kind: AttestationKind, 94 + /// `$type` discriminator from the attestation entry, if present. 95 + pub signature_type: Option<String>, 96 + /// Key reference for inline signatures (if available). 97 + pub key: Option<String>, 98 + /// Verification outcome. 99 + pub status: VerificationStatus, 100 + } 101 + 102 + /// Create a deterministic CID for a record prepared with [`prepare_signing_record`]. 103 + /// 104 + /// The record **must** contain a `$sig` object with at least a `$type` string 105 + /// to scope the signature. The returned CID uses the blessed parameters: 106 + /// CIDv1, dag-cbor codec (0x71), and sha2-256 multihash. 107 + pub fn create_cid(record: &Value) -> Result<Cid, AttestationError> { 108 + let record_object = record 109 + .as_object() 110 + .ok_or(AttestationError::RecordMustBeObject)?; 111 + 112 + let sig_value = record_object 113 + .get("$sig") 114 + .ok_or(AttestationError::SigMetadataMissing)?; 115 + 116 + let sig_object = sig_value 117 + .as_object() 118 + .ok_or(AttestationError::SigMetadataNotObject)?; 119 + 120 + if !sig_object 121 + .get("$type") 122 + .and_then(Value::as_str) 123 + .filter(|value| !value.is_empty()) 124 + .is_some() 125 + { 126 + return Err(AttestationError::SigMetadataMissingType); 127 + } 128 + 129 + let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?; 130 + let digest = Sha256::digest(&dag_cbor_bytes); 131 + let multihash = Multihash::wrap(0x12, &digest) 132 + .map_err(|error| AttestationError::MultihashWrapFailed { error })?; 133 + 134 + Ok(Cid::new_v1(0x71, multihash)) 135 + } 136 + 137 + fn create_plain_cid(record: &Value) -> Result<Cid, AttestationError> { 138 + let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?; 139 + let digest = Sha256::digest(&dag_cbor_bytes); 140 + let multihash = Multihash::wrap(0x12, &digest) 141 + .map_err(|error| AttestationError::MultihashWrapFailed { error })?; 142 + 143 + Ok(Cid::new_v1(0x71, multihash)) 144 + } 145 + 146 + /// Prepare a record for signing by removing attestation artifacts and adding `$sig`. 147 + /// 148 + /// - Removes any existing `signatures`, `sigs`, and `$sig` fields. 149 + /// - Inserts the provided `attestation` metadata as the new `$sig` object. 150 + /// - Ensures the metadata contains a string `$type` discriminator. 151 + pub fn prepare_signing_record( 152 + record: &Value, 153 + attestation: &Value, 154 + ) -> Result<Value, AttestationError> { 155 + let mut prepared = record 156 + .as_object() 157 + .cloned() 158 + .ok_or(AttestationError::RecordMustBeObject)?; 159 + 160 + let mut sig_metadata = attestation 161 + .as_object() 162 + .cloned() 163 + .ok_or(AttestationError::MetadataMustBeObject)?; 164 + 165 + if !sig_metadata 166 + .get("$type") 167 + .and_then(Value::as_str) 168 + .filter(|value| !value.is_empty()) 169 + .is_some() 170 + { 171 + return Err(AttestationError::MetadataMissingSigType); 172 + } 173 + 174 + sig_metadata.remove("signature"); 175 + sig_metadata.remove("cid"); 176 + 177 + prepared.remove("signatures"); 178 + prepared.remove("sigs"); 179 + prepared.remove("$sig"); 180 + prepared.insert("$sig".to_string(), Value::Object(sig_metadata)); 181 + 182 + Ok(Value::Object(prepared)) 183 + } 184 + 185 + /// Creates an inline attestation by signing the prepared record with the provided key. 186 + pub fn create_inline_attestation( 187 + record: &Value, 188 + attestation_metadata: &Value, 189 + signing_key: &KeyData, 190 + ) -> Result<Value, AttestationError> { 191 + let signing_record = prepare_signing_record(record, attestation_metadata)?; 192 + let cid = create_cid(&signing_record)?; 193 + 194 + let raw_signature = sign(signing_key, &cid.to_bytes()) 195 + .map_err(|error| AttestationError::SignatureCreationFailed { error })?; 196 + let signature_bytes = normalize_signature(raw_signature, signing_key.key_type())?; 197 + 198 + let mut inline_object = attestation_metadata 199 + .as_object() 200 + .cloned() 201 + .ok_or(AttestationError::MetadataMustBeObject)?; 202 + 203 + inline_object.remove("signature"); 204 + inline_object.remove("cid"); 205 + inline_object.insert( 206 + "signature".to_string(), 207 + json!({"$bytes": BASE64.encode(signature_bytes)}), 208 + ); 209 + 210 + create_inline_attestation_reference(record, &Value::Object(inline_object)) 211 + } 212 + 213 + /// Creates a remote attestation by generating a proof record and strongRef entry. 214 + /// 215 + /// Returns a tuple containing: 216 + /// - Remote proof record containing the CID for storage in a repository. 217 + pub fn create_remote_attestation( 218 + record: &Value, 219 + attestation_metadata: &Value, 220 + ) -> Result<Value, AttestationError> { 221 + let metadata = attestation_metadata 222 + .as_object() 223 + .cloned() 224 + .ok_or(AttestationError::MetadataMustBeObject)?; 225 + 226 + let metadata_value = Value::Object(metadata.clone()); 227 + let signing_record = prepare_signing_record(record, &metadata_value)?; 228 + let cid = create_cid(&signing_record)?; 229 + 230 + let mut remote_attestation = metadata.clone(); 231 + remote_attestation.insert("cid".to_string(), Value::String(cid.to_string())); 232 + 233 + Ok(Value::Object(remote_attestation)) 234 + } 235 + 236 + /// Normalize raw signature bytes to the required low-S form. 237 + /// 238 + /// This helper ensures signatures produced by signing APIs comply with the 239 + /// specification requirements before embedding them in attestation objects. 240 + pub fn normalize_signature( 241 + signature: Vec<u8>, 242 + key_type: &KeyType, 243 + ) -> Result<Vec<u8>, AttestationError> { 244 + match key_type { 245 + KeyType::P256Private | KeyType::P256Public => normalize_p256(signature), 246 + KeyType::K256Private | KeyType::K256Public => normalize_k256(signature), 247 + other => Err(AttestationError::UnsupportedKeyType { 248 + key_type: other.clone(), 249 + }), 250 + } 251 + } 252 + 253 + /// Attach a remote attestation entry (strongRef) to the record. 254 + /// 255 + /// The `attestation` value must be an object containing: 256 + /// - `$type`: `"com.atproto.repo.strongRef"` 257 + /// - `cid`: base32 CID string referencing the remote proof record 258 + /// - Optional `uri`: AT URI for the remote record 259 + pub fn create_remote_attestation_reference( 260 + record: &Value, 261 + attestation: &Value, 262 + did: &str, 263 + ) -> Result<Value, AttestationError> { 264 + let mut result = record 265 + .as_object() 266 + .cloned() 267 + .ok_or(AttestationError::RecordMustBeObject)?; 268 + 269 + let attestation = attestation 270 + .as_object() 271 + .cloned() 272 + .ok_or(AttestationError::MetadataMustBeObject)?; 273 + 274 + let remote_object_type = attestation 275 + .get("$type") 276 + .and_then(Value::as_str) 277 + .filter(|value| !value.is_empty()) 278 + .ok_or(AttestationError::RemoteAttestationMissingCid)?; 279 + 280 + let tid = Tid::new(); 281 + 282 + let attestion_cid = create_plain_cid(&serde_json::Value::Object(attestation.clone()))?; 283 + 284 + let remote_object = json!({ 285 + "$type": STRONG_REF_TYPE, 286 + "uri": format!("at://{did}/{remote_object_type}/{tid}"), 287 + "cid": attestion_cid.to_string() 288 + }); 289 + 290 + let mut signatures = extract_signatures_vec(&mut result)?; 291 + signatures.push(remote_object); 292 + result.insert("signatures".to_string(), Value::Array(signatures)); 293 + 294 + Ok(Value::Object(result)) 295 + } 296 + 297 + /// Attach an inline attestation entry containing signature bytes. 298 + /// 299 + /// The `attestation` value must be an object containing: 300 + /// - `$type`: union discriminator (must NOT be `com.atproto.repo.strongRef`) 301 + /// - `key`: verification method reference used to sign 302 + /// - `signature`: object with `$bytes` base64 signature 303 + /// Additional custom fields are preserved for `$sig` metadata. 304 + pub fn create_inline_attestation_reference( 305 + record: &Value, 306 + attestation: &Value, 307 + ) -> Result<Value, AttestationError> { 308 + let mut result = record 309 + .as_object() 310 + .cloned() 311 + .ok_or(AttestationError::RecordMustBeObject)?; 312 + 313 + let inline_object = attestation 314 + .as_object() 315 + .cloned() 316 + .ok_or(AttestationError::MetadataMustBeObject)?; 317 + 318 + let signature_type = inline_object 319 + .get("$type") 320 + .and_then(Value::as_str) 321 + .ok_or_else(|| AttestationError::MetadataMissingField { 322 + field: "$type".to_string(), 323 + })?; 324 + 325 + if signature_type == STRONG_REF_TYPE { 326 + return Err(AttestationError::InlineAttestationTypeInvalid); 327 + } 328 + 329 + inline_object 330 + .get("key") 331 + .and_then(Value::as_str) 332 + .filter(|value| !value.is_empty()) 333 + .ok_or_else(|| AttestationError::SignatureMissingField { 334 + field: "key".to_string(), 335 + })?; 336 + 337 + let signature_bytes = inline_object 338 + .get("signature") 339 + .and_then(Value::as_object) 340 + .and_then(|object| object.get("$bytes")) 341 + .and_then(Value::as_str) 342 + .filter(|value| !value.is_empty()) 343 + .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 344 + 345 + // Ensure the signature bytes decode cleanly to catch malformed input early. 346 + let _ = BASE64 347 + .decode(signature_bytes) 348 + .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 349 + 350 + let mut signatures = extract_signatures_vec(&mut result)?; 351 + signatures.push(Value::Object(inline_object)); 352 + result.insert("signatures".to_string(), Value::Array(signatures)); 353 + result.remove("$sig"); 354 + 355 + Ok(Value::Object(result)) 356 + } 357 + 358 + /// Verify a single attestation entry at the specified index without a record resolver. 359 + /// 360 + /// Inline signatures are reconstructed into `$sig` metadata, a CID is generated, 361 + /// and the signature bytes are validated against the resolved public key. 362 + /// Remote attestations will be reported as unverified. 363 + /// 364 + /// This is a convenience function for the common case where no record resolver is needed. 365 + /// For verifying remote attestations, use [`verify_signature_with_resolver`]. 366 + pub async fn verify_signature( 367 + record: &Value, 368 + index: usize, 369 + key_resolver: Option<&dyn KeyResolver>, 370 + ) -> Result<VerificationReport, AttestationError> { 371 + verify_signature_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>( 372 + record, 373 + index, 374 + key_resolver, 375 + None, 376 + ) 377 + .await 378 + } 379 + 380 + /// Verify a single attestation entry at the specified index with optional record resolver. 381 + /// 382 + /// Inline signatures are reconstructed into `$sig` metadata, a CID is generated, 383 + /// and the signature bytes are validated against the resolved public key. 384 + /// Remote attestations can be verified if a `record_resolver` is provided to fetch 385 + /// the proof record via AT-URI. Without a record resolver, remote attestations are 386 + /// reported as unverified. 387 + pub async fn verify_signature_with_resolver<R>( 388 + record: &Value, 389 + index: usize, 390 + key_resolver: Option<&dyn KeyResolver>, 391 + record_resolver: Option<&R>, 392 + ) -> Result<VerificationReport, AttestationError> 393 + where 394 + R: atproto_client::record_resolver::RecordResolver, 395 + { 396 + let signatures_array = extract_signatures_array(record)?; 397 + let signature_entry = signatures_array 398 + .get(index) 399 + .ok_or(AttestationError::SignatureIndexOutOfBounds { index })?; 400 + 401 + let signature_map = 402 + signature_entry 403 + .as_object() 404 + .ok_or_else(|| AttestationError::SignatureMissingField { 405 + field: "object".to_string(), 406 + })?; 407 + 408 + let signature_type = signature_map 409 + .get("$type") 410 + .and_then(Value::as_str) 411 + .map(ToOwned::to_owned); 412 + 413 + let report_kind = match signature_type.as_deref() { 414 + Some(STRONG_REF_TYPE) => AttestationKind::Remote, 415 + _ => AttestationKind::Inline, 416 + }; 417 + 418 + let key_reference = signature_map 419 + .get("key") 420 + .and_then(Value::as_str) 421 + .map(ToOwned::to_owned); 422 + 423 + let status = match report_kind { 424 + AttestationKind::Remote => { 425 + match record_resolver { 426 + Some(resolver) => { 427 + match verify_remote_attestation(record, signature_map, resolver).await { 428 + Ok(cid) => VerificationStatus::Valid { cid }, 429 + Err(error) => VerificationStatus::Invalid { error }, 430 + } 431 + } 432 + None => VerificationStatus::Unverified { 433 + reason: "Remote attestations require a record resolver to fetch the proof record via strongRef.".to_string(), 434 + }, 435 + } 436 + } 437 + AttestationKind::Inline => { 438 + match verify_inline_attestation(record, signature_map, key_resolver).await { 439 + Ok(cid) => VerificationStatus::Valid { cid }, 440 + Err(error) => VerificationStatus::Invalid { error }, 441 + } 442 + } 443 + }; 444 + 445 + Ok(VerificationReport { 446 + index, 447 + kind: report_kind, 448 + signature_type, 449 + key: key_reference, 450 + status, 451 + }) 452 + } 453 + 454 + /// Verify all attestation entries attached to the record without a record resolver. 455 + /// 456 + /// Returns a report per signature. Structural issues with the record (for 457 + /// example, a missing `signatures` array) are returned as an error. 458 + /// 459 + /// Remote attestations will be reported as unverified. For verifying remote 460 + /// attestations, use [`verify_all_signatures_with_resolver`]. 461 + pub async fn verify_all_signatures( 462 + record: &Value, 463 + key_resolver: Option<&dyn KeyResolver>, 464 + ) -> Result<Vec<VerificationReport>, AttestationError> { 465 + verify_all_signatures_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>( 466 + record, 467 + key_resolver, 468 + None, 469 + ) 470 + .await 471 + } 472 + 473 + /// Verify all attestation entries attached to the record with optional record resolver. 474 + /// 475 + /// Returns a report per signature. Structural issues with the record (for 476 + /// example, a missing `signatures` array) are returned as an error. 477 + /// 478 + /// If a `record_resolver` is provided, remote attestations will be fetched and verified. 479 + /// Otherwise, remote attestations will be reported as unverified. 480 + pub async fn verify_all_signatures_with_resolver<R>( 481 + record: &Value, 482 + key_resolver: Option<&dyn KeyResolver>, 483 + record_resolver: Option<&R>, 484 + ) -> Result<Vec<VerificationReport>, AttestationError> 485 + where 486 + R: atproto_client::record_resolver::RecordResolver, 487 + { 488 + let signatures_array = extract_signatures_array(record)?; 489 + let mut reports = Vec::with_capacity(signatures_array.len()); 490 + 491 + for index in 0..signatures_array.len() { 492 + reports.push( 493 + verify_signature_with_resolver(record, index, key_resolver, record_resolver).await?, 494 + ); 495 + } 496 + 497 + Ok(reports) 498 + } 499 + 500 + async fn verify_remote_attestation<R>( 501 + record: &Value, 502 + signature_object: &Map<String, Value>, 503 + record_resolver: &R, 504 + ) -> Result<Cid, AttestationError> 505 + where 506 + R: atproto_client::record_resolver::RecordResolver, 507 + { 508 + // Extract the strongRef URI and CID 509 + let uri = signature_object 510 + .get("uri") 511 + .and_then(Value::as_str) 512 + .ok_or_else(|| AttestationError::SignatureMissingField { 513 + field: "uri".to_string(), 514 + })?; 515 + 516 + let expected_cid_str = signature_object 517 + .get("cid") 518 + .and_then(Value::as_str) 519 + .ok_or_else(|| AttestationError::SignatureMissingField { 520 + field: "cid".to_string(), 521 + })?; 522 + 523 + // Fetch the proof record from the URI 524 + let proof_record: Value = record_resolver.resolve(uri).await.map_err(|error| { 525 + AttestationError::RemoteAttestationFetchFailed { 526 + uri: uri.to_string(), 527 + error, 528 + } 529 + })?; 530 + 531 + // Verify the proof record CID matches 532 + let proof_cid = create_plain_cid(&proof_record)?; 533 + if proof_cid.to_string() != expected_cid_str { 534 + return Err(AttestationError::RemoteAttestationCidMismatch { 535 + expected: expected_cid_str.to_string(), 536 + actual: proof_cid.to_string(), 537 + }); 538 + } 539 + 540 + // Extract the CID from the proof record 541 + let attestation_cid_str = proof_record 542 + .get("cid") 543 + .and_then(Value::as_str) 544 + .ok_or_else(|| AttestationError::SignatureMissingField { 545 + field: "cid".to_string(), 546 + })?; 547 + 548 + // Parse the attestation CID 549 + let attestation_cid = 550 + attestation_cid_str 551 + .parse::<Cid>() 552 + .map_err(|_| AttestationError::InvalidCid { 553 + cid: attestation_cid_str.to_string(), 554 + })?; 555 + 556 + // Prepare the signing record using the proof record as metadata (without the CID field) 557 + let mut proof_metadata = proof_record 558 + .as_object() 559 + .cloned() 560 + .ok_or(AttestationError::RecordMustBeObject)?; 561 + proof_metadata.remove("cid"); 562 + 563 + let signing_record = prepare_signing_record(record, &Value::Object(proof_metadata))?; 564 + let computed_cid = create_cid(&signing_record)?; 565 + 566 + // Verify the CID matches 567 + if computed_cid != attestation_cid { 568 + return Err(AttestationError::RemoteAttestationCidMismatch { 569 + expected: attestation_cid.to_string(), 570 + actual: computed_cid.to_string(), 571 + }); 572 + } 573 + 574 + Ok(computed_cid) 575 + } 576 + 577 + async fn verify_inline_attestation( 578 + record: &Value, 579 + signature_object: &Map<String, Value>, 580 + key_resolver: Option<&dyn KeyResolver>, 581 + ) -> Result<Cid, AttestationError> { 582 + let key_reference = signature_object 583 + .get("key") 584 + .and_then(Value::as_str) 585 + .ok_or_else(|| AttestationError::SignatureMissingField { 586 + field: "key".to_string(), 587 + })?; 588 + 589 + let key_data = resolve_key_reference(key_reference, key_resolver).await?; 590 + 591 + let signature_bytes = signature_object 592 + .get("signature") 593 + .and_then(Value::as_object) 594 + .and_then(|object| object.get("$bytes")) 595 + .and_then(Value::as_str) 596 + .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 597 + 598 + let signature_bytes = BASE64 599 + .decode(signature_bytes) 600 + .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 601 + 602 + ensure_normalized_signature(&key_data, &signature_bytes)?; 603 + 604 + let mut sig_metadata = signature_object.clone(); 605 + sig_metadata.remove("signature"); 606 + 607 + let signing_record = prepare_signing_record(record, &Value::Object(sig_metadata))?; 608 + let cid = create_cid(&signing_record)?; 609 + let cid_bytes = cid.to_bytes(); 610 + 611 + validate(&key_data, &signature_bytes, &cid_bytes) 612 + .map_err(|error| AttestationError::SignatureValidationFailed { error })?; 613 + 614 + Ok(cid) 615 + } 616 + 617 + async fn resolve_key_reference( 618 + key_reference: &str, 619 + key_resolver: Option<&dyn KeyResolver>, 620 + ) -> Result<KeyData, AttestationError> { 621 + if let Some(base) = key_reference.split('#').next() { 622 + if let Ok(key_data) = identify_key(base) { 623 + return Ok(key_data); 624 + } 625 + } 626 + 627 + if let Ok(key_data) = identify_key(key_reference) { 628 + return Ok(key_data); 629 + } 630 + 631 + let resolver = key_resolver.ok_or_else(|| AttestationError::KeyResolverRequired { 632 + key: key_reference.to_string(), 633 + })?; 634 + 635 + resolver 636 + .resolve(key_reference) 637 + .await 638 + .map_err(|error| AttestationError::KeyResolutionFailed { 639 + key: key_reference.to_string(), 640 + error, 641 + }) 642 + } 643 + 644 + fn normalize_p256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> { 645 + if signature.len() != 64 { 646 + return Err(AttestationError::SignatureLengthInvalid { 647 + expected: 64, 648 + actual: signature.len(), 649 + }); 650 + } 651 + 652 + let parsed = P256Signature::from_slice(&signature).map_err(|_| { 653 + AttestationError::SignatureLengthInvalid { 654 + expected: 64, 655 + actual: signature.len(), 656 + } 657 + })?; 658 + 659 + let normalized = parsed.normalize_s().unwrap_or(parsed); 660 + 661 + Ok(normalized.to_vec()) 662 + } 663 + 664 + fn normalize_k256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> { 665 + if signature.len() != 64 { 666 + return Err(AttestationError::SignatureLengthInvalid { 667 + expected: 64, 668 + actual: signature.len(), 669 + }); 670 + } 671 + 672 + let parsed = K256Signature::from_slice(&signature).map_err(|_| { 673 + AttestationError::SignatureLengthInvalid { 674 + expected: 64, 675 + actual: signature.len(), 676 + } 677 + })?; 678 + 679 + let normalized = parsed.normalize_s().unwrap_or(parsed); 680 + 681 + Ok(normalized.to_vec()) 682 + } 683 + 684 + fn ensure_normalized_signature( 685 + key_data: &KeyData, 686 + signature: &[u8], 687 + ) -> Result<(), AttestationError> { 688 + match key_data.key_type() { 689 + KeyType::P256Private | KeyType::P256Public => { 690 + if signature.len() != 64 { 691 + return Err(AttestationError::SignatureLengthInvalid { 692 + expected: 64, 693 + actual: signature.len(), 694 + }); 695 + } 696 + 697 + let parsed = P256Signature::from_slice(signature).map_err(|_| { 698 + AttestationError::SignatureLengthInvalid { 699 + expected: 64, 700 + actual: signature.len(), 701 + } 702 + })?; 703 + 704 + if bool::from(parsed.s().is_high()) { 705 + return Err(AttestationError::SignatureNotNormalized); 706 + } 707 + } 708 + KeyType::K256Private | KeyType::K256Public => { 709 + if signature.len() != 64 { 710 + return Err(AttestationError::SignatureLengthInvalid { 711 + expected: 64, 712 + actual: signature.len(), 713 + }); 714 + } 715 + 716 + let parsed = K256Signature::from_slice(signature).map_err(|_| { 717 + AttestationError::SignatureLengthInvalid { 718 + expected: 64, 719 + actual: signature.len(), 720 + } 721 + })?; 722 + 723 + if bool::from(parsed.s().is_high()) { 724 + return Err(AttestationError::SignatureNotNormalized); 725 + } 726 + } 727 + other => { 728 + return Err(AttestationError::UnsupportedKeyType { 729 + key_type: other.clone(), 730 + }); 731 + } 732 + } 733 + 734 + Ok(()) 735 + } 736 + 737 + fn extract_signatures_array(record: &Value) -> Result<&Vec<Value>, AttestationError> { 738 + let signatures = record.get("signatures"); 739 + 740 + match signatures { 741 + Some(value) => value 742 + .as_array() 743 + .ok_or(AttestationError::SignaturesFieldInvalid), 744 + None => Err(AttestationError::SignaturesArrayMissing), 745 + } 746 + } 747 + 748 + fn extract_signatures_vec(record: &mut Map<String, Value>) -> Result<Vec<Value>, AttestationError> { 749 + let existing = record.remove("signatures"); 750 + 751 + match existing { 752 + Some(Value::Array(array)) => Ok(array), 753 + Some(_) => Err(AttestationError::SignaturesFieldInvalid), 754 + None => Ok(Vec::new()), 755 + } 756 + } 757 + 758 + #[cfg(test)] 759 + mod tests { 760 + use super::*; 761 + use atproto_identity::key::{IdentityDocumentKeyResolver, KeyType, generate_key, to_public}; 762 + use atproto_identity::model::{Document, DocumentBuilder, VerificationMethod}; 763 + use atproto_identity::resolve::IdentityResolver; 764 + use serde_json::json; 765 + use std::sync::Arc; 766 + 767 + struct StaticResolver { 768 + document: Document, 769 + } 770 + 771 + #[async_trait::async_trait] 772 + impl IdentityResolver for StaticResolver { 773 + async fn resolve(&self, _subject: &str) -> anyhow::Result<Document> { 774 + Ok(self.document.clone()) 775 + } 776 + } 777 + 778 + #[test] 779 + fn prepare_signing_record_removes_signatures() -> Result<(), AttestationError> { 780 + let record = json!({ 781 + "$type": "app.bsky.feed.post", 782 + "text": "hello", 783 + "signatures": [ 784 + {"$type": "example.sig", "signature": {"$bytes": "dGVzdA=="}, "key": "did:key:zabc"} 785 + ] 786 + }); 787 + 788 + let metadata = json!({ 789 + "$type": "com.example.inlineSignature", 790 + "key": "did:key:zabc", 791 + "purpose": "demo", 792 + "signature": {"$bytes": "trim"}, 793 + "cid": "bafyignored" 794 + }); 795 + 796 + let prepared = prepare_signing_record(&record, &metadata)?; 797 + let object = prepared.as_object().unwrap(); 798 + assert!(object.get("signatures").is_none()); 799 + assert!(object.get("sigs").is_none()); 800 + assert!(object.get("$sig").is_some()); 801 + 802 + let sig_object = object.get("$sig").unwrap().as_object().unwrap(); 803 + assert_eq!( 804 + sig_object.get("$type").and_then(Value::as_str), 805 + Some("com.example.inlineSignature") 806 + ); 807 + assert_eq!( 808 + sig_object.get("purpose").and_then(Value::as_str), 809 + Some("demo") 810 + ); 811 + assert!(sig_object.get("signature").is_none()); 812 + assert!(sig_object.get("cid").is_none()); 813 + 814 + Ok(()) 815 + } 816 + 817 + #[test] 818 + fn create_cid_produces_expected_codec_and_length() -> Result<(), AttestationError> { 819 + let prepared = json!({ 820 + "$type": "app.example.record", 821 + "text": "cid demo", 822 + "$sig": { 823 + "$type": "com.example.inlineSignature", 824 + "key": "did:key:zabc" 825 + } 826 + }); 827 + 828 + let cid = create_cid(&prepared)?; 829 + assert_eq!(cid.codec(), 0x71); 830 + assert_eq!(cid.hash().code(), 0x12); 831 + assert_eq!(cid.hash().digest().len(), 32); 832 + assert_eq!(cid.to_bytes().len(), 36); 833 + 834 + Ok(()) 835 + } 836 + 837 + #[test] 838 + fn create_inline_attestation_appends_signature() -> Result<(), AttestationError> { 839 + let record = json!({ 840 + "$type": "app.example.record", 841 + "body": "Important content" 842 + }); 843 + 844 + let inline = json!({ 845 + "$type": "com.example.inlineSignature", 846 + "key": "did:key:zabc", 847 + "signature": {"$bytes": "ZHVtbXk="} 848 + }); 849 + 850 + let updated = create_inline_attestation_reference(&record, &inline)?; 851 + let signatures = updated 852 + .get("signatures") 853 + .and_then(Value::as_array) 854 + .expect("signatures array should exist"); 855 + assert_eq!(signatures.len(), 1); 856 + assert_eq!( 857 + signatures[0].get("$type").and_then(Value::as_str), 858 + Some("com.example.inlineSignature") 859 + ); 860 + 861 + Ok(()) 862 + } 863 + 864 + #[test] 865 + fn create_remote_attestation_produces_reference_and_proof() 866 + -> Result<(), Box<dyn std::error::Error>> { 867 + let record = json!({ 868 + "$type": "app.example.record", 869 + "body": "remote attestation" 870 + }); 871 + 872 + let metadata = json!({ 873 + "$type": "com.example.inlineSignature" 874 + }); 875 + 876 + let proof_record = create_remote_attestation(&record, &metadata)?; 877 + 878 + let proof_object = proof_record 879 + .as_object() 880 + .expect("reference should be an object"); 881 + assert_eq!( 882 + proof_object.get("$type").and_then(Value::as_str), 883 + Some("com.example.inlineSignature") 884 + ); 885 + assert!( 886 + proof_object.get("cid").and_then(Value::as_str).is_some(), 887 + "proof must contain a cid" 888 + ); 889 + 890 + Ok(()) 891 + } 892 + 893 + #[tokio::test] 894 + async fn verify_inline_signature_with_did_key() -> Result<(), Box<dyn std::error::Error>> { 895 + let private_key = generate_key(KeyType::K256Private)?; 896 + let public_key = to_public(&private_key)?; 897 + let key_reference = format!("{}", &public_key); 898 + 899 + let base_record = json!({ 900 + "$type": "app.example.record", 901 + "body": "Sign me" 902 + }); 903 + 904 + let sig_metadata = json!({ 905 + "$type": "com.example.inlineSignature", 906 + "key": key_reference, 907 + "purpose": "unit-test" 908 + }); 909 + 910 + let signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?; 911 + 912 + let report = verify_signature(&signed, 0, None).await?; 913 + match report.status { 914 + VerificationStatus::Valid { .. } => {} 915 + other => panic!("expected valid signature, got {:?}", other), 916 + } 917 + 918 + Ok(()) 919 + } 920 + 921 + #[tokio::test] 922 + async fn verify_inline_signature_with_resolver() -> Result<(), Box<dyn std::error::Error>> { 923 + let private_key = generate_key(KeyType::P256Private)?; 924 + let public_key = to_public(&private_key)?; 925 + let key_multibase = format!("{}", &public_key); 926 + let key_reference = "did:plc:resolvertest#atproto".to_string(); 927 + 928 + let document = DocumentBuilder::new() 929 + .id("did:plc:resolvertest") 930 + .add_verification_method(VerificationMethod::Multikey { 931 + id: key_reference.clone(), 932 + controller: "did:plc:resolvertest".to_string(), 933 + public_key_multibase: key_multibase 934 + .strip_prefix("did:key:") 935 + .unwrap_or(&key_multibase) 936 + .to_string(), 937 + extra: std::collections::HashMap::new(), 938 + }) 939 + .build() 940 + .unwrap(); 941 + 942 + let identity_resolver = Arc::new(StaticResolver { document }); 943 + let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver.clone()); 944 + 945 + let base_record = json!({ 946 + "$type": "app.example.record", 947 + "body": "resolver test" 948 + }); 949 + 950 + let sig_metadata = json!({ 951 + "$type": "com.example.inlineSignature", 952 + "key": key_reference, 953 + "scope": "resolver" 954 + }); 955 + 956 + let signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?; 957 + 958 + let report = verify_signature(&signed, 0, Some(&key_resolver)).await?; 959 + match report.status { 960 + VerificationStatus::Valid { .. } => {} 961 + other => panic!("expected valid signature, got {:?}", other), 962 + } 963 + 964 + Ok(()) 965 + } 966 + 967 + #[tokio::test] 968 + async fn verify_all_signatures_reports_remote() -> Result<(), Box<dyn std::error::Error>> { 969 + let record = json!({ 970 + "$type": "app.example.record", 971 + "signatures": [ 972 + { 973 + "$type": STRONG_REF_TYPE, 974 + "cid": "bafyreid473y2gjzvzgjwdj3vpbk2bdzodf5hvbgxncjc62xmy3zsmb3pxq", 975 + "uri": "at://did:plc:example/com.example.attestation/abc123" 976 + } 977 + ] 978 + }); 979 + 980 + let reports = verify_all_signatures(&record, None).await?; 981 + assert_eq!(reports.len(), 1); 982 + match &reports[0].status { 983 + VerificationStatus::Unverified { reason } => { 984 + assert!(reason.contains("Remote attestations")); 985 + } 986 + other => panic!("expected unverified status, got {:?}", other), 987 + } 988 + 989 + Ok(()) 990 + } 991 + 992 + #[tokio::test] 993 + async fn verify_detects_tampering() -> Result<(), Box<dyn std::error::Error>> { 994 + let private_key = generate_key(KeyType::K256Private)?; 995 + let public_key = to_public(&private_key)?; 996 + let key_reference = format!("{}", &public_key); 997 + 998 + let base_record = json!({ 999 + "$type": "app.example.record", 1000 + "body": "original" 1001 + }); 1002 + 1003 + let sig_metadata = json!({ 1004 + "$type": "com.example.inlineSignature", 1005 + "key": key_reference 1006 + }); 1007 + 1008 + let mut signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?; 1009 + if let Some(object) = signed.as_object_mut() { 1010 + object.insert("body".to_string(), json!("tampered")); 1011 + } 1012 + 1013 + let report = verify_signature(&signed, 0, None).await?; 1014 + match report.status { 1015 + VerificationStatus::Invalid { .. } => {} 1016 + other => panic!("expected invalid signature, got {:?}", other), 1017 + } 1018 + 1019 + Ok(()) 1020 + } 1021 + }
+2 -1
crates/atproto-client/Cargo.toml
··· 37 37 38 38 [dependencies] 39 39 atproto-identity.workspace = true 40 - atproto-record.workspace = true 41 40 atproto-oauth.workspace = true 41 + atproto-record.workspace = true 42 42 43 43 anyhow.workspace = true 44 44 reqwest-chain.workspace = true ··· 50 50 tokio.workspace = true 51 51 tracing.workspace = true 52 52 urlencoding = "2.1.3" 53 + async-trait.workspace = true 53 54 bytes = "1.10.1" 54 55 clap = { workspace = true, optional = true } 55 56 rpassword = { workspace = true, optional = true }
+7 -7
crates/atproto-client/src/com_atproto_identity.rs
··· 6 6 use std::collections::HashMap; 7 7 8 8 use anyhow::Result; 9 - use atproto_identity::url::URLBuilder; 9 + use atproto_identity::url::build_url; 10 10 use serde::{Deserialize, de::DeserializeOwned}; 11 11 12 12 use crate::{ ··· 58 58 base_url: &str, 59 59 handle: String, 60 60 ) -> Result<ResolveHandleResponse> { 61 - let mut url_builder = URLBuilder::new(base_url); 62 - url_builder.path("/xrpc/com.atproto.identity.resolveHandle"); 63 - 64 - url_builder.param("handle", &handle); 65 - 66 - let url = url_builder.build(); 61 + let url = build_url( 62 + base_url, 63 + "/xrpc/com.atproto.identity.resolveHandle", 64 + [("handle", handle.as_str())], 65 + )? 66 + .to_string(); 67 67 68 68 match auth { 69 69 Auth::None => get_json(http_client, &url)
+48 -41
crates/atproto-client/src/com_atproto_repo.rs
··· 23 23 //! OAuth access tokens and private keys for proof generation. 24 24 25 25 use std::collections::HashMap; 26 + use std::iter; 26 27 27 28 use anyhow::Result; 28 - use atproto_identity::url::URLBuilder; 29 + use atproto_identity::url::build_url; 29 30 use bytes::Bytes; 30 31 use serde::{Deserialize, Serialize, de::DeserializeOwned}; 31 32 ··· 77 78 did: &str, 78 79 cid: &str, 79 80 ) -> Result<Bytes> { 80 - let mut url_builder = URLBuilder::new(base_url); 81 - url_builder.path("/xrpc/com.atproto.sync.getBlob"); 82 - 83 - url_builder.param("did", did); 84 - url_builder.param("cid", cid); 85 - 86 - let url = url_builder.build(); 81 + let url = build_url( 82 + base_url, 83 + "/xrpc/com.atproto.sync.getBlob", 84 + [("did", did), ("cid", cid)], 85 + )? 86 + .to_string(); 87 87 88 88 get_bytes(http_client, &url).await 89 89 } ··· 112 112 rkey: &str, 113 113 cid: Option<&str>, 114 114 ) -> Result<GetRecordResponse> { 115 - let mut url_builder = URLBuilder::new(base_url); 116 - url_builder.path("/xrpc/com.atproto.repo.getRecord"); 117 - 118 - url_builder.param("repo", repo); 119 - url_builder.param("collection", collection); 120 - url_builder.param("rkey", rkey); 121 - 115 + let mut params = vec![("repo", repo), ("collection", collection), ("rkey", rkey)]; 122 116 if let Some(cid) = cid { 123 - url_builder.param("cid", cid); 117 + params.push(("cid", cid)); 124 118 } 125 119 126 - let url = url_builder.build(); 120 + let url = build_url(base_url, "/xrpc/com.atproto.repo.getRecord", params)?.to_string(); 127 121 128 122 match auth { 129 123 Auth::None => get_json(http_client, &url) ··· 218 212 collection: String, 219 213 params: ListRecordsParams, 220 214 ) -> Result<ListRecordsResponse<T>> { 221 - let mut url_builder = URLBuilder::new(base_url); 222 - url_builder.path("/xrpc/com.atproto.repo.listRecords"); 215 + let mut url = build_url( 216 + base_url, 217 + "/xrpc/com.atproto.repo.listRecords", 218 + iter::empty::<(&str, &str)>(), 219 + )?; 220 + { 221 + let mut pairs = url.query_pairs_mut(); 222 + pairs.append_pair("repo", &repo); 223 + pairs.append_pair("collection", &collection); 223 224 224 - // Add query parameters 225 - url_builder.param("repo", &repo); 226 - url_builder.param("collection", &collection); 225 + if let Some(limit) = params.limit { 226 + pairs.append_pair("limit", &limit.to_string()); 227 + } 227 228 228 - if let Some(limit) = params.limit { 229 - url_builder.param("limit", &limit.to_string()); 230 - } 229 + if let Some(cursor) = params.cursor { 230 + pairs.append_pair("cursor", &cursor); 231 + } 231 232 232 - if let Some(cursor) = params.cursor { 233 - url_builder.param("cursor", &cursor); 233 + if let Some(reverse) = params.reverse { 234 + pairs.append_pair("reverse", &reverse.to_string()); 235 + } 234 236 } 235 237 236 - if let Some(reverse) = params.reverse { 237 - url_builder.param("reverse", &reverse.to_string()); 238 - } 239 - 240 - let url = url_builder.build(); 238 + let url = url.to_string(); 241 239 242 240 match auth { 243 241 Auth::None => get_json(http_client, &url) ··· 319 317 base_url: &str, 320 318 record: CreateRecordRequest<T>, 321 319 ) -> Result<CreateRecordResponse> { 322 - let mut url_builder = URLBuilder::new(base_url); 323 - url_builder.path("/xrpc/com.atproto.repo.createRecord"); 324 - let url = url_builder.build(); 320 + let url = build_url( 321 + base_url, 322 + "/xrpc/com.atproto.repo.createRecord", 323 + iter::empty::<(&str, &str)>(), 324 + )? 325 + .to_string(); 325 326 326 327 let value = serde_json::to_value(record)?; 327 328 ··· 413 414 base_url: &str, 414 415 record: PutRecordRequest<T>, 415 416 ) -> Result<PutRecordResponse> { 416 - let mut url_builder = URLBuilder::new(base_url); 417 - url_builder.path("/xrpc/com.atproto.repo.putRecord"); 418 - let url = url_builder.build(); 417 + let url = build_url( 418 + base_url, 419 + "/xrpc/com.atproto.repo.putRecord", 420 + iter::empty::<(&str, &str)>(), 421 + )? 422 + .to_string(); 419 423 420 424 let value = serde_json::to_value(record)?; 421 425 ··· 496 500 base_url: &str, 497 501 record: DeleteRecordRequest, 498 502 ) -> Result<DeleteRecordResponse> { 499 - let mut url_builder = URLBuilder::new(base_url); 500 - url_builder.path("/xrpc/com.atproto.repo.deleteRecord"); 501 - let url = url_builder.build(); 503 + let url = build_url( 504 + base_url, 505 + "/xrpc/com.atproto.repo.deleteRecord", 506 + iter::empty::<(&str, &str)>(), 507 + )? 508 + .to_string(); 502 509 503 510 let value = serde_json::to_value(record)?; 504 511
+26 -13
crates/atproto-client/src/com_atproto_server.rs
··· 19 19 //! an access JWT token from an authenticated session. 20 20 21 21 use anyhow::Result; 22 - use atproto_identity::url::URLBuilder; 22 + use atproto_identity::url::build_url; 23 23 use serde::{Deserialize, Serialize}; 24 + use std::iter; 24 25 25 26 use crate::{ 26 27 client::{Auth, post_json}, ··· 118 119 password: &str, 119 120 auth_factor_token: Option<&str>, 120 121 ) -> Result<AppPasswordSession> { 121 - let mut url_builder = URLBuilder::new(base_url); 122 - url_builder.path("/xrpc/com.atproto.server.createSession"); 123 - let url = url_builder.build(); 122 + let url = build_url( 123 + base_url, 124 + "/xrpc/com.atproto.server.createSession", 125 + iter::empty::<(&str, &str)>(), 126 + )? 127 + .to_string(); 124 128 125 129 let request = CreateSessionRequest { 126 130 identifier: identifier.to_string(), ··· 156 160 base_url: &str, 157 161 refresh_token: &str, 158 162 ) -> Result<RefreshSessionResponse> { 159 - let mut url_builder = URLBuilder::new(base_url); 160 - url_builder.path("/xrpc/com.atproto.server.refreshSession"); 161 - let url = url_builder.build(); 163 + let url = build_url( 164 + base_url, 165 + "/xrpc/com.atproto.server.refreshSession", 166 + iter::empty::<(&str, &str)>(), 167 + )? 168 + .to_string(); 162 169 163 170 // Create a new client with the refresh token in Authorization header 164 171 let mut headers = reqwest::header::HeaderMap::new(); ··· 197 204 access_token: &str, 198 205 name: &str, 199 206 ) -> Result<AppPasswordResponse> { 200 - let mut url_builder = URLBuilder::new(base_url); 201 - url_builder.path("/xrpc/com.atproto.server.createAppPassword"); 202 - let url = url_builder.build(); 207 + let url = build_url( 208 + base_url, 209 + "/xrpc/com.atproto.server.createAppPassword", 210 + iter::empty::<(&str, &str)>(), 211 + )? 212 + .to_string(); 203 213 204 214 let request_body = serde_json::json!({ 205 215 "name": name ··· 260 270 } 261 271 }; 262 272 263 - let mut url_builder = URLBuilder::new(base_url); 264 - url_builder.path("/xrpc/com.atproto.server.deleteSession"); 265 - let url = url_builder.build(); 273 + let url = build_url( 274 + base_url, 275 + "/xrpc/com.atproto.server.deleteSession", 276 + iter::empty::<(&str, &str)>(), 277 + )? 278 + .to_string(); 266 279 267 280 // Create headers with the Bearer token 268 281 let mut headers = reqwest::header::HeaderMap::new();
+3
crates/atproto-client/src/lib.rs
··· 20 20 21 21 pub mod client; 22 22 pub mod errors; 23 + pub mod record_resolver; 24 + 25 + pub use record_resolver::{HttpRecordResolver, RecordResolver}; 23 26 24 27 mod com_atproto_identity; 25 28 mod com_atproto_repo;
+77
crates/atproto-client/src/record_resolver.rs
··· 1 + //! Helpers for resolving AT Protocol records referenced by URI. 2 + 3 + use std::str::FromStr; 4 + 5 + use anyhow::{Result, anyhow, bail}; 6 + use async_trait::async_trait; 7 + use atproto_record::aturi::ATURI; 8 + 9 + use crate::{ 10 + client::Auth, 11 + com::atproto::repo::{GetRecordResponse, get_record}, 12 + }; 13 + 14 + /// Trait for resolving AT Protocol records by `at://` URI. 15 + /// 16 + /// Implementations perform the network lookup and deserialize the response into 17 + /// the requested type. 18 + #[async_trait] 19 + pub trait RecordResolver: Send + Sync { 20 + /// Resolve an AT URI to a typed record. 21 + async fn resolve<T>(&self, aturi: &str) -> Result<T> 22 + where 23 + T: serde::de::DeserializeOwned + Send; 24 + } 25 + 26 + /// Resolver that fetches records using public XRPC endpoints. 27 + #[derive(Clone)] 28 + pub struct HttpRecordResolver { 29 + http_client: reqwest::Client, 30 + base_url: String, 31 + } 32 + 33 + impl HttpRecordResolver { 34 + /// Create a new resolver using the provided HTTP client and PDS base URL. 35 + pub fn new(http_client: reqwest::Client, base_url: impl Into<String>) -> Self { 36 + Self { 37 + http_client, 38 + base_url: base_url.into(), 39 + } 40 + } 41 + } 42 + 43 + #[async_trait] 44 + impl RecordResolver for HttpRecordResolver { 45 + async fn resolve<T>(&self, aturi: &str) -> Result<T> 46 + where 47 + T: serde::de::DeserializeOwned + Send, 48 + { 49 + let parsed = ATURI::from_str(aturi).map_err(|error| anyhow!(error))?; 50 + let auth = Auth::None; 51 + 52 + let response = get_record( 53 + &self.http_client, 54 + &auth, 55 + &self.base_url, 56 + &parsed.authority, 57 + &parsed.collection, 58 + &parsed.record_key, 59 + None, 60 + ) 61 + .await?; 62 + 63 + match response { 64 + GetRecordResponse::Record { value, .. } => { 65 + serde_json::from_value(value).map_err(|error| anyhow!(error)) 66 + } 67 + GetRecordResponse::Error(error) => { 68 + let message = error.error_message(); 69 + if message.is_empty() { 70 + bail!("Record resolution failed without additional error details"); 71 + } 72 + 73 + bail!(message); 74 + } 75 + } 76 + } 77 + }
+1
crates/atproto-identity/Cargo.toml
··· 62 62 thiserror.workspace = true 63 63 tokio.workspace = true 64 64 tracing.workspace = true 65 + url.workspace = true 65 66 urlencoding.workspace = true 66 67 zeroize = { workspace = true, optional = true } 67 68
+70 -21
crates/atproto-identity/src/key.rs
··· 47 47 //! } 48 48 //! ``` 49 49 50 - use anyhow::Result; 50 + use anyhow::{Context, Result, anyhow}; 51 51 use ecdsa::signature::Signer; 52 52 use elliptic_curve::JwkEcKey; 53 53 use elliptic_curve::sec1::ToEncodedPoint; 54 54 55 + use crate::model::VerificationMethod; 56 + use crate::traits::IdentityResolver; 57 + 58 + pub use crate::traits::KeyResolver; 59 + use std::sync::Arc; 60 + 55 61 use crate::errors::KeyError; 56 62 57 63 #[cfg(feature = "zeroize")] 58 64 use zeroize::{Zeroize, ZeroizeOnDrop}; 59 65 60 66 /// Cryptographic key types supported for AT Protocol identity. 61 - #[derive(Clone, PartialEq)] 62 - #[cfg_attr(debug_assertions, derive(Debug))] 67 + #[derive(Clone, PartialEq, Debug)] 63 68 #[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))] 64 69 pub enum KeyType { 65 70 /// A p256 (P-256 / secp256r1 / ES256) public key. ··· 160 165 // Add DID key prefix 161 166 write!(f, "did:key:{}", multibase_encoded) 162 167 } 163 - } 164 - 165 - /// Trait for providing cryptographic keys by identifier. 166 - /// 167 - /// This trait defines the interface for key providers that can retrieve private keys 168 - /// by their identifier. Implementations must be thread-safe to support concurrent access. 169 - #[async_trait::async_trait] 170 - pub trait KeyProvider: Send + Sync { 171 - /// Retrieves a private key by its identifier. 172 - /// 173 - /// # Arguments 174 - /// * `key_id` - The identifier of the key to retrieve 175 - /// 176 - /// # Returns 177 - /// * `Ok(Some(KeyData))` - If the key was found and successfully retrieved 178 - /// * `Ok(None)` - If no key exists for the given identifier 179 - /// * `Err(anyhow::Error)` - If an error occurred during key retrieval 180 - async fn get_private_key_by_id(&self, key_id: &str) -> Result<Option<KeyData>>; 181 168 } 182 169 183 170 /// DID key method prefix. ··· 362 349 .map_err(|error| KeyError::ECDSAError { error })?; 363 350 Ok(signature.to_vec()) 364 351 } 352 + } 353 + } 354 + 355 + /// Key resolver implementation that fetches DID documents using an [`IdentityResolver`]. 356 + #[derive(Clone)] 357 + pub struct IdentityDocumentKeyResolver { 358 + identity_resolver: Arc<dyn IdentityResolver>, 359 + } 360 + 361 + impl IdentityDocumentKeyResolver { 362 + /// Creates a new key resolver backed by an [`IdentityResolver`]. 363 + pub fn new(identity_resolver: Arc<dyn IdentityResolver>) -> Self { 364 + Self { identity_resolver } 365 + } 366 + } 367 + 368 + #[async_trait::async_trait] 369 + impl KeyResolver for IdentityDocumentKeyResolver { 370 + async fn resolve(&self, key: &str) -> Result<KeyData> { 371 + if let Some(did_key) = key.split('#').next() { 372 + if let Ok(key_data) = identify_key(did_key) { 373 + return Ok(key_data); 374 + } 375 + } else if let Ok(key_data) = identify_key(key) { 376 + return Ok(key_data); 377 + } 378 + 379 + let (did, fragment) = key 380 + .split_once('#') 381 + .context("Key reference must contain a DID fragment (e.g., did:example#key)")?; 382 + 383 + if did.is_empty() || fragment.is_empty() { 384 + return Err(anyhow!( 385 + "Key reference must include both DID and fragment (received `{key}`)" 386 + )); 387 + } 388 + 389 + let document = self.identity_resolver.resolve(did).await?; 390 + let fragment_with_hash = format!("#{fragment}"); 391 + 392 + let public_key_multibase = document 393 + .verification_method 394 + .iter() 395 + .find_map(|method| match method { 396 + VerificationMethod::Multikey { 397 + id, 398 + public_key_multibase, 399 + .. 400 + } if id == key || *id == fragment_with_hash => Some(public_key_multibase.clone()), 401 + _ => None, 402 + }) 403 + .context(format!( 404 + "Verification method `{key}` not found in DID document `{did}`" 405 + ))?; 406 + 407 + let full_key = if public_key_multibase.starts_with("did:key:") { 408 + public_key_multibase 409 + } else { 410 + format!("did:key:{}", public_key_multibase) 411 + }; 412 + 413 + identify_key(&full_key).context("Failed to parse key data from verification method") 365 414 } 366 415 } 367 416
+1 -1
crates/atproto-identity/src/lib.rs
··· 19 19 pub mod model; 20 20 pub mod plc; 21 21 pub mod resolve; 22 - pub mod storage; 23 22 #[cfg(feature = "lru")] 24 23 pub mod storage_lru; 24 + pub mod traits; 25 25 pub mod url; 26 26 pub mod validation; 27 27 pub mod web;
+95 -29
crates/atproto-identity/src/resolve.rs
··· 32 32 use crate::validation::{is_valid_did_method_plc, is_valid_handle}; 33 33 use crate::web::query as web_query; 34 34 35 - /// Trait for AT Protocol identity resolution. 36 - /// 37 - /// Implementations must be thread-safe (Send + Sync) and usable in async environments. 38 - /// This trait provides the core functionality for resolving AT Protocol subjects 39 - /// (handles or DIDs) to their corresponding DID documents. 40 - #[async_trait::async_trait] 41 - pub trait IdentityResolver: Send + Sync { 42 - /// Resolves an AT Protocol subject to its DID document. 43 - /// 44 - /// Takes a handle or DID, resolves it to a canonical DID, then retrieves 45 - /// the corresponding DID document from the appropriate source (PLC directory or web). 46 - /// 47 - /// # Arguments 48 - /// * `subject` - The AT Protocol handle or DID to resolve 49 - /// 50 - /// # Returns 51 - /// * `Ok(Document)` - The resolved DID document 52 - /// * `Err(anyhow::Error)` - Resolution error with detailed context 53 - async fn resolve(&self, subject: &str) -> Result<Document>; 54 - } 55 - 56 - /// Trait for DNS resolution operations. 57 - /// Provides async DNS TXT record lookups for handle resolution. 58 - #[async_trait::async_trait] 59 - pub trait DnsResolver: Send + Sync { 60 - /// Resolves TXT records for a given domain name. 61 - /// Returns a vector of strings representing the TXT record values. 62 - async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>; 63 - } 35 + pub use crate::traits::{DnsResolver, IdentityResolver}; 64 36 65 37 /// Hickory DNS implementation of the DnsResolver trait. 66 38 /// Wraps hickory_resolver::TokioResolver for TXT record resolution. ··· 196 168 is_valid_handle(trimmed) 197 169 .map(InputType::Handle) 198 170 .ok_or(ResolveError::InvalidInput) 171 + } 172 + } 173 + 174 + #[cfg(test)] 175 + mod tests { 176 + use super::*; 177 + use crate::key::{ 178 + IdentityDocumentKeyResolver, KeyResolver, KeyType, generate_key, identify_key, to_public, 179 + }; 180 + use crate::model::{DocumentBuilder, VerificationMethod}; 181 + use std::collections::HashMap; 182 + 183 + struct StubIdentityResolver { 184 + expected: String, 185 + document: Document, 186 + } 187 + 188 + #[async_trait::async_trait] 189 + impl IdentityResolver for StubIdentityResolver { 190 + async fn resolve(&self, subject: &str) -> Result<Document> { 191 + if !self.expected.is_empty() { 192 + assert_eq!(self.expected, subject); 193 + } 194 + Ok(self.document.clone()) 195 + } 196 + } 197 + 198 + #[tokio::test] 199 + async fn resolves_direct_did_key() -> Result<()> { 200 + let private_key = generate_key(KeyType::K256Private)?; 201 + let public_key = to_public(&private_key)?; 202 + let key_reference = format!("{}", &public_key); 203 + 204 + let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 205 + expected: String::new(), 206 + document: Document::builder() 207 + .id("did:plc:placeholder") 208 + .build() 209 + .unwrap(), 210 + })); 211 + 212 + let key_data = resolver.resolve(&key_reference).await?; 213 + assert_eq!(key_data.bytes(), public_key.bytes()); 214 + Ok(()) 215 + } 216 + 217 + #[tokio::test] 218 + async fn resolves_literal_did_key_reference() -> Result<()> { 219 + let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 220 + expected: String::new(), 221 + document: Document::builder() 222 + .id("did:example:unused".to_string()) 223 + .build() 224 + .unwrap(), 225 + })); 226 + 227 + let sample = "did:key:zDnaezRmyM3NKx9NCphGiDFNBEMyR2sTZhhMGTseXCU2iXn53"; 228 + let expected = identify_key(sample)?; 229 + let resolved = resolver.resolve(sample).await?; 230 + assert_eq!(resolved.bytes(), expected.bytes()); 231 + Ok(()) 232 + } 233 + 234 + #[tokio::test] 235 + async fn resolves_via_identity_document() -> Result<()> { 236 + let private_key = generate_key(KeyType::P256Private)?; 237 + let public_key = to_public(&private_key)?; 238 + let public_key_multibase = format!("{}", &public_key) 239 + .strip_prefix("did:key:") 240 + .unwrap() 241 + .to_string(); 242 + 243 + let did = "did:web:example.com"; 244 + let method_id = format!("{did}#atproto"); 245 + 246 + let document = DocumentBuilder::new() 247 + .id(did.to_string()) 248 + .add_verification_method(VerificationMethod::Multikey { 249 + id: method_id.clone(), 250 + controller: did.to_string(), 251 + public_key_multibase, 252 + extra: HashMap::new(), 253 + }) 254 + .build() 255 + .unwrap(); 256 + 257 + let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 258 + expected: did.to_string(), 259 + document, 260 + })); 261 + 262 + let key_data = resolver.resolve(&method_id).await?; 263 + assert_eq!(key_data.bytes(), public_key.bytes()); 264 + Ok(()) 199 265 } 200 266 } 201 267
-212
crates/atproto-identity/src/storage.rs
··· 1 - //! DID document storage abstraction. 2 - //! 3 - //! Storage trait for DID document CRUD operations supporting multiple 4 - //! backends (database, file system, memory) with consistent interface. 5 - 6 - use anyhow::Result; 7 - 8 - use crate::model::Document; 9 - 10 - /// Trait for implementing DID document CRUD operations across different storage backends. 11 - /// 12 - /// This trait provides an abstraction layer for storing and retrieving DID documents, 13 - /// allowing different implementations for various storage systems such as databases, file systems, 14 - /// in-memory stores, or cloud storage services. 15 - /// 16 - /// All methods return `anyhow::Result` to allow implementations to use their own error types 17 - /// while providing a consistent interface for callers. Implementations should handle their 18 - /// specific error conditions and convert them to appropriate error messages. 19 - /// 20 - /// ## Thread Safety 21 - /// 22 - /// This trait requires implementations to be thread-safe (`Send + Sync`), meaning: 23 - /// - `Send`: The storage implementation can be moved between threads 24 - /// - `Sync`: The storage implementation can be safely accessed from multiple threads simultaneously 25 - /// 26 - /// This is essential for async applications where the storage might be accessed from different 27 - /// async tasks running on different threads. Implementations should use appropriate 28 - /// synchronization primitives (like `Arc<Mutex<>>`, `RwLock`, or database connection pools) 29 - /// to ensure thread safety. 30 - /// 31 - /// ## Usage 32 - /// 33 - /// Implementors of this trait can provide storage for AT Protocol DID documents in any backend: 34 - /// 35 - /// ```rust,ignore 36 - /// use atproto_identity::storage::DidDocumentStorage; 37 - /// use atproto_identity::model::Document; 38 - /// use anyhow::Result; 39 - /// use std::sync::Arc; 40 - /// use tokio::sync::RwLock; 41 - /// use std::collections::HashMap; 42 - /// 43 - /// // Thread-safe in-memory storage using Arc<RwLock<>> 44 - /// #[derive(Clone)] 45 - /// struct InMemoryStorage { 46 - /// data: Arc<RwLock<HashMap<String, Document>>>, // DID -> Document mapping 47 - /// } 48 - /// 49 - /// #[async_trait::async_trait] 50 - /// impl DidDocumentStorage for InMemoryStorage { 51 - /// async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> { 52 - /// let data = self.data.read().await; 53 - /// Ok(data.get(did).cloned()) 54 - /// } 55 - /// 56 - /// async fn store_document(&self, document: Document) -> Result<()> { 57 - /// let mut data = self.data.write().await; 58 - /// data.insert(document.id.clone(), document); 59 - /// Ok(()) 60 - /// } 61 - /// 62 - /// async fn delete_document_by_did(&self, did: &str) -> Result<()> { 63 - /// let mut data = self.data.write().await; 64 - /// data.remove(did); 65 - /// Ok(()) 66 - /// } 67 - /// } 68 - /// 69 - /// // Database storage with thread-safe connection pool 70 - /// struct DatabaseStorage { 71 - /// pool: sqlx::Pool<sqlx::Postgres>, // Thread-safe connection pool 72 - /// } 73 - /// 74 - /// #[async_trait::async_trait] 75 - /// impl DidDocumentStorage for DatabaseStorage { 76 - /// async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> { 77 - /// // Database connection pools are thread-safe 78 - /// let row: Option<(serde_json::Value,)> = sqlx::query_as( 79 - /// "SELECT document FROM did_documents WHERE did = $1" 80 - /// ) 81 - /// .bind(did) 82 - /// .fetch_optional(&self.pool) 83 - /// .await?; 84 - /// 85 - /// if let Some((doc_json,)) = row { 86 - /// let document: Document = serde_json::from_value(doc_json)?; 87 - /// Ok(Some(document)) 88 - /// } else { 89 - /// Ok(None) 90 - /// } 91 - /// } 92 - /// 93 - /// async fn store_document(&self, document: Document) -> Result<()> { 94 - /// let doc_json = serde_json::to_value(&document)?; 95 - /// sqlx::query("INSERT INTO did_documents (did, document) VALUES ($1, $2) ON CONFLICT (did) DO UPDATE SET document = $2") 96 - /// .bind(&document.id) 97 - /// .bind(doc_json) 98 - /// .execute(&self.pool) 99 - /// .await?; 100 - /// Ok(()) 101 - /// } 102 - /// 103 - /// async fn delete_document_by_did(&self, did: &str) -> Result<()> { 104 - /// sqlx::query("DELETE FROM did_documents WHERE did = $1") 105 - /// .bind(did) 106 - /// .execute(&self.pool) 107 - /// .await?; 108 - /// Ok(()) 109 - /// } 110 - /// } 111 - /// ``` 112 - #[async_trait::async_trait] 113 - pub trait DidDocumentStorage: Send + Sync { 114 - /// Retrieves a DID document associated with the given DID. 115 - /// 116 - /// This method looks up the complete DID document that is currently stored for the provided 117 - /// DID (Decentralized Identifier). The document contains services, verification methods, 118 - /// and other identity information for the DID. 119 - /// 120 - /// # Arguments 121 - /// * `did` - The DID (Decentralized Identifier) to look up. Should be in the format 122 - /// `did:method:identifier` (e.g., "did:plc:bv6ggog3tya2z3vxsub7hnal") 123 - /// 124 - /// # Returns 125 - /// * `Ok(Some(document))` - If a document is found for the given DID 126 - /// * `Ok(None)` - If no document is currently stored for the DID 127 - /// * `Err(error)` - If an error occurs during retrieval (storage failure, invalid DID format, etc.) 128 - /// 129 - /// # Examples 130 - /// 131 - /// ```rust,ignore 132 - /// let storage = MyStorage::new(); 133 - /// let document = storage.get_document_by_did("did:plc:bv6ggog3tya2z3vxsub7hnal").await?; 134 - /// match document { 135 - /// Some(doc) => { 136 - /// println!("Found document for DID: {}", doc.id); 137 - /// if let Some(handle) = doc.handles() { 138 - /// println!("Primary handle: {}", handle); 139 - /// } 140 - /// }, 141 - /// None => println!("No document found for this DID"), 142 - /// } 143 - /// ``` 144 - async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>>; 145 - 146 - /// Stores or updates a DID document. 147 - /// 148 - /// This method creates a new DID document entry or updates an existing one. 149 - /// In the AT Protocol ecosystem, this operation typically occurs when a DID document 150 - /// is resolved from the network, updated by the identity owner, or cached for performance. 151 - /// 152 - /// Implementations should ensure that: 153 - /// - The document's DID (`document.id`) is used as the key for storage 154 - /// - The operation is atomic (either fully succeeds or fully fails) 155 - /// - Any existing document for the same DID is properly replaced 156 - /// - The complete document structure is preserved 157 - /// 158 - /// # Arguments 159 - /// * `document` - The complete DID document to store. The document's `id` field 160 - /// will be used as the storage key. 161 - /// 162 - /// # Returns 163 - /// * `Ok(())` - If the document was successfully stored or updated 164 - /// * `Err(error)` - If an error occurs during the operation (storage failure, 165 - /// serialization failure, constraint violation, etc.) 166 - /// 167 - /// # Examples 168 - /// 169 - /// ```rust,ignore 170 - /// let storage = MyStorage::new(); 171 - /// let document = Document { 172 - /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(), 173 - /// also_known_as: vec!["at://alice.bsky.social".to_string()], 174 - /// service: vec![/* services */], 175 - /// verification_method: vec![/* verification methods */], 176 - /// extra: HashMap::new(), 177 - /// }; 178 - /// storage.store_document(document).await?; 179 - /// println!("Document successfully stored"); 180 - /// ``` 181 - async fn store_document(&self, document: Document) -> Result<()>; 182 - 183 - /// Deletes a DID document by its DID. 184 - /// 185 - /// This method removes a DID document from storage using the DID as the identifier. 186 - /// This operation is typically used when cleaning up expired cache entries, removing 187 - /// invalid documents, or when an identity is deactivated. 188 - /// 189 - /// Implementations should: 190 - /// - Handle the case where the DID doesn't exist gracefully (return Ok(())) 191 - /// - Ensure the deletion is atomic 192 - /// - Clean up any related data or indexes 193 - /// - Preserve referential integrity if applicable 194 - /// 195 - /// # Arguments 196 - /// * `did` - The DID identifying the document to delete. 197 - /// Should be in the format `did:method:identifier` 198 - /// (e.g., "did:plc:bv6ggog3tya2z3vxsub7hnal") 199 - /// 200 - /// # Returns 201 - /// * `Ok(())` - If the document was successfully deleted or didn't exist 202 - /// * `Err(error)` - If an error occurs during deletion (storage failure, etc.) 203 - /// 204 - /// # Examples 205 - /// 206 - /// ```rust,ignore 207 - /// let storage = MyStorage::new(); 208 - /// storage.delete_document_by_did("did:plc:bv6ggog3tya2z3vxsub7hnal").await?; 209 - /// println!("Document deleted"); 210 - /// ``` 211 - async fn delete_document_by_did(&self, did: &str) -> Result<()>; 212 - }
+8 -7
crates/atproto-identity/src/storage_lru.rs
··· 11 11 12 12 use crate::errors::StorageError; 13 13 use crate::model::Document; 14 - use crate::storage::DidDocumentStorage; 14 + use crate::traits::DidDocumentStorage; 15 15 16 16 /// An LRU-based implementation of `DidDocumentStorage` that maintains a fixed-size cache of DID documents. 17 17 /// ··· 54 54 /// 55 55 /// ```rust 56 56 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 57 - /// use atproto_identity::storage::DidDocumentStorage; 57 + /// use atproto_identity::traits::DidDocumentStorage; 58 58 /// use atproto_identity::model::Document; 59 59 /// use std::num::NonZeroUsize; 60 60 /// use std::collections::HashMap; ··· 164 164 /// 165 165 /// ```rust 166 166 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 167 - /// use atproto_identity::storage::DidDocumentStorage; 167 + /// use atproto_identity::traits::DidDocumentStorage; 168 168 /// use atproto_identity::model::Document; 169 169 /// use std::num::NonZeroUsize; 170 170 /// use std::collections::HashMap; ··· 251 251 /// 252 252 /// ```rust 253 253 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 254 - /// use atproto_identity::storage::DidDocumentStorage; 254 + /// use atproto_identity::traits::DidDocumentStorage; 255 255 /// use atproto_identity::model::Document; 256 256 /// use std::num::NonZeroUsize; 257 257 /// use std::collections::HashMap; ··· 305 305 /// 306 306 /// ```rust 307 307 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 308 - /// use atproto_identity::storage::DidDocumentStorage; 308 + /// use atproto_identity::traits::DidDocumentStorage; 309 309 /// use atproto_identity::model::Document; 310 310 /// use std::num::NonZeroUsize; 311 311 /// use std::collections::HashMap; ··· 370 370 /// 371 371 /// ```rust 372 372 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 373 - /// use atproto_identity::storage::DidDocumentStorage; 373 + /// use atproto_identity::traits::DidDocumentStorage; 374 374 /// use atproto_identity::model::Document; 375 375 /// use std::num::NonZeroUsize; 376 376 /// use std::collections::HashMap; ··· 460 460 /// 461 461 /// ```rust 462 462 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 463 - /// use atproto_identity::storage::DidDocumentStorage; 463 + /// use atproto_identity::traits::DidDocumentStorage; 464 464 /// use atproto_identity::model::Document; 465 465 /// use std::num::NonZeroUsize; 466 466 /// use std::collections::HashMap; ··· 507 507 #[cfg(test)] 508 508 mod tests { 509 509 use super::*; 510 + use crate::traits::DidDocumentStorage; 510 511 use std::collections::HashMap; 511 512 use std::num::NonZeroUsize; 512 513
+49
crates/atproto-identity/src/traits.rs
··· 1 + //! Shared trait definitions for AT Protocol identity operations. 2 + //! 3 + //! This module centralizes async traits used across the identity crate so they can 4 + //! be implemented without introducing circular module dependencies. 5 + 6 + use anyhow::Result; 7 + use async_trait::async_trait; 8 + 9 + use crate::errors::ResolveError; 10 + use crate::key::KeyData; 11 + use crate::model::Document; 12 + 13 + /// Trait for AT Protocol identity resolution. 14 + /// 15 + /// Implementations must resolve handles or DIDs to canonical DID documents. 16 + #[async_trait] 17 + pub trait IdentityResolver: Send + Sync { 18 + /// Resolves an AT Protocol subject to its DID document. 19 + async fn resolve(&self, subject: &str) -> Result<Document>; 20 + } 21 + 22 + /// Trait for DNS resolution operations used during handle lookups. 23 + #[async_trait] 24 + pub trait DnsResolver: Send + Sync { 25 + /// Resolves TXT records for a given domain name. 26 + async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>; 27 + } 28 + 29 + /// Trait for retrieving private keys by identifier. 30 + #[async_trait] 31 + /// Trait for resolving key references (e.g., DID verification methods) to [`KeyData`]. 32 + #[async_trait] 33 + pub trait KeyResolver: Send + Sync { 34 + /// Resolves a key reference string into key material. 35 + async fn resolve(&self, key: &str) -> Result<KeyData>; 36 + } 37 + 38 + /// Trait for DID document storage backends. 39 + #[async_trait] 40 + pub trait DidDocumentStorage: Send + Sync { 41 + /// Retrieves a DID document if present. 42 + async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>>; 43 + 44 + /// Stores or updates a DID document. 45 + async fn store_document(&self, document: Document) -> Result<()>; 46 + 47 + /// Deletes a DID document by DID. 48 + async fn delete_document_by_did(&self, did: &str) -> Result<()>; 49 + }
+48 -119
crates/atproto-identity/src/url.rs
··· 1 - //! URL construction utilities for HTTP endpoints. 1 + //! URL construction utilities leveraging the `url` crate. 2 2 //! 3 - //! Build well-formed HTTP request URLs with parameter encoding 4 - //! and query string generation. 5 - 6 - /// A single query parameter as a key-value pair. 7 - pub type QueryParam<'a> = (&'a str, &'a str); 8 - /// A collection of query parameters. 9 - pub type QueryParams<'a> = Vec<QueryParam<'a>>; 10 - 11 - /// Builds a query string from a collection of query parameters. 12 - /// 13 - /// # Arguments 14 - /// 15 - /// * `query` - Collection of key-value pairs to build into a query string 16 - /// 17 - /// # Returns 18 - /// 19 - /// A formatted query string with URL-encoded parameters 20 - pub fn build_querystring(query: QueryParams) -> String { 21 - query.iter().fold(String::new(), |acc, &tuple| { 22 - acc + tuple.0 + "=" + tuple.1 + "&" 23 - }) 24 - } 3 + //! Provides helpers for building URLs and appending query parameters 4 + //! without manual string concatenation. 25 5 26 - /// Builder for constructing URLs with host, path, and query parameters. 27 - pub struct URLBuilder { 28 - host: String, 29 - path: String, 30 - params: Vec<(String, String)>, 31 - } 6 + use url::{ParseError, Url}; 32 7 33 - /// Convenience function to build a URL with optional parameters. 34 - /// 35 - /// # Arguments 36 - /// 37 - /// * `host` - The hostname (will be prefixed with https:// if needed) 38 - /// * `path` - The URL path 39 - /// * `params` - Vector of optional key-value pairs for query parameters 40 - /// 41 - /// # Returns 42 - /// 43 - /// A fully constructed URL string 44 - pub fn build_url(host: &str, path: &str, params: Vec<Option<(&str, &str)>>) -> String { 45 - let mut url_builder = URLBuilder::new(host); 46 - url_builder.path(path); 8 + /// Builds a URL from the provided components. 9 + /// Returns `Result<Url, ParseError>` to surface parsing errors. 10 + pub fn build_url<K, V, I>(host: &str, path: &str, params: I) -> Result<Url, ParseError> 11 + where 12 + I: IntoIterator<Item = (K, V)>, 13 + K: AsRef<str>, 14 + V: AsRef<str>, 15 + { 16 + let mut base = if host.starts_with("http://") || host.starts_with("https://") { 17 + Url::parse(host)? 18 + } else { 19 + Url::parse(&format!("https://{}", host))? 20 + }; 47 21 48 - for (key, value) in params.iter().filter_map(|x| *x) { 49 - url_builder.param(key, value); 22 + if !base.path().ends_with('/') { 23 + let mut new_path = base.path().to_string(); 24 + if !new_path.ends_with('/') { 25 + new_path.push('/'); 26 + } 27 + if new_path.is_empty() { 28 + new_path.push('/'); 29 + } 30 + base.set_path(&new_path); 50 31 } 51 32 52 - url_builder.build() 53 - } 54 - 55 - impl URLBuilder { 56 - /// Creates a new URLBuilder with the specified host. 57 - /// 58 - /// # Arguments 59 - /// 60 - /// * `host` - The hostname (will be prefixed with https:// if needed and trailing slash removed) 61 - /// 62 - /// # Returns 63 - /// 64 - /// A new URLBuilder instance 65 - pub fn new(host: &str) -> URLBuilder { 66 - let host = if host.starts_with("https://") { 67 - host.to_string() 68 - } else { 69 - format!("https://{}", host) 70 - }; 71 - 72 - let host = if let Some(trimmed) = host.strip_suffix('/') { 73 - trimmed.to_string() 74 - } else { 75 - host 76 - }; 77 - 78 - URLBuilder { 79 - host: host.to_string(), 80 - params: vec![], 81 - path: "/".to_string(), 33 + let mut url = base.join(path.trim_start_matches('/'))?; 34 + { 35 + let mut pairs = url.query_pairs_mut(); 36 + for (key, value) in params { 37 + pairs.append_pair(key.as_ref(), value.as_ref()); 82 38 } 83 39 } 40 + Ok(url) 41 + } 84 42 85 - /// Adds a query parameter to the URL. 86 - /// 87 - /// # Arguments 88 - /// 89 - /// * `key` - The parameter key 90 - /// * `value` - The parameter value (will be URL-encoded) 91 - /// 92 - /// # Returns 93 - /// 94 - /// A mutable reference to self for method chaining 95 - pub fn param(&mut self, key: &str, value: &str) -> &mut Self { 96 - self.params 97 - .push((key.to_owned(), urlencoding::encode(value).to_string())); 98 - self 99 - } 43 + #[cfg(test)] 44 + mod tests { 45 + use super::*; 100 46 101 - /// Sets the URL path. 102 - /// 103 - /// # Arguments 104 - /// 105 - /// * `path` - The URL path 106 - /// 107 - /// # Returns 108 - /// 109 - /// A mutable reference to self for method chaining 110 - pub fn path(&mut self, path: &str) -> &mut Self { 111 - path.clone_into(&mut self.path); 112 - self 113 - } 47 + #[test] 48 + fn builds_url_with_params() { 49 + let url = build_url( 50 + "example.com/api", 51 + "resource", 52 + [("id", "123"), ("status", "active")], 53 + ) 54 + .expect("url build failed"); 114 55 115 - /// Constructs the final URL string. 116 - /// 117 - /// # Returns 118 - /// 119 - /// The complete URL with host, path, and query parameters 120 - pub fn build(self) -> String { 121 - let mut url_params = String::new(); 122 - 123 - if !self.params.is_empty() { 124 - url_params.push('?'); 125 - 126 - let qs_args = self.params.iter().map(|(k, v)| (&**k, &**v)).collect(); 127 - url_params.push_str(build_querystring(qs_args).as_str()); 128 - } 129 - 130 - format!("{}{}{}", self.host, self.path, url_params) 56 + assert_eq!( 57 + url.as_str(), 58 + "https://example.com/api/resource?id=123&status=active" 59 + ); 131 60 } 132 61 }
+17 -11
crates/atproto-oauth-aip/src/workflow.rs
··· 112 112 //! and protocol violations. 113 113 114 114 use anyhow::Result; 115 - use atproto_identity::url::URLBuilder; 115 + use atproto_identity::url::build_url; 116 116 use atproto_oauth::{ 117 117 jwk::WrappedJsonWebKey, 118 118 workflow::{OAuthRequest, OAuthRequestState, ParResponse, TokenResponse}, 119 119 }; 120 120 use serde::Deserialize; 121 + use std::iter; 121 122 122 123 use crate::errors::OAuthWorkflowError; 123 124 ··· 522 523 access_token_type: &Option<&str>, 523 524 subject: &Option<&str>, 524 525 ) -> Result<ATProtocolSession> { 525 - let mut url_builder = URLBuilder::new(protected_resource_base); 526 - url_builder.path("/api/atprotocol/session"); 526 + let mut url = build_url( 527 + protected_resource_base, 528 + "/api/atprotocol/session", 529 + iter::empty::<(&str, &str)>(), 530 + )?; 531 + { 532 + let mut pairs = url.query_pairs_mut(); 533 + if let Some(value) = access_token_type { 534 + pairs.append_pair("access_token_type", value); 535 + } 527 536 528 - if let Some(value) = access_token_type { 529 - url_builder.param("access_token_type", value); 530 - } 531 - 532 - if let Some(value) = subject { 533 - url_builder.param("sub", value); 537 + if let Some(value) = subject { 538 + pairs.append_pair("sub", value); 539 + } 534 540 } 535 541 536 - let url = url_builder.build(); 542 + let url: String = url.into(); 537 543 538 544 let response = http_client 539 - .get(url) 545 + .get(&url) 540 546 .bearer_auth(access_token) 541 547 .send() 542 548 .await
+15 -12
crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs
··· 30 30 use async_trait::async_trait; 31 31 use atproto_identity::{ 32 32 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 33 - key::{KeyData, KeyProvider, KeyType, generate_key, identify_key, to_public}, 34 - storage::DidDocumentStorage, 33 + key::{KeyData, KeyResolver, KeyType, generate_key, identify_key, to_public}, 35 34 storage_lru::LruDidDocumentStorage, 35 + traits::DidDocumentStorage, 36 36 }; 37 37 38 38 #[cfg(feature = "hickory-dns")] ··· 66 66 }; 67 67 68 68 #[derive(Clone)] 69 - pub struct SimpleKeyProvider { 69 + pub struct SimpleKeyResolver { 70 70 keys: HashMap<String, KeyData>, 71 71 } 72 72 73 - impl Default for SimpleKeyProvider { 73 + impl Default for SimpleKeyResolver { 74 74 fn default() -> Self { 75 75 Self::new() 76 76 } 77 77 } 78 78 79 - impl SimpleKeyProvider { 79 + impl SimpleKeyResolver { 80 80 pub fn new() -> Self { 81 81 Self { 82 82 keys: HashMap::new(), ··· 85 85 } 86 86 87 87 #[async_trait] 88 - impl KeyProvider for SimpleKeyProvider { 89 - async fn get_private_key_by_id(&self, key_id: &str) -> anyhow::Result<Option<KeyData>> { 90 - Ok(self.keys.get(key_id).cloned()) 88 + impl KeyResolver for SimpleKeyResolver { 89 + async fn resolve(&self, key_id: &str) -> anyhow::Result<KeyData> { 90 + self.keys 91 + .get(key_id) 92 + .cloned() 93 + .ok_or_else(|| anyhow::anyhow!("Key not found: {}", key_id)) 91 94 } 92 95 } 93 96 ··· 97 100 pub oauth_client_config: OAuthClientConfig, 98 101 pub oauth_storage: Arc<dyn OAuthRequestStorage + Send + Sync>, 99 102 pub document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 100 - pub key_provider: Arc<dyn KeyProvider + Send + Sync>, 103 + pub key_resolver: Arc<dyn KeyResolver + Send + Sync>, 101 104 } 102 105 103 106 #[derive(Clone, FromRef)] ··· 135 138 } 136 139 } 137 140 138 - impl FromRef<WebContext> for Arc<dyn KeyProvider> { 141 + impl FromRef<WebContext> for Arc<dyn KeyResolver> { 139 142 fn from_ref(context: &WebContext) -> Self { 140 - context.0.key_provider.clone() 143 + context.0.key_resolver.clone() 141 144 } 142 145 } 143 146 ··· 305 308 oauth_client_config: oauth_client_config.clone(), 306 309 oauth_storage: Arc::new(LruOAuthRequestStorage::new(NonZeroUsize::new(256).unwrap())), 307 310 document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())), 308 - key_provider: Arc::new(SimpleKeyProvider { 311 + key_resolver: Arc::new(SimpleKeyResolver { 309 312 keys: signing_key_storage, 310 313 }), 311 314 }));
+7 -9
crates/atproto-oauth-axum/src/handle_complete.rs
··· 7 7 8 8 use anyhow::Result; 9 9 use atproto_identity::{ 10 - key::{KeyProvider, identify_key}, 11 - storage::DidDocumentStorage, 10 + key::{KeyResolver, identify_key}, 11 + traits::DidDocumentStorage, 12 12 }; 13 13 use atproto_oauth::{ 14 14 resources::pds_resources, ··· 61 61 client: HttpClient, 62 62 oauth_request_storage: State<Arc<dyn OAuthRequestStorage>>, 63 63 did_document_storage: State<Arc<dyn DidDocumentStorage>>, 64 - key_provider: State<Arc<dyn KeyProvider>>, 64 + key_resolver: State<Arc<dyn KeyResolver>>, 65 65 Form(callback_form): Form<OAuthCallbackForm>, 66 66 ) -> Result<impl IntoResponse, OAuthCallbackError> { 67 67 let oauth_request = oauth_request_storage ··· 77 77 }); 78 78 } 79 79 80 - let private_signing_key_data = key_provider 81 - .get_private_key_by_id(&oauth_request.signing_public_key) 82 - .await?; 83 - 84 - let private_signing_key_data = 85 - private_signing_key_data.ok_or(OAuthCallbackError::NoSigningKeyFound)?; 80 + let private_signing_key_data = key_resolver 81 + .resolve(&oauth_request.signing_public_key) 82 + .await 83 + .map_err(|_| OAuthCallbackError::NoSigningKeyFound)?; 86 84 87 85 let private_dpop_key_data = identify_key(&oauth_request.dpop_private_key)?; 88 86
+2 -2
crates/atproto-oauth/src/dpop.rs
··· 183 183 /// * `false` if no DPoP error is found or the header format is invalid 184 184 /// 185 185 /// # Examples 186 - /// ``` 186 + /// ```no_run 187 187 /// use atproto_oauth::dpop::is_dpop_error; 188 188 /// 189 189 /// // Valid DPoP error: invalid_dpop_proof ··· 516 516 /// - HTTP method or URI don't match expected values 517 517 /// 518 518 /// # Examples 519 - /// ``` 519 + /// ```no_run 520 520 /// use atproto_oauth::dpop::{validate_dpop_jwt, DpopValidationConfig}; 521 521 /// 522 522 /// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
+5 -14
crates/atproto-record/Cargo.toml
··· 14 14 keywords.workspace = true 15 15 categories.workspace = true 16 16 17 - [[bin]] 18 - name = "atproto-record-sign" 19 - test = false 20 - bench = false 21 - doc = true 22 - required-features = ["clap", "tokio"] 23 - 24 - [[bin]] 25 - name = "atproto-record-verify" 26 - test = false 27 - bench = false 28 - doc = true 29 - required-features = ["clap", "tokio"] 30 - 31 17 [[bin]] 32 18 name = "atproto-record-cid" 33 19 test = false ··· 40 26 41 27 anyhow.workspace = true 42 28 base64.workspace = true 29 + rand.workspace = true 43 30 serde_ipld_dagcbor.workspace = true 44 31 serde_json.workspace = true 45 32 serde.workspace = true ··· 51 38 cid = "0.11" 52 39 multihash = "0.19" 53 40 sha2 = { workspace = true } 41 + 42 + [dev-dependencies] 43 + async-trait = "0.1" 44 + tokio = { workspace = true, features = ["macros", "rt"] } 54 45 55 46 [features] 56 47 default = ["hickory-dns"]
+51 -68
crates/atproto-record/README.md
··· 1 1 # atproto-record 2 2 3 - Cryptographic signature operations and utilities for AT Protocol records. 3 + Utilities for working with AT Protocol records. 4 4 5 5 ## Overview 6 6 7 - 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. 7 + 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. 8 8 9 9 ## Features 10 10 11 - - **Record signing**: Create cryptographic signatures on AT Protocol records following community.lexicon.attestation.signature specification 12 - - **Signature verification**: Verify record signatures against public keys with issuer validation 13 11 - **AT-URI parsing**: Parse and validate AT Protocol URIs (at://authority/collection/record_key) with robust error handling 14 - - **IPLD serialization**: DAG-CBOR serialization ensuring deterministic and verifiable record encoding 15 - - **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curve signatures 12 + - **TID generation**: Timestamp-based identifiers for AT Protocol records with microsecond precision 13 + - **CID generation**: Content Identifier generation using DAG-CBOR serialization and SHA-256 hashing 16 14 - **DateTime utilities**: RFC 3339 datetime serialization with millisecond precision for consistent timestamp handling 15 + - **Typed records**: Type-safe record handling with lexicon type validation 16 + - **Bytes handling**: Base64 encoding/decoding for binary data in AT Protocol records 17 17 - **Structured errors**: Type-safe error handling following project conventions with detailed error messages 18 18 19 19 ## CLI Tools 20 20 21 - The following command-line tools are available when built with the `clap` feature: 21 + The following command-line tool is available when built with the `clap` feature: 22 22 23 - - **`atproto-record-sign`**: Sign AT Protocol records with private keys, supporting flexible argument ordering 24 - - **`atproto-record-verify`**: Verify AT Protocol record signatures by validating cryptographic signatures against issuer DIDs and public keys 23 + - **`atproto-record-cid`**: Generate CID (Content Identifier) for AT Protocol records from JSON input 25 24 26 25 ## Library Usage 27 26 28 - ### Creating Signatures 27 + ### Generating CIDs 29 28 30 29 ```rust 31 - use atproto_record::signature; 32 - use atproto_identity::key::identify_key; 33 30 use serde_json::json; 34 - 35 - // Parse the signing key from a did:key 36 - let key_data = identify_key("did:key:zQ3sh...")?; 37 - 38 - // The record to sign 39 - let record = json!({"$type": "app.bsky.feed.post", "text": "Hello world!"}); 31 + use cid::Cid; 32 + use sha2::{Digest, Sha256}; 33 + use multihash::Multihash; 40 34 41 - // Signature metadata (issuer is required, other fields are optional) 42 - let signature_object = json!({ 43 - "issuer": "did:plc:issuer" 44 - // Optional: "issuedAt", "purpose", "expiry", etc. 35 + // Serialize a record to DAG-CBOR and generate its CID 36 + let record = json!({ 37 + "$type": "app.bsky.feed.post", 38 + "text": "Hello world!", 39 + "createdAt": "2024-01-01T00:00:00.000Z" 45 40 }); 46 41 47 - // Create the signed record with embedded signatures array 48 - let signed_record = signature::create( 49 - &key_data, 50 - &record, 51 - "did:plc:repository", 52 - "app.bsky.feed.post", 53 - signature_object 54 - ).await?; 42 + let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(&record)?; 43 + let hash = Sha256::digest(&dag_cbor_bytes); 44 + let multihash = Multihash::wrap(0x12, &hash)?; 45 + let cid = Cid::new_v1(0x71, multihash); 46 + 47 + println!("Record CID: {}", cid); 55 48 ``` 56 49 57 - ### Verifying Signatures 50 + ### Generating TIDs 58 51 59 52 ```rust 60 - use atproto_record::signature; 61 - use atproto_identity::key::identify_key; 53 + use atproto_record::tid::Tid; 62 54 63 - // Parse the public key for verification 64 - let issuer_key = identify_key("did:key:zQ3sh...")?; 55 + // Generate a new timestamp-based identifier 56 + let tid = Tid::new(); 57 + println!("TID: {}", tid); // e.g., "3l2k4j5h6g7f8d9s" 65 58 66 - // Verify the signature (throws error if invalid) 67 - signature::verify( 68 - "did:plc:issuer", // Expected issuer DID 69 - &issuer_key, // Public key for verification 70 - signed_record, // The signed record 71 - "did:plc:repository", // Repository context 72 - "app.bsky.feed.post" // Collection context 73 - ).await?; 59 + // TIDs are sortable by creation time 60 + let tid1 = Tid::new(); 61 + std::thread::sleep(std::time::Duration::from_millis(1)); 62 + let tid2 = Tid::new(); 63 + assert!(tid1 < tid2); 74 64 ``` 75 65 76 66 ### AT-URI Parsing ··· 110 100 111 101 ## Command Line Usage 112 102 113 - All CLI tools require the `clap` feature: 103 + The CLI tool requires the `clap` feature: 114 104 115 105 ```bash 116 106 # Build with CLI support 117 107 cargo build --features clap --bins 118 108 119 - # Sign a record 120 - cargo run --features clap --bin atproto-record-sign -- \ 121 - did:key:zQ3sh... # Signing key (did:key format) 122 - did:plc:issuer # Issuer DID 123 - record.json # Record file (or use -- for stdin) 124 - repository=did:plc:repo # Repository context 125 - collection=app.bsky.feed.post # Collection type 109 + # Generate CID from JSON file 110 + cat record.json | cargo run --features clap --bin atproto-record-cid 126 111 127 - # Sign with custom fields (e.g., issuedAt, purpose, expiry) 128 - cargo run --features clap --bin atproto-record-sign -- \ 129 - did:key:zQ3sh... did:plc:issuer record.json \ 130 - repository=did:plc:repo collection=app.bsky.feed.post \ 131 - issuedAt="2024-01-01T00:00:00.000Z" purpose="attestation" 112 + # Generate CID from inline JSON 113 + echo '{"$type":"app.bsky.feed.post","text":"Hello!"}' | cargo run --features clap --bin atproto-record-cid 132 114 133 - # Verify a signature 134 - cargo run --features clap --bin atproto-record-verify -- \ 135 - did:plc:issuer # Expected issuer DID 136 - did:key:zQ3sh... # Verification key 137 - signed.json # Signed record file 138 - repository=did:plc:repo # Repository context (must match signing) 139 - collection=app.bsky.feed.post # Collection type (must match signing) 115 + # Example with a complete AT Protocol record 116 + cat <<EOF | cargo run --features clap --bin atproto-record-cid 117 + { 118 + "$type": "app.bsky.feed.post", 119 + "text": "Hello AT Protocol!", 120 + "createdAt": "2024-01-01T00:00:00.000Z" 121 + } 122 + EOF 123 + ``` 140 124 141 - # Read from stdin 142 - echo '{"text":"Hello"}' | cargo run --features clap --bin atproto-record-sign -- \ 143 - did:key:zQ3sh... did:plc:issuer -- \ 144 - repository=did:plc:repo collection=app.bsky.feed.post 125 + The tool outputs the CID in base32 format: 126 + ``` 127 + bafyreibjzlvhtyxnhbvvzl3gj4qmg2ufl2jbhh5qr3gvvxlm7ksf3qwxqq 145 128 ``` 146 129 147 130 ## License 148 131 149 - MIT License 132 + MIT License
-192
crates/atproto-record/src/bin/atproto-record-sign.rs
··· 1 - //! Command-line tool for signing AT Protocol records with cryptographic signatures. 2 - //! 3 - //! This tool creates cryptographic signatures on AT Protocol records using ECDSA 4 - //! signatures with IPLD DAG-CBOR serialization. It supports flexible argument 5 - //! ordering and customizable signature metadata. 6 - 7 - use anyhow::Result; 8 - use atproto_identity::{ 9 - key::{KeyData, identify_key}, 10 - resolve::{InputType, parse_input}, 11 - }; 12 - use atproto_record::errors::CliError; 13 - use atproto_record::signature::create; 14 - use clap::Parser; 15 - use serde_json::json; 16 - use std::{ 17 - collections::HashMap, 18 - fs, 19 - io::{self, Read}, 20 - }; 21 - 22 - /// AT Protocol Record Signing CLI 23 - #[derive(Parser)] 24 - #[command( 25 - name = "atproto-record-sign", 26 - version, 27 - about = "Sign AT Protocol records with cryptographic signatures", 28 - long_about = " 29 - A command-line tool for signing AT Protocol records using DID keys. Reads a JSON 30 - record from a file or stdin, applies a cryptographic signature, and outputs the 31 - signed record with embedded signature metadata. 32 - 33 - The tool accepts flexible argument ordering with DID keys, issuer DIDs, record 34 - inputs, and key=value parameters for repository, collection, and custom metadata. 35 - 36 - REQUIRED PARAMETERS: 37 - repository=<DID> Repository context for the signature 38 - collection=<name> Collection type context for the signature 39 - 40 - OPTIONAL PARAMETERS: 41 - Any additional key=value pairs are included in the signature metadata 42 - (e.g., issuedAt=<timestamp>, purpose=<string>, expiry=<timestamp>) 43 - 44 - EXAMPLES: 45 - # Basic usage: 46 - atproto-record-sign \\ 47 - did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\ 48 - ./post.json \\ 49 - did:plc:tgudj2fjm77pzkuawquqhsxm \\ 50 - repository=did:plc:4zutorghlchjxzgceklue4la \\ 51 - collection=app.bsky.feed.post 52 - 53 - # With custom metadata: 54 - atproto-record-sign \\ 55 - did:key:z42tv1pb3... ./post.json did:plc:issuer... \\ 56 - repository=did:plc:repo... collection=app.bsky.feed.post \\ 57 - issuedAt=\"2024-01-01T00:00:00.000Z\" purpose=\"attestation\" 58 - 59 - # Reading from stdin: 60 - echo '{\"text\":\"Hello!\"}' | atproto-record-sign \\ 61 - did:key:z42tv1pb3... -- did:plc:issuer... \\ 62 - repository=did:plc:repo... collection=app.bsky.feed.post 63 - 64 - SIGNATURE PROCESS: 65 - - Creates $sig object with repository, collection, and custom metadata 66 - - Serializes record using IPLD DAG-CBOR format 67 - - Generates ECDSA signatures using P-256, P-384, or K-256 curves 68 - - Embeds signatures with issuer and any provided metadata 69 - " 70 - )] 71 - struct Args { 72 - /// All arguments - flexible parsing handles DID keys, issuer DIDs, files, and key=value pairs 73 - args: Vec<String>, 74 - } 75 - #[tokio::main] 76 - async fn main() -> Result<()> { 77 - let args = Args::parse(); 78 - 79 - let arguments = args.args.into_iter(); 80 - 81 - let mut collection: Option<String> = None; 82 - let mut repository: Option<String> = None; 83 - let mut record: Option<serde_json::Value> = None; 84 - let mut issuer: Option<String> = None; 85 - let mut key_data: Option<KeyData> = None; 86 - let mut signature_extras: HashMap<String, String> = HashMap::default(); 87 - 88 - for argument in arguments { 89 - if let Some((key, value)) = argument.split_once("=") { 90 - match key { 91 - "collection" => { 92 - collection = Some(value.to_string()); 93 - } 94 - "repository" => { 95 - repository = Some(value.to_string()); 96 - } 97 - _ => { 98 - signature_extras.insert(key.to_string(), value.to_string()); 99 - } 100 - } 101 - } else if argument.starts_with("did:key:") { 102 - // Parse the did:key to extract key data for signing 103 - key_data = Some(identify_key(&argument)?); 104 - } else if argument.starts_with("did:") { 105 - match parse_input(&argument) { 106 - Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => { 107 - issuer = Some(did); 108 - } 109 - Ok(_) => { 110 - return Err(CliError::UnsupportedDidMethod { 111 - method: argument.clone(), 112 - } 113 - .into()); 114 - } 115 - Err(_) => { 116 - return Err(CliError::DidParseFailed { 117 - did: argument.clone(), 118 - } 119 - .into()); 120 - } 121 - } 122 - } else if argument == "--" { 123 - // Read record from stdin 124 - if record.is_none() { 125 - let mut stdin_content = String::new(); 126 - io::stdin() 127 - .read_to_string(&mut stdin_content) 128 - .map_err(|_| CliError::StdinReadFailed)?; 129 - record = Some( 130 - serde_json::from_str(&stdin_content) 131 - .map_err(|_| CliError::StdinJsonParseFailed)?, 132 - ); 133 - } else { 134 - return Err(CliError::UnexpectedArgument { 135 - argument: argument.clone(), 136 - } 137 - .into()); 138 - } 139 - } else { 140 - // Assume it's a file path to read the record from 141 - if record.is_none() { 142 - let file_content = 143 - fs::read_to_string(&argument).map_err(|_| CliError::FileReadFailed { 144 - path: argument.clone(), 145 - })?; 146 - record = Some(serde_json::from_str(&file_content).map_err(|_| { 147 - CliError::FileJsonParseFailed { 148 - path: argument.clone(), 149 - } 150 - })?); 151 - } else { 152 - return Err(CliError::UnexpectedArgument { 153 - argument: argument.clone(), 154 - } 155 - .into()); 156 - } 157 - } 158 - } 159 - 160 - let collection = collection.ok_or(CliError::MissingRequiredValue { 161 - name: "collection".to_string(), 162 - })?; 163 - let repository = repository.ok_or(CliError::MissingRequiredValue { 164 - name: "repository".to_string(), 165 - })?; 166 - let record = record.ok_or(CliError::MissingRequiredValue { 167 - name: "record".to_string(), 168 - })?; 169 - let issuer = issuer.ok_or(CliError::MissingRequiredValue { 170 - name: "issuer".to_string(), 171 - })?; 172 - let key_data = key_data.ok_or(CliError::MissingRequiredValue { 173 - name: "signing_key".to_string(), 174 - })?; 175 - 176 - // Write "issuer" key to signature_extras 177 - signature_extras.insert("issuer".to_string(), issuer); 178 - 179 - let signature_object = json!(signature_extras); 180 - let signed_record = create( 181 - &key_data, 182 - &record, 183 - &repository, 184 - &collection, 185 - signature_object, 186 - )?; 187 - 188 - let pretty_signed_record = serde_json::to_string_pretty(&signed_record); 189 - println!("{}", pretty_signed_record.unwrap()); 190 - 191 - Ok(()) 192 - }
-166
crates/atproto-record/src/bin/atproto-record-verify.rs
··· 1 - //! Command-line tool for verifying cryptographic signatures on AT Protocol records. 2 - //! 3 - //! This tool validates signatures on AT Protocol records by reconstructing the 4 - //! signed content and verifying ECDSA signatures against public keys. It ensures 5 - //! that records have valid signatures from specified issuers. 6 - 7 - use anyhow::Result; 8 - use atproto_identity::{ 9 - key::{KeyData, identify_key}, 10 - resolve::{InputType, parse_input}, 11 - }; 12 - use atproto_record::errors::CliError; 13 - use atproto_record::signature::verify; 14 - use clap::Parser; 15 - use std::{ 16 - fs, 17 - io::{self, Read}, 18 - }; 19 - 20 - /// AT Protocol Record Verification CLI 21 - #[derive(Parser)] 22 - #[command( 23 - name = "atproto-record-verify", 24 - version, 25 - about = "Verify cryptographic signatures of AT Protocol records", 26 - long_about = " 27 - A command-line tool for verifying cryptographic signatures of AT Protocol records. 28 - Reads a signed JSON record from a file or stdin, validates the embedded signatures 29 - using a public key, and reports verification success or failure. 30 - 31 - The tool accepts flexible argument ordering with issuer DIDs, verification keys, 32 - record inputs, and key=value parameters for repository and collection context. 33 - 34 - REQUIRED PARAMETERS: 35 - repository=<DID> Repository context used during signing 36 - collection=<name> Collection type context used during signing 37 - 38 - EXAMPLES: 39 - # Basic verification: 40 - atproto-record-verify \\ 41 - did:plc:tgudj2fjm77pzkuawquqhsxm \\ 42 - did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\ 43 - ./signed_post.json \\ 44 - repository=did:plc:4zutorghlchjxzgceklue4la \\ 45 - collection=app.bsky.feed.post 46 - 47 - # Verify from stdin: 48 - echo '{\"signatures\":[...]}' | atproto-record-verify \\ 49 - did:plc:issuer... did:key:z42tv1pb3... -- \\ 50 - repository=did:plc:repo... collection=app.bsky.feed.post 51 - 52 - VERIFICATION PROCESS: 53 - - Extracts signatures from the signatures array 54 - - Finds signatures matching the specified issuer DID 55 - - Reconstructs $sig object with repository and collection context 56 - - Validates ECDSA signatures using P-256 or K-256 curves 57 - " 58 - )] 59 - struct Args { 60 - /// All arguments - flexible parsing handles issuer DIDs, verification keys, files, and key=value pairs 61 - args: Vec<String>, 62 - } 63 - #[tokio::main] 64 - async fn main() -> Result<()> { 65 - let args = Args::parse(); 66 - 67 - let arguments = args.args.into_iter(); 68 - 69 - let mut collection: Option<String> = None; 70 - let mut repository: Option<String> = None; 71 - let mut record: Option<serde_json::Value> = None; 72 - let mut issuer: Option<String> = None; 73 - let mut key_data: Option<KeyData> = None; 74 - 75 - for argument in arguments { 76 - if let Some((key, value)) = argument.split_once("=") { 77 - match key { 78 - "collection" => { 79 - collection = Some(value.to_string()); 80 - } 81 - "repository" => { 82 - repository = Some(value.to_string()); 83 - } 84 - _ => {} 85 - } 86 - } else if argument.starts_with("did:key:") { 87 - // Parse the did:key to extract key data for verification 88 - key_data = Some(identify_key(&argument)?); 89 - } else if argument.starts_with("did:") { 90 - match parse_input(&argument) { 91 - Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => { 92 - issuer = Some(did); 93 - } 94 - Ok(_) => { 95 - return Err(CliError::UnsupportedDidMethod { 96 - method: argument.clone(), 97 - } 98 - .into()); 99 - } 100 - Err(_) => { 101 - return Err(CliError::DidParseFailed { 102 - did: argument.clone(), 103 - } 104 - .into()); 105 - } 106 - } 107 - } else if argument == "--" { 108 - // Read record from stdin 109 - if record.is_none() { 110 - let mut stdin_content = String::new(); 111 - io::stdin() 112 - .read_to_string(&mut stdin_content) 113 - .map_err(|_| CliError::StdinReadFailed)?; 114 - record = Some( 115 - serde_json::from_str(&stdin_content) 116 - .map_err(|_| CliError::StdinJsonParseFailed)?, 117 - ); 118 - } else { 119 - return Err(CliError::UnexpectedArgument { 120 - argument: argument.clone(), 121 - } 122 - .into()); 123 - } 124 - } else { 125 - // Assume it's a file path to read the record from 126 - if record.is_none() { 127 - let file_content = 128 - fs::read_to_string(&argument).map_err(|_| CliError::FileReadFailed { 129 - path: argument.clone(), 130 - })?; 131 - record = Some(serde_json::from_str(&file_content).map_err(|_| { 132 - CliError::FileJsonParseFailed { 133 - path: argument.clone(), 134 - } 135 - })?); 136 - } else { 137 - return Err(CliError::UnexpectedArgument { 138 - argument: argument.clone(), 139 - } 140 - .into()); 141 - } 142 - } 143 - } 144 - 145 - let collection = collection.ok_or(CliError::MissingRequiredValue { 146 - name: "collection".to_string(), 147 - })?; 148 - let repository = repository.ok_or(CliError::MissingRequiredValue { 149 - name: "repository".to_string(), 150 - })?; 151 - let record = record.ok_or(CliError::MissingRequiredValue { 152 - name: "record".to_string(), 153 - })?; 154 - let issuer = issuer.ok_or(CliError::MissingRequiredValue { 155 - name: "issuer".to_string(), 156 - })?; 157 - let key_data = key_data.ok_or(CliError::MissingRequiredValue { 158 - name: "key".to_string(), 159 - })?; 160 - 161 - verify(&issuer, &key_data, record, &repository, &collection)?; 162 - 163 - println!("OK"); 164 - 165 - Ok(()) 166 - }
+46 -1
crates/atproto-record/src/errors.rs
··· 14 14 //! Errors occurring during AT-URI parsing and validation. 15 15 //! Error codes: aturi-1 through aturi-9 16 16 //! 17 + //! ### `TidError` (Domain: tid) 18 + //! Errors occurring during TID (Timestamp Identifier) parsing and decoding. 19 + //! Error codes: tid-1 through tid-3 20 + //! 17 21 //! ### `CliError` (Domain: cli) 18 22 //! Command-line interface specific errors for file I/O, argument parsing, and DID validation. 19 - //! Error codes: cli-1 through cli-8 23 + //! Error codes: cli-1 through cli-10 20 24 //! 21 25 //! ## Error Format 22 26 //! ··· 220 224 /// record key component, which is not valid. 221 225 #[error("error-atproto-record-aturi-9 Record key component cannot be empty")] 222 226 EmptyRecordKey, 227 + } 228 + 229 + /// Errors that can occur during TID (Timestamp Identifier) operations. 230 + /// 231 + /// This enum covers all validation failures when parsing and decoding TIDs, 232 + /// including format violations, invalid characters, and encoding errors. 233 + #[derive(Debug, Error)] 234 + pub enum TidError { 235 + /// Error when TID string length is invalid. 236 + /// 237 + /// This error occurs when a TID string is not exactly 13 characters long, 238 + /// which is required by the TID specification. 239 + #[error("error-atproto-record-tid-1 Invalid TID length: expected {expected}, got {actual}")] 240 + InvalidLength { 241 + /// Expected length (always 13) 242 + expected: usize, 243 + /// Actual length of the provided string 244 + actual: usize, 245 + }, 246 + 247 + /// Error when TID contains an invalid character. 248 + /// 249 + /// This error occurs when a TID string contains a character outside the 250 + /// base32-sortable character set (234567abcdefghijklmnopqrstuvwxyz). 251 + #[error("error-atproto-record-tid-2 Invalid character '{character}' at position {position}")] 252 + InvalidCharacter { 253 + /// The invalid character 254 + character: char, 255 + /// Position in the string (0-indexed) 256 + position: usize, 257 + }, 258 + 259 + /// Error when TID format is invalid. 260 + /// 261 + /// This error occurs when the TID violates structural requirements, 262 + /// such as having the top bit set (which must always be 0). 263 + #[error("error-atproto-record-tid-3 Invalid TID format: {reason}")] 264 + InvalidFormat { 265 + /// Reason for the format violation 266 + reason: String, 267 + }, 223 268 } 224 269 225 270 /// Errors specific to command-line interface operations.
+40 -19
crates/atproto-record/src/lib.rs
··· 16 16 //! ## Example Usage 17 17 //! 18 18 //! ```ignore 19 - //! use atproto_record::signature; 20 - //! use atproto_identity::key::identify_key; 19 + //! use atproto_record::attestation; 20 + //! use atproto_identity::key::{identify_key, sign, to_public}; 21 + //! use base64::engine::general_purpose::STANDARD; 21 22 //! use serde_json::json; 22 23 //! 23 - //! // Sign a record 24 - //! let key_data = identify_key("did:key:...")?; 25 - //! let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 26 - //! let sig_obj = json!({"issuer": "did:plc:..."}); 24 + //! let private_key = identify_key("did:key:zPrivate...")?; 25 + //! let public_key = to_public(&private_key)?; 26 + //! let key_reference = format!("{}", &public_key); 27 27 //! 28 - //! let signed = signature::create(&key_data, &record, "did:plc:repo", 29 - //! "app.bsky.feed.post", sig_obj).await?; 28 + //! let record = json!({ 29 + //! "$type": "app.example.record", 30 + //! "text": "Hello from attestation helpers!" 31 + //! }); 32 + //! 33 + //! let sig_metadata = json!({ 34 + //! "$type": "com.example.inlineSignature", 35 + //! "key": &key_reference, 36 + //! "purpose": "demo" 37 + //! }); 38 + //! 39 + //! let signing_record = attestation::prepare_signing_record(&record, &sig_metadata)?; 40 + //! let cid = attestation::create_cid(&signing_record)?; 41 + //! let signature_bytes = sign(&private_key, &cid.to_bytes())?; 42 + //! 43 + //! let inline_attestation = json!({ 44 + //! "$type": "com.example.inlineSignature", 45 + //! "key": key_reference, 46 + //! "purpose": "demo", 47 + //! "signature": {"$bytes": STANDARD.encode(signature_bytes)} 48 + //! }); 30 49 //! 31 - //! // Verify a signature 32 - //! signature::verify("did:plc:issuer", &key_data, signed, 33 - //! "did:plc:repo", "app.bsky.feed.post").await?; 50 + //! let signed = attestation::create_inline_attestation_reference(&record, &inline_attestation)?; 51 + //! let reports = tokio_test::block_on(async { 52 + //! attestation::verify_all_signatures(&signed, None).await 53 + //! })?; 54 + //! assert!(matches!(reports[0].status, attestation::VerificationStatus::Valid { .. })); 34 55 //! ``` 35 56 36 57 #![forbid(unsafe_code)] ··· 42 63 /// and CLI operations. All errors follow the project's standardized format: 43 64 /// `error-atproto-record-{domain}-{number} {message}: {details}` 44 65 pub mod errors; 45 - 46 - /// Core signature creation and verification. 47 - /// 48 - /// Provides functions for creating and verifying cryptographic signatures on 49 - /// AT Protocol records using IPLD DAG-CBOR serialization. Supports the 50 - /// community.lexicon.attestation.signature specification with proper $sig 51 - /// object handling and multiple signature support. 52 - pub mod signature; 53 66 54 67 /// AT-URI parsing and validation. 55 68 /// ··· 84 97 /// in many AT Protocol lexicon structures. The wrapper can automatically add type 85 98 /// fields during serialization and validate them during deserialization. 86 99 pub mod typed; 100 + 101 + /// Timestamp Identifier (TID) generation and parsing. 102 + /// 103 + /// TIDs are sortable, distributed identifiers combining microsecond timestamps 104 + /// with random clock identifiers. They provide a collision-resistant, monotonically 105 + /// increasing identifier scheme for AT Protocol records encoded as 13-character 106 + /// base32-sortable strings. 107 + pub mod tid;
-672
crates/atproto-record/src/signature.rs
··· 1 - //! AT Protocol record signature creation and verification. 2 - //! 3 - //! This module provides comprehensive functionality for creating and verifying 4 - //! cryptographic signatures on AT Protocol records following the 5 - //! community.lexicon.attestation.signature specification. 6 - //! 7 - //! ## Signature Process 8 - //! 9 - //! 1. **Signing**: Records are augmented with a `$sig` object containing issuer, 10 - //! timestamp, and context information, then serialized using IPLD DAG-CBOR 11 - //! for deterministic encoding before signing with ECDSA. 12 - //! 13 - //! 2. **Storage**: Signatures are stored in a `signatures` array within the record, 14 - //! allowing multiple signatures from different issuers. 15 - //! 16 - //! 3. **Verification**: The original signed content is reconstructed by replacing 17 - //! the signatures array with the appropriate `$sig` object, then verified 18 - //! using the issuer's public key. 19 - //! 20 - //! ## Supported Curves 21 - //! 22 - //! - P-256 (NIST P-256 / secp256r1) 23 - //! - P-384 (NIST P-384 / secp384r1) 24 - //! - K-256 (secp256k1) 25 - //! 26 - //! ## Example 27 - //! 28 - //! ```ignore 29 - //! use atproto_record::signature::{create, verify}; 30 - //! use atproto_identity::key::identify_key; 31 - //! use serde_json::json; 32 - //! 33 - //! // Create a signature 34 - //! let key = identify_key("did:key:...")?; 35 - //! let record = json!({"text": "Hello!"}); 36 - //! let sig_obj = json!({ 37 - //! "issuer": "did:plc:issuer" 38 - //! // Optional: any additional fields like "issuedAt", "purpose", etc. 39 - //! }); 40 - //! 41 - //! let signed = create(&key, &record, "did:plc:repo", 42 - //! "app.bsky.feed.post", sig_obj)?; 43 - //! 44 - //! // Verify the signature 45 - //! verify("did:plc:issuer", &key, signed, 46 - //! "did:plc:repo", "app.bsky.feed.post")?; 47 - //! ``` 48 - 49 - use atproto_identity::key::{KeyData, sign, validate}; 50 - use base64::{Engine, engine::general_purpose::STANDARD}; 51 - use serde_json::json; 52 - 53 - use crate::errors::VerificationError; 54 - 55 - /// Creates a cryptographic signature for an AT Protocol record. 56 - /// 57 - /// This function generates a signature following the community.lexicon.attestation.signature 58 - /// specification. The record is augmented with a `$sig` object containing context information, 59 - /// serialized using IPLD DAG-CBOR, signed with the provided key, and the signature is added 60 - /// to a `signatures` array in the returned record. 61 - /// 62 - /// # Parameters 63 - /// 64 - /// * `key_data` - The signing key (private key) wrapped in KeyData 65 - /// * `record` - The JSON record to be signed (will not be modified) 66 - /// * `repository` - The repository DID where this record will be stored 67 - /// * `collection` - The collection type (NSID) for this record 68 - /// * `signature_object` - Metadata for the signature, must include: 69 - /// - `issuer`: The DID of the entity creating the signature (required) 70 - /// - Additional custom fields are preserved in the signature (optional) 71 - /// 72 - /// # Returns 73 - /// 74 - /// Returns a new record containing: 75 - /// - All original record fields 76 - /// - A `signatures` array with the new signature appended 77 - /// - No `$sig` field (only used during signing) 78 - /// 79 - /// # Errors 80 - /// 81 - /// Returns [`VerificationError`] if: 82 - /// - Required field `issuer` is missing from signature_object 83 - /// - IPLD DAG-CBOR serialization fails 84 - /// - Cryptographic signing operation fails 85 - /// - JSON structure manipulation fails 86 - pub fn create( 87 - key_data: &KeyData, 88 - record: &serde_json::Value, 89 - repository: &str, 90 - collection: &str, 91 - signature_object: serde_json::Value, 92 - ) -> Result<serde_json::Value, VerificationError> { 93 - if let Some(record_map) = signature_object.as_object() { 94 - if !record_map.contains_key("issuer") { 95 - return Err(VerificationError::SignatureObjectMissingField { 96 - field: "issuer".to_string(), 97 - }); 98 - } 99 - } else { 100 - return Err(VerificationError::InvalidSignatureObjectType); 101 - }; 102 - 103 - // Prepare the $sig object. 104 - let mut sig = signature_object.clone(); 105 - if let Some(record_map) = sig.as_object_mut() { 106 - record_map.insert("repository".to_string(), json!(repository)); 107 - record_map.insert("collection".to_string(), json!(collection)); 108 - record_map.insert( 109 - "$type".to_string(), 110 - json!("community.lexicon.attestation.signature"), 111 - ); 112 - } 113 - 114 - // Create a copy of the record with the $sig object for signing. 115 - let mut signing_record = record.clone(); 116 - if let Some(record_map) = signing_record.as_object_mut() { 117 - record_map.remove("signatures"); 118 - record_map.remove("$sig"); 119 - record_map.insert("$sig".to_string(), sig); 120 - } 121 - 122 - // Create a signature. 123 - let serialized_signing_record = serde_ipld_dagcbor::to_vec(&signing_record)?; 124 - 125 - let signature: Vec<u8> = sign(key_data, &serialized_signing_record)?; 126 - let encoded_signature = STANDARD.encode(&signature); 127 - 128 - // Compose the proof object 129 - let mut proof = signature_object.clone(); 130 - if let Some(record_map) = proof.as_object_mut() { 131 - record_map.remove("repository"); 132 - record_map.remove("collection"); 133 - record_map.insert( 134 - "signature".to_string(), 135 - json!({"$bytes": json!(encoded_signature)}), 136 - ); 137 - record_map.insert( 138 - "$type".to_string(), 139 - json!("community.lexicon.attestation.signature"), 140 - ); 141 - } 142 - 143 - // Add the signature to the original record 144 - let mut signed_record = record.clone(); 145 - 146 - if let Some(record_map) = signed_record.as_object_mut() { 147 - let mut signatures: Vec<serde_json::Value> = record 148 - .get("signatures") 149 - .and_then(|v| v.as_array().cloned()) 150 - .unwrap_or_default(); 151 - 152 - signatures.push(proof); 153 - 154 - record_map.remove("$sig"); 155 - record_map.remove("signatures"); 156 - 157 - // Add the $sig field 158 - record_map.insert("signatures".to_string(), json!(signatures)); 159 - } 160 - 161 - Ok(signed_record) 162 - } 163 - 164 - /// Verifies a cryptographic signature on an AT Protocol record. 165 - /// 166 - /// This function validates signatures by reconstructing the original signed content 167 - /// (record with `$sig` object) and verifying the ECDSA signature against it. 168 - /// It searches through all signatures in the record to find one matching the 169 - /// specified issuer, then verifies it with the provided public key. 170 - /// 171 - /// # Parameters 172 - /// 173 - /// * `issuer` - The DID of the expected signature issuer to verify 174 - /// * `key_data` - The public key for signature verification 175 - /// * `record` - The signed record containing a `signatures` or `sigs` array 176 - /// * `repository` - The repository DID used during signing (must match) 177 - /// * `collection` - The collection type used during signing (must match) 178 - /// 179 - /// # Returns 180 - /// 181 - /// Returns `Ok(())` if a valid signature from the specified issuer is found 182 - /// and successfully verified against the reconstructed signed content. 183 - /// 184 - /// # Errors 185 - /// 186 - /// Returns [`VerificationError`] if: 187 - /// - No `signatures` or `sigs` field exists in the record 188 - /// - No signature from the specified issuer is found 189 - /// - The issuer's signature is malformed or missing required fields 190 - /// - The signature is not in the expected `{"$bytes": "..."}` format 191 - /// - Base64 decoding of the signature fails 192 - /// - IPLD DAG-CBOR serialization of reconstructed content fails 193 - /// - Cryptographic verification fails (invalid signature) 194 - /// 195 - /// # Note 196 - /// 197 - /// This function supports both `signatures` and `sigs` field names for 198 - /// backward compatibility with different AT Protocol implementations. 199 - pub fn verify( 200 - issuer: &str, 201 - key_data: &KeyData, 202 - record: serde_json::Value, 203 - repository: &str, 204 - collection: &str, 205 - ) -> Result<(), VerificationError> { 206 - let signatures = record 207 - .get("sigs") 208 - .or_else(|| record.get("signatures")) 209 - .and_then(|v| v.as_array()) 210 - .ok_or(VerificationError::NoSignaturesField)?; 211 - 212 - for sig_obj in signatures { 213 - // Extract the issuer from the signature object 214 - let signature_issuer = sig_obj 215 - .get("issuer") 216 - .and_then(|v| v.as_str()) 217 - .ok_or(VerificationError::MissingIssuerField)?; 218 - 219 - let signature_value = sig_obj 220 - .get("signature") 221 - .and_then(|v| v.as_object()) 222 - .and_then(|obj| obj.get("$bytes")) 223 - .and_then(|b| b.as_str()) 224 - .ok_or(VerificationError::MissingSignatureField)?; 225 - 226 - if issuer != signature_issuer { 227 - continue; 228 - } 229 - 230 - let mut sig_variable = sig_obj.clone(); 231 - 232 - if let Some(sig_map) = sig_variable.as_object_mut() { 233 - sig_map.remove("signature"); 234 - sig_map.insert("repository".to_string(), json!(repository)); 235 - sig_map.insert("collection".to_string(), json!(collection)); 236 - } 237 - 238 - let mut signed_record = record.clone(); 239 - if let Some(record_map) = signed_record.as_object_mut() { 240 - record_map.remove("signatures"); 241 - record_map.remove("sigs"); 242 - record_map.insert("$sig".to_string(), sig_variable); 243 - } 244 - 245 - let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record) 246 - .map_err(|error| VerificationError::RecordSerializationFailed { error })?; 247 - 248 - let signature_bytes = STANDARD 249 - .decode(signature_value) 250 - .map_err(|error| VerificationError::SignatureDecodingFailed { error })?; 251 - 252 - validate(key_data, &signature_bytes, &serialized_record) 253 - .map_err(|error| VerificationError::CryptographicValidationFailed { error })?; 254 - 255 - return Ok(()); 256 - } 257 - 258 - Err(VerificationError::NoValidSignatureForIssuer { 259 - issuer: issuer.to_string(), 260 - }) 261 - } 262 - 263 - #[cfg(test)] 264 - mod tests { 265 - use super::*; 266 - use atproto_identity::key::{KeyType, generate_key, to_public}; 267 - use serde_json::json; 268 - 269 - #[test] 270 - fn test_create_sign_and_verify_record_p256() -> Result<(), Box<dyn std::error::Error>> { 271 - // Step 1: Generate a P-256 key pair 272 - let private_key = generate_key(KeyType::P256Private)?; 273 - let public_key = to_public(&private_key)?; 274 - 275 - // Step 2: Create a sample record 276 - let record = json!({ 277 - "text": "Hello AT Protocol!", 278 - "createdAt": "2025-01-19T10:00:00Z", 279 - "langs": ["en"] 280 - }); 281 - 282 - // Step 3: Define signature metadata 283 - let issuer_did = "did:plc:test123"; 284 - let repository = "did:plc:repo456"; 285 - let collection = "app.bsky.feed.post"; 286 - 287 - let signature_object = json!({ 288 - "issuer": issuer_did, 289 - "issuedAt": "2025-01-19T10:00:00Z", 290 - "purpose": "attestation" 291 - }); 292 - 293 - // Step 4: Sign the record 294 - let signed_record = create( 295 - &private_key, 296 - &record, 297 - repository, 298 - collection, 299 - signature_object.clone(), 300 - )?; 301 - 302 - // Verify that the signed record contains signatures array 303 - assert!(signed_record.get("signatures").is_some()); 304 - let signatures = signed_record 305 - .get("signatures") 306 - .and_then(|v| v.as_array()) 307 - .expect("signatures should be an array"); 308 - assert_eq!(signatures.len(), 1); 309 - 310 - // Verify signature object structure 311 - let sig = &signatures[0]; 312 - assert_eq!(sig.get("issuer").and_then(|v| v.as_str()), Some(issuer_did)); 313 - assert!(sig.get("signature").is_some()); 314 - assert_eq!( 315 - sig.get("$type").and_then(|v| v.as_str()), 316 - Some("community.lexicon.attestation.signature") 317 - ); 318 - 319 - // Step 5: Verify the signature 320 - verify( 321 - issuer_did, 322 - &public_key, 323 - signed_record.clone(), 324 - repository, 325 - collection, 326 - )?; 327 - 328 - Ok(()) 329 - } 330 - 331 - #[test] 332 - fn test_create_sign_and_verify_record_k256() -> Result<(), Box<dyn std::error::Error>> { 333 - // Test with K-256 curve 334 - let private_key = generate_key(KeyType::K256Private)?; 335 - let public_key = to_public(&private_key)?; 336 - 337 - let record = json!({ 338 - "subject": "at://did:plc:example/app.bsky.feed.post/123", 339 - "likedAt": "2025-01-19T10:00:00Z" 340 - }); 341 - 342 - let issuer_did = "did:plc:issuer789"; 343 - let repository = "did:plc:repo789"; 344 - let collection = "app.bsky.feed.like"; 345 - 346 - let signature_object = json!({ 347 - "issuer": issuer_did, 348 - "issuedAt": "2025-01-19T10:00:00Z" 349 - }); 350 - 351 - let signed_record = create( 352 - &private_key, 353 - &record, 354 - repository, 355 - collection, 356 - signature_object, 357 - )?; 358 - 359 - verify( 360 - issuer_did, 361 - &public_key, 362 - signed_record, 363 - repository, 364 - collection, 365 - )?; 366 - 367 - Ok(()) 368 - } 369 - 370 - #[test] 371 - fn test_create_sign_and_verify_record_p384() -> Result<(), Box<dyn std::error::Error>> { 372 - // Test with P-384 curve 373 - let private_key = generate_key(KeyType::P384Private)?; 374 - let public_key = to_public(&private_key)?; 375 - 376 - let record = json!({ 377 - "displayName": "Test User", 378 - "description": "Testing P-384 signatures" 379 - }); 380 - 381 - let issuer_did = "did:web:example.com"; 382 - let repository = "did:plc:profile123"; 383 - let collection = "app.bsky.actor.profile"; 384 - 385 - let signature_object = json!({ 386 - "issuer": issuer_did, 387 - "issuedAt": "2025-01-19T10:00:00Z", 388 - "expiresAt": "2025-01-20T10:00:00Z", 389 - "customField": "custom value" 390 - }); 391 - 392 - let signed_record = create( 393 - &private_key, 394 - &record, 395 - repository, 396 - collection, 397 - signature_object.clone(), 398 - )?; 399 - 400 - // Verify custom fields are preserved in signature 401 - let signatures = signed_record 402 - .get("signatures") 403 - .and_then(|v| v.as_array()) 404 - .expect("signatures should exist"); 405 - let sig = &signatures[0]; 406 - assert_eq!( 407 - sig.get("customField").and_then(|v| v.as_str()), 408 - Some("custom value") 409 - ); 410 - 411 - verify( 412 - issuer_did, 413 - &public_key, 414 - signed_record, 415 - repository, 416 - collection, 417 - )?; 418 - 419 - Ok(()) 420 - } 421 - 422 - #[test] 423 - fn test_multiple_signatures() -> Result<(), Box<dyn std::error::Error>> { 424 - // Create a record with multiple signatures from different issuers 425 - let private_key1 = generate_key(KeyType::P256Private)?; 426 - let public_key1 = to_public(&private_key1)?; 427 - 428 - let private_key2 = generate_key(KeyType::K256Private)?; 429 - let public_key2 = to_public(&private_key2)?; 430 - 431 - let record = json!({ 432 - "text": "Multi-signed content", 433 - "important": true 434 - }); 435 - 436 - let repository = "did:plc:repo_multi"; 437 - let collection = "app.example.document"; 438 - 439 - // First signature 440 - let issuer1 = "did:plc:issuer1"; 441 - let sig_obj1 = json!({ 442 - "issuer": issuer1, 443 - "issuedAt": "2025-01-19T09:00:00Z", 444 - "role": "author" 445 - }); 446 - 447 - let signed_once = create(&private_key1, &record, repository, collection, sig_obj1)?; 448 - 449 - // Second signature on already signed record 450 - let issuer2 = "did:plc:issuer2"; 451 - let sig_obj2 = json!({ 452 - "issuer": issuer2, 453 - "issuedAt": "2025-01-19T10:00:00Z", 454 - "role": "reviewer" 455 - }); 456 - 457 - let signed_twice = create( 458 - &private_key2, 459 - &signed_once, 460 - repository, 461 - collection, 462 - sig_obj2, 463 - )?; 464 - 465 - // Verify we have two signatures 466 - let signatures = signed_twice 467 - .get("signatures") 468 - .and_then(|v| v.as_array()) 469 - .expect("signatures should exist"); 470 - assert_eq!(signatures.len(), 2); 471 - 472 - // Verify both signatures independently 473 - verify( 474 - issuer1, 475 - &public_key1, 476 - signed_twice.clone(), 477 - repository, 478 - collection, 479 - )?; 480 - verify( 481 - issuer2, 482 - &public_key2, 483 - signed_twice.clone(), 484 - repository, 485 - collection, 486 - )?; 487 - 488 - Ok(()) 489 - } 490 - 491 - #[test] 492 - fn test_verify_wrong_issuer_fails() -> Result<(), Box<dyn std::error::Error>> { 493 - let private_key = generate_key(KeyType::P256Private)?; 494 - let public_key = to_public(&private_key)?; 495 - 496 - let record = json!({"test": "data"}); 497 - let repository = "did:plc:repo"; 498 - let collection = "app.test"; 499 - 500 - let sig_obj = json!({ 501 - "issuer": "did:plc:correct_issuer" 502 - }); 503 - 504 - let signed = create(&private_key, &record, repository, collection, sig_obj)?; 505 - 506 - // Try to verify with wrong issuer 507 - let result = verify( 508 - "did:plc:wrong_issuer", 509 - &public_key, 510 - signed, 511 - repository, 512 - collection, 513 - ); 514 - 515 - assert!(result.is_err()); 516 - assert!(matches!( 517 - result.unwrap_err(), 518 - VerificationError::NoValidSignatureForIssuer { .. } 519 - )); 520 - 521 - Ok(()) 522 - } 523 - 524 - #[test] 525 - fn test_verify_wrong_key_fails() -> Result<(), Box<dyn std::error::Error>> { 526 - let private_key = generate_key(KeyType::P256Private)?; 527 - let wrong_private_key = generate_key(KeyType::P256Private)?; 528 - let wrong_public_key = to_public(&wrong_private_key)?; 529 - 530 - let record = json!({"test": "data"}); 531 - let repository = "did:plc:repo"; 532 - let collection = "app.test"; 533 - let issuer = "did:plc:issuer"; 534 - 535 - let sig_obj = json!({ "issuer": issuer }); 536 - 537 - let signed = create(&private_key, &record, repository, collection, sig_obj)?; 538 - 539 - // Try to verify with wrong key 540 - let result = verify(issuer, &wrong_public_key, signed, repository, collection); 541 - 542 - assert!(result.is_err()); 543 - assert!(matches!( 544 - result.unwrap_err(), 545 - VerificationError::CryptographicValidationFailed { .. } 546 - )); 547 - 548 - Ok(()) 549 - } 550 - 551 - #[test] 552 - fn test_verify_tampered_record_fails() -> Result<(), Box<dyn std::error::Error>> { 553 - let private_key = generate_key(KeyType::P256Private)?; 554 - let public_key = to_public(&private_key)?; 555 - 556 - let record = json!({"text": "original"}); 557 - let repository = "did:plc:repo"; 558 - let collection = "app.test"; 559 - let issuer = "did:plc:issuer"; 560 - 561 - let sig_obj = json!({ "issuer": issuer }); 562 - 563 - let mut signed = create(&private_key, &record, repository, collection, sig_obj)?; 564 - 565 - // Tamper with the record content 566 - if let Some(obj) = signed.as_object_mut() { 567 - obj.insert("text".to_string(), json!("tampered")); 568 - } 569 - 570 - // Verification should fail 571 - let result = verify(issuer, &public_key, signed, repository, collection); 572 - 573 - assert!(result.is_err()); 574 - assert!(matches!( 575 - result.unwrap_err(), 576 - VerificationError::CryptographicValidationFailed { .. } 577 - )); 578 - 579 - Ok(()) 580 - } 581 - 582 - #[test] 583 - fn test_create_missing_issuer_fails() -> Result<(), Box<dyn std::error::Error>> { 584 - let private_key = generate_key(KeyType::P256Private)?; 585 - 586 - let record = json!({"test": "data"}); 587 - let repository = "did:plc:repo"; 588 - let collection = "app.test"; 589 - 590 - // Signature object without issuer field 591 - let sig_obj = json!({ 592 - "issuedAt": "2025-01-19T10:00:00Z" 593 - }); 594 - 595 - let result = create(&private_key, &record, repository, collection, sig_obj); 596 - 597 - assert!(result.is_err()); 598 - assert!(matches!( 599 - result.unwrap_err(), 600 - VerificationError::SignatureObjectMissingField { field } if field == "issuer" 601 - )); 602 - 603 - Ok(()) 604 - } 605 - 606 - #[test] 607 - fn test_verify_supports_sigs_field() -> Result<(), Box<dyn std::error::Error>> { 608 - // Test backward compatibility with "sigs" field name 609 - let private_key = generate_key(KeyType::P256Private)?; 610 - let public_key = to_public(&private_key)?; 611 - 612 - let record = json!({"test": "data"}); 613 - let repository = "did:plc:repo"; 614 - let collection = "app.test"; 615 - let issuer = "did:plc:issuer"; 616 - 617 - let sig_obj = json!({ "issuer": issuer }); 618 - 619 - let mut signed = create(&private_key, &record, repository, collection, sig_obj)?; 620 - 621 - // Rename "signatures" to "sigs" 622 - if let Some(obj) = signed.as_object_mut() 623 - && let Some(signatures) = obj.remove("signatures") 624 - { 625 - obj.insert("sigs".to_string(), signatures); 626 - } 627 - 628 - // Should still verify successfully 629 - verify(issuer, &public_key, signed, repository, collection)?; 630 - 631 - Ok(()) 632 - } 633 - 634 - #[test] 635 - fn test_signature_preserves_original_record() -> Result<(), Box<dyn std::error::Error>> { 636 - let private_key = generate_key(KeyType::P256Private)?; 637 - 638 - let original_record = json!({ 639 - "text": "Original content", 640 - "metadata": { 641 - "author": "Test", 642 - "version": 1 643 - }, 644 - "tags": ["test", "sample"] 645 - }); 646 - 647 - let repository = "did:plc:repo"; 648 - let collection = "app.test"; 649 - 650 - let sig_obj = json!({ 651 - "issuer": "did:plc:issuer" 652 - }); 653 - 654 - let signed = create( 655 - &private_key, 656 - &original_record, 657 - repository, 658 - collection, 659 - sig_obj, 660 - )?; 661 - 662 - // All original fields should be preserved 663 - assert_eq!(signed.get("text"), original_record.get("text")); 664 - assert_eq!(signed.get("metadata"), original_record.get("metadata")); 665 - assert_eq!(signed.get("tags"), original_record.get("tags")); 666 - 667 - // Plus the new signatures field 668 - assert!(signed.get("signatures").is_some()); 669 - 670 - Ok(()) 671 - } 672 - }
+492
crates/atproto-record/src/tid.rs
··· 1 + //! Timestamp Identifier (TID) generation and parsing. 2 + //! 3 + //! TIDs are 64-bit integers encoded as 13-character base32-sortable strings, combining 4 + //! a microsecond timestamp with a random clock identifier for collision resistance. 5 + //! They provide a sortable, distributed identifier scheme for AT Protocol records. 6 + //! 7 + //! ## Format 8 + //! 9 + //! - **Length**: Always 13 ASCII characters 10 + //! - **Encoding**: Base32-sortable character set (`234567abcdefghijklmnopqrstuvwxyz`) 11 + //! - **Structure**: 64-bit big-endian integer with: 12 + //! - Bit 0 (top): Always 0 13 + //! - Bits 1-53: Microseconds since UNIX epoch 14 + //! - Bits 54-63: Random 10-bit clock identifier 15 + //! 16 + //! ## Example 17 + //! 18 + //! ``` 19 + //! use atproto_record::tid::Tid; 20 + //! 21 + //! // Generate a new TID 22 + //! let tid = Tid::new(); 23 + //! let tid_str = tid.to_string(); 24 + //! assert_eq!(tid_str.len(), 13); 25 + //! 26 + //! // Parse a TID string 27 + //! let parsed = tid_str.parse::<Tid>().unwrap(); 28 + //! assert_eq!(tid, parsed); 29 + //! 30 + //! // TIDs are sortable by timestamp 31 + //! let tid1 = Tid::new(); 32 + //! std::thread::sleep(std::time::Duration::from_micros(10)); 33 + //! let tid2 = Tid::new(); 34 + //! assert!(tid1 < tid2); 35 + //! ``` 36 + 37 + use std::fmt; 38 + use std::str::FromStr; 39 + use std::sync::Mutex; 40 + use std::time::{SystemTime, UNIX_EPOCH}; 41 + 42 + use crate::errors::TidError; 43 + 44 + /// Base32-sortable character set for TID encoding. 45 + /// 46 + /// This character set maintains lexicographic sort order when encoded TIDs 47 + /// are compared as strings, ensuring timestamp ordering is preserved. 48 + const BASE32_SORTABLE: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; 49 + 50 + /// Reverse lookup table for base32-sortable decoding. 51 + /// 52 + /// Maps ASCII character values to their corresponding 5-bit values. 53 + /// Invalid characters are marked with 0xFF. 54 + const BASE32_DECODE: [u8; 256] = { 55 + let mut table = [0xFF; 256]; 56 + table[b'2' as usize] = 0; 57 + table[b'3' as usize] = 1; 58 + table[b'4' as usize] = 2; 59 + table[b'5' as usize] = 3; 60 + table[b'6' as usize] = 4; 61 + table[b'7' as usize] = 5; 62 + table[b'a' as usize] = 6; 63 + table[b'b' as usize] = 7; 64 + table[b'c' as usize] = 8; 65 + table[b'd' as usize] = 9; 66 + table[b'e' as usize] = 10; 67 + table[b'f' as usize] = 11; 68 + table[b'g' as usize] = 12; 69 + table[b'h' as usize] = 13; 70 + table[b'i' as usize] = 14; 71 + table[b'j' as usize] = 15; 72 + table[b'k' as usize] = 16; 73 + table[b'l' as usize] = 17; 74 + table[b'm' as usize] = 18; 75 + table[b'n' as usize] = 19; 76 + table[b'o' as usize] = 20; 77 + table[b'p' as usize] = 21; 78 + table[b'q' as usize] = 22; 79 + table[b'r' as usize] = 23; 80 + table[b's' as usize] = 24; 81 + table[b't' as usize] = 25; 82 + table[b'u' as usize] = 26; 83 + table[b'v' as usize] = 27; 84 + table[b'w' as usize] = 28; 85 + table[b'x' as usize] = 29; 86 + table[b'y' as usize] = 30; 87 + table[b'z' as usize] = 31; 88 + table 89 + }; 90 + 91 + /// Timestamp Identifier (TID) for AT Protocol records. 92 + /// 93 + /// A TID combines a microsecond-precision timestamp with a random clock identifier 94 + /// to create a sortable, collision-resistant identifier. TIDs are represented as 95 + /// 13-character base32-sortable strings. 96 + /// 97 + /// ## Monotonicity 98 + /// 99 + /// The TID generator ensures monotonically increasing values even when the system 100 + /// clock moves backwards or multiple TIDs are generated within the same microsecond. 101 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 102 + pub struct Tid(u64); 103 + 104 + /// Thread-local state for monotonic TID generation. 105 + /// 106 + /// Tracks the last generated timestamp and clock identifier to ensure 107 + /// monotonically increasing TID values. 108 + static LAST_TID: Mutex<Option<(u64, u16)>> = Mutex::new(None); 109 + 110 + impl Tid { 111 + /// The length of a TID string in characters. 112 + pub const LENGTH: usize = 13; 113 + 114 + /// Maximum valid timestamp value (53 bits). 115 + const MAX_TIMESTAMP: u64 = (1u64 << 53) - 1; 116 + 117 + /// Bitmask for extracting the 10-bit clock identifier. 118 + const CLOCK_ID_MASK: u64 = 0x3FF; 119 + 120 + /// Creates a new TID with the current timestamp and a random clock identifier. 121 + /// 122 + /// This function ensures monotonically increasing TID values by tracking the 123 + /// last generated TID and incrementing the clock identifier when necessary. 124 + /// 125 + /// # Example 126 + /// 127 + /// ``` 128 + /// use atproto_record::tid::Tid; 129 + /// 130 + /// let tid = Tid::new(); 131 + /// println!("Generated TID: {}", tid); 132 + /// ``` 133 + pub fn new() -> Self { 134 + Self::new_with_time(Self::current_timestamp_micros()) 135 + } 136 + 137 + /// Creates a new TID with a specific timestamp (for testing). 138 + /// 139 + /// # Arguments 140 + /// 141 + /// * `timestamp_micros` - Microseconds since UNIX epoch 142 + /// 143 + /// # Panics 144 + /// 145 + /// Panics if the timestamp exceeds 53 bits (year 2255+). 146 + pub fn new_with_time(timestamp_micros: u64) -> Self { 147 + assert!( 148 + timestamp_micros <= Self::MAX_TIMESTAMP, 149 + "Timestamp exceeds 53-bit maximum" 150 + ); 151 + 152 + let mut last = LAST_TID.lock().unwrap(); 153 + 154 + let clock_id = if let Some((last_timestamp, last_clock)) = *last { 155 + if timestamp_micros > last_timestamp { 156 + // New timestamp, generate random clock ID 157 + Self::random_clock_id() 158 + } else if timestamp_micros == last_timestamp { 159 + // Same timestamp, increment clock ID 160 + if last_clock == Self::CLOCK_ID_MASK as u16 { 161 + // Clock ID overflow, use random 162 + Self::random_clock_id() 163 + } else { 164 + last_clock + 1 165 + } 166 + } else { 167 + // Clock moved backwards, use last timestamp + 1 168 + let adjusted_timestamp = last_timestamp + 1; 169 + let adjusted_clock = Self::random_clock_id(); 170 + *last = Some((adjusted_timestamp, adjusted_clock)); 171 + return Self::from_parts(adjusted_timestamp, adjusted_clock); 172 + } 173 + } else { 174 + // First TID, generate random clock ID 175 + Self::random_clock_id() 176 + }; 177 + 178 + *last = Some((timestamp_micros, clock_id)); 179 + Self::from_parts(timestamp_micros, clock_id) 180 + } 181 + 182 + /// Creates a TID from timestamp and clock identifier components. 183 + /// 184 + /// # Arguments 185 + /// 186 + /// * `timestamp_micros` - Microseconds since UNIX epoch (53 bits max) 187 + /// * `clock_id` - Random clock identifier (10 bits max) 188 + /// 189 + /// # Panics 190 + /// 191 + /// Panics if timestamp exceeds 53 bits or clock_id exceeds 10 bits. 192 + pub fn from_parts(timestamp_micros: u64, clock_id: u16) -> Self { 193 + assert!( 194 + timestamp_micros <= Self::MAX_TIMESTAMP, 195 + "Timestamp exceeds 53-bit maximum" 196 + ); 197 + assert!( 198 + clock_id <= Self::CLOCK_ID_MASK as u16, 199 + "Clock ID exceeds 10-bit maximum" 200 + ); 201 + 202 + // Combine: top bit 0, 53 bits timestamp, 10 bits clock ID 203 + let value = (timestamp_micros << 10) | (clock_id as u64); 204 + Tid(value) 205 + } 206 + 207 + /// Returns the timestamp component in microseconds since UNIX epoch. 208 + /// 209 + /// # Example 210 + /// 211 + /// ``` 212 + /// use atproto_record::tid::Tid; 213 + /// 214 + /// let tid = Tid::new(); 215 + /// let timestamp = tid.timestamp_micros(); 216 + /// println!("Timestamp: {} μs", timestamp); 217 + /// ``` 218 + pub fn timestamp_micros(&self) -> u64 { 219 + self.0 >> 10 220 + } 221 + 222 + /// Returns the clock identifier component (10 bits). 223 + /// 224 + /// # Example 225 + /// 226 + /// ``` 227 + /// use atproto_record::tid::Tid; 228 + /// 229 + /// let tid = Tid::new(); 230 + /// let clock_id = tid.clock_id(); 231 + /// println!("Clock ID: {}", clock_id); 232 + /// ``` 233 + pub fn clock_id(&self) -> u16 { 234 + (self.0 & Self::CLOCK_ID_MASK) as u16 235 + } 236 + 237 + /// Returns the raw 64-bit integer value. 238 + pub fn as_u64(&self) -> u64 { 239 + self.0 240 + } 241 + 242 + /// Encodes the TID as a 13-character base32-sortable string. 243 + /// 244 + /// # Example 245 + /// 246 + /// ``` 247 + /// use atproto_record::tid::Tid; 248 + /// 249 + /// let tid = Tid::new(); 250 + /// let encoded = tid.encode(); 251 + /// assert_eq!(encoded.len(), 13); 252 + /// ``` 253 + pub fn encode(&self) -> String { 254 + let mut chars = [0u8; Self::LENGTH]; 255 + let mut value = self.0; 256 + 257 + // Encode from right to left (least significant to most significant) 258 + for i in (0..Self::LENGTH).rev() { 259 + chars[i] = BASE32_SORTABLE[(value & 0x1F) as usize]; 260 + value >>= 5; 261 + } 262 + 263 + // BASE32_SORTABLE only contains valid UTF-8 ASCII characters 264 + String::from_utf8(chars.to_vec()).expect("base32-sortable encoding is always valid UTF-8") 265 + } 266 + 267 + /// Decodes a base32-sortable string into a TID. 268 + /// 269 + /// # Errors 270 + /// 271 + /// Returns [`TidError::InvalidLength`] if the string is not exactly 13 characters. 272 + /// Returns [`TidError::InvalidCharacter`] if the string contains invalid characters. 273 + /// Returns [`TidError::InvalidFormat`] if the decoded value has the top bit set. 274 + /// 275 + /// # Example 276 + /// 277 + /// ``` 278 + /// use atproto_record::tid::Tid; 279 + /// 280 + /// let tid_str = "3jzfcijpj2z2a"; 281 + /// let tid = Tid::decode(tid_str).unwrap(); 282 + /// assert_eq!(tid.to_string(), tid_str); 283 + /// ``` 284 + pub fn decode(s: &str) -> Result<Self, TidError> { 285 + if s.len() != Self::LENGTH { 286 + return Err(TidError::InvalidLength { 287 + expected: Self::LENGTH, 288 + actual: s.len(), 289 + }); 290 + } 291 + 292 + let bytes = s.as_bytes(); 293 + let mut value: u64 = 0; 294 + 295 + for (i, &byte) in bytes.iter().enumerate() { 296 + let decoded = BASE32_DECODE[byte as usize]; 297 + if decoded == 0xFF { 298 + return Err(TidError::InvalidCharacter { 299 + character: byte as char, 300 + position: i, 301 + }); 302 + } 303 + value = (value << 5) | (decoded as u64); 304 + } 305 + 306 + // Verify top bit is 0 307 + if value & (1u64 << 63) != 0 { 308 + return Err(TidError::InvalidFormat { 309 + reason: "Top bit must be 0".to_string(), 310 + }); 311 + } 312 + 313 + Ok(Tid(value)) 314 + } 315 + 316 + /// Gets the current timestamp in microseconds since UNIX epoch. 317 + fn current_timestamp_micros() -> u64 { 318 + SystemTime::now() 319 + .duration_since(UNIX_EPOCH) 320 + .expect("System time before UNIX epoch") 321 + .as_micros() as u64 322 + } 323 + 324 + /// Generates a random 10-bit clock identifier. 325 + fn random_clock_id() -> u16 { 326 + use rand::RngCore; 327 + let mut rng = rand::thread_rng(); 328 + (rng.next_u32() as u16) & (Self::CLOCK_ID_MASK as u16) 329 + } 330 + } 331 + 332 + impl Default for Tid { 333 + fn default() -> Self { 334 + Self::new() 335 + } 336 + } 337 + 338 + impl fmt::Display for Tid { 339 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 340 + write!(f, "{}", self.encode()) 341 + } 342 + } 343 + 344 + impl FromStr for Tid { 345 + type Err = TidError; 346 + 347 + fn from_str(s: &str) -> Result<Self, Self::Err> { 348 + Self::decode(s) 349 + } 350 + } 351 + 352 + impl serde::Serialize for Tid { 353 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 354 + where 355 + S: serde::Serializer, 356 + { 357 + serializer.serialize_str(&self.encode()) 358 + } 359 + } 360 + 361 + impl<'de> serde::Deserialize<'de> for Tid { 362 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 363 + where 364 + D: serde::Deserializer<'de>, 365 + { 366 + let s = String::deserialize(deserializer)?; 367 + Self::decode(&s).map_err(serde::de::Error::custom) 368 + } 369 + } 370 + 371 + #[cfg(test)] 372 + mod tests { 373 + use super::*; 374 + 375 + #[test] 376 + fn test_tid_encode_decode() { 377 + let tid = Tid::new(); 378 + let encoded = tid.encode(); 379 + assert_eq!(encoded.len(), Tid::LENGTH); 380 + 381 + let decoded = Tid::decode(&encoded).unwrap(); 382 + assert_eq!(tid, decoded); 383 + } 384 + 385 + #[test] 386 + fn test_tid_from_parts() { 387 + let timestamp = 1234567890123456u64; 388 + let clock_id = 42u16; 389 + let tid = Tid::from_parts(timestamp, clock_id); 390 + 391 + assert_eq!(tid.timestamp_micros(), timestamp); 392 + assert_eq!(tid.clock_id(), clock_id); 393 + } 394 + 395 + #[test] 396 + fn test_tid_monotonic() { 397 + let tid1 = Tid::new(); 398 + std::thread::sleep(std::time::Duration::from_micros(10)); 399 + let tid2 = Tid::new(); 400 + 401 + assert!(tid1 < tid2); 402 + } 403 + 404 + #[test] 405 + fn test_tid_same_timestamp() { 406 + let timestamp = 1234567890123456u64; 407 + let tid1 = Tid::new_with_time(timestamp); 408 + let tid2 = Tid::new_with_time(timestamp); 409 + 410 + // Should have different clock IDs or incremented clock ID 411 + assert!(tid1 < tid2 || tid1.clock_id() + 1 == tid2.clock_id()); 412 + } 413 + 414 + #[test] 415 + fn test_tid_string_roundtrip() { 416 + let tid = Tid::new(); 417 + let s = tid.to_string(); 418 + let parsed: Tid = s.parse().unwrap(); 419 + assert_eq!(tid, parsed); 420 + } 421 + 422 + #[test] 423 + fn test_tid_serde() { 424 + let tid = Tid::new(); 425 + let json = serde_json::to_string(&tid).unwrap(); 426 + let parsed: Tid = serde_json::from_str(&json).unwrap(); 427 + assert_eq!(tid, parsed); 428 + } 429 + 430 + #[test] 431 + fn test_tid_valid_examples() { 432 + // Examples from the specification 433 + let examples = ["3jzfcijpj2z2a", "7777777777777", "2222222222222"]; 434 + 435 + for example in &examples { 436 + let tid = Tid::decode(example).unwrap(); 437 + assert_eq!(&tid.encode(), example); 438 + } 439 + } 440 + 441 + #[test] 442 + fn test_tid_invalid_length() { 443 + let result = Tid::decode("123"); 444 + assert!(matches!(result, Err(TidError::InvalidLength { .. }))); 445 + } 446 + 447 + #[test] 448 + fn test_tid_invalid_character() { 449 + let result = Tid::decode("123456789012!"); 450 + assert!(matches!(result, Err(TidError::InvalidCharacter { .. }))); 451 + } 452 + 453 + #[test] 454 + fn test_tid_first_char_range() { 455 + // First character must be in valid range per spec 456 + let tid = Tid::new(); 457 + let encoded = tid.encode(); 458 + let first_char = encoded.chars().next().unwrap(); 459 + 460 + // First char must be 234567abcdefghij (values 0-15 in base32-sortable) 461 + assert!("234567abcdefghij".contains(first_char)); 462 + } 463 + 464 + #[test] 465 + fn test_tid_sortability() { 466 + // TIDs with increasing timestamps should sort correctly as strings 467 + let tid1 = Tid::from_parts(1000000, 0); 468 + let tid2 = Tid::from_parts(2000000, 0); 469 + let tid3 = Tid::from_parts(3000000, 0); 470 + 471 + let s1 = tid1.to_string(); 472 + let s2 = tid2.to_string(); 473 + let s3 = tid3.to_string(); 474 + 475 + assert!(s1 < s2); 476 + assert!(s2 < s3); 477 + assert!(s1 < s3); 478 + } 479 + 480 + #[test] 481 + fn test_tid_clock_backward() { 482 + // Simulate clock moving backwards 483 + let timestamp1 = 2000000u64; 484 + let tid1 = Tid::new_with_time(timestamp1); 485 + 486 + let timestamp2 = 1000000u64; // Earlier timestamp 487 + let tid2 = Tid::new_with_time(timestamp2); 488 + 489 + // TID should still be monotonically increasing 490 + assert!(tid2 > tid1); 491 + } 492 + }
+16 -12
crates/atproto-xrpcs-helloworld/src/main.rs
··· 5 5 use atproto_identity::resolve::SharedIdentityResolver; 6 6 use atproto_identity::{ 7 7 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 8 - key::{KeyData, KeyProvider, identify_key, to_public}, 8 + key::{KeyData, KeyResolver, identify_key, to_public}, 9 9 resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver}, 10 - storage::DidDocumentStorage, 11 10 storage_lru::LruDidDocumentStorage, 11 + traits::DidDocumentStorage, 12 12 }; 13 13 use atproto_xrpcs::authorization::ResolvingAuthorization; 14 14 use axum::{ ··· 24 24 use std::{collections::HashMap, num::NonZeroUsize, ops::Deref, sync::Arc}; 25 25 26 26 #[derive(Clone)] 27 - pub struct SimpleKeyProvider { 27 + pub struct SimpleKeyResolver { 28 28 keys: HashMap<String, KeyData>, 29 29 } 30 30 31 - impl Default for SimpleKeyProvider { 31 + impl Default for SimpleKeyResolver { 32 32 fn default() -> Self { 33 33 Self::new() 34 34 } 35 35 } 36 36 37 - impl SimpleKeyProvider { 37 + impl SimpleKeyResolver { 38 38 pub fn new() -> Self { 39 39 Self { 40 40 keys: HashMap::new(), ··· 43 43 } 44 44 45 45 #[async_trait] 46 - impl KeyProvider for SimpleKeyProvider { 47 - async fn get_private_key_by_id(&self, key_id: &str) -> anyhow::Result<Option<KeyData>> { 48 - Ok(self.keys.get(key_id).cloned()) 46 + impl KeyResolver for SimpleKeyResolver { 47 + async fn resolve(&self, key: &str) -> anyhow::Result<KeyData> { 48 + if let Some(key_data) = self.keys.get(key) { 49 + Ok(key_data.clone()) 50 + } else { 51 + identify_key(key).map_err(Into::into) 52 + } 49 53 } 50 54 } 51 55 ··· 58 62 pub struct InnerWebContext { 59 63 pub http_client: reqwest::Client, 60 64 pub document_storage: Arc<dyn DidDocumentStorage>, 61 - pub key_provider: Arc<dyn KeyProvider>, 65 + pub key_resolver: Arc<dyn KeyResolver>, 62 66 pub service_document: ServiceDocument, 63 67 pub service_did: ServiceDID, 64 68 pub identity_resolver: Arc<dyn IdentityResolver>, ··· 99 103 } 100 104 } 101 105 102 - impl FromRef<WebContext> for Arc<dyn KeyProvider> { 106 + impl FromRef<WebContext> for Arc<dyn KeyResolver> { 103 107 fn from_ref(context: &WebContext) -> Self { 104 - context.0.key_provider.clone() 108 + context.0.key_resolver.clone() 105 109 } 106 110 } 107 111 ··· 213 217 let web_context = WebContext(Arc::new(InnerWebContext { 214 218 http_client: http_client.clone(), 215 219 document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())), 216 - key_provider: Arc::new(SimpleKeyProvider { 220 + key_resolver: Arc::new(SimpleKeyResolver { 217 221 keys: signing_key_storage, 218 222 }), 219 223 service_document,
+2 -2
crates/atproto-xrpcs/src/authorization.rs
··· 6 6 use anyhow::Result; 7 7 use atproto_identity::key::identify_key; 8 8 use atproto_identity::resolve::IdentityResolver; 9 - use atproto_identity::storage::DidDocumentStorage; 9 + use atproto_identity::traits::DidDocumentStorage; 10 10 use atproto_oauth::jwt::{Claims, Header}; 11 11 use axum::extract::{FromRef, OptionalFromRequestParts}; 12 12 use axum::http::request::Parts; ··· 206 206 mod tests { 207 207 use super::*; 208 208 use atproto_identity::model::{Document, VerificationMethod}; 209 - use atproto_identity::storage::DidDocumentStorage; 209 + use atproto_identity::traits::DidDocumentStorage; 210 210 use axum::extract::FromRef; 211 211 use axum::http::{Method, Request}; 212 212 use std::collections::HashMap;