A library for ATProtocol identities.

refactor: atproto-attestation dx and ergonomics

+2
Cargo.lock
··· 115 "atproto-identity", 116 "atproto-record", 117 "base64", 118 "cid", 119 "clap", 120 "elliptic-curve", ··· 2300 source = "registry+https://github.com/rust-lang/crates.io-index" 2301 checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 2302 dependencies = [ 2303 "itoa", 2304 "memchr", 2305 "ryu",
··· 115 "atproto-identity", 116 "atproto-record", 117 "base64", 118 + "chrono", 119 "cid", 120 "clap", 121 "elliptic-curve", ··· 2301 source = "registry+https://github.com/rust-lang/crates.io-index" 2302 checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 2303 dependencies = [ 2304 + "indexmap", 2305 "itoa", 2306 "memchr", 2307 "ryu",
+12 -4
README.md
··· 88 89 ```rust 90 use atproto_identity::key::{identify_key, to_public}; 91 - use atproto_attestation::{create_inline_attestation, verify_all_signatures, VerificationStatus}; 92 use serde_json::json; 93 94 #[tokio::main] ··· 96 let private_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 97 let public_key = to_public(&private_key)?; 98 let key_reference = format!("{}", &public_key); 99 100 let record = json!({ 101 "$type": "app.bsky.feed.post", ··· 110 "issuedAt": "2024-01-01T00:00:00.000Z" 111 }); 112 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 { .. }))); 118 119 Ok(())
··· 88 89 ```rust 90 use atproto_identity::key::{identify_key, to_public}; 91 + use atproto_attestation::{ 92 + create_inline_attestation, verify_all_signatures, VerificationStatus, 93 + input::{AnyInput, PhantomSignature} 94 + }; 95 use serde_json::json; 96 97 #[tokio::main] ··· 99 let private_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 100 let public_key = to_public(&private_key)?; 101 let key_reference = format!("{}", &public_key); 102 + let repository_did = "did:plc:repo123"; 103 104 let record = json!({ 105 "$type": "app.bsky.feed.post", ··· 114 "issuedAt": "2024-01-01T00:00:00.000Z" 115 }); 116 117 + let signed_record = create_inline_attestation::<PhantomSignature, PhantomSignature>( 118 + AnyInput::Json(record), 119 + AnyInput::Json(sig_metadata), 120 + repository_did, 121 + &private_key 122 + )?; 123 124 + let reports = verify_all_signatures(&signed_record, repository_did, None).await?; 125 assert!(reports.iter().all(|report| matches!(report.status, VerificationStatus::Valid { .. }))); 126 127 Ok(())
+2 -1
crates/atproto-attestation/Cargo.toml
··· 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 ··· 52 53 [dev-dependencies] 54 async-trait = "0.1" 55 tokio = { workspace = true, features = ["macros", "rt"] } 56 57 [features]
··· 34 anyhow.workspace = true 35 base64.workspace = true 36 serde.workspace = true 37 + serde_json = {workspace = true, features = ["preserve_order"]} 38 serde_ipld_dagcbor.workspace = true 39 sha2.workspace = true 40 thiserror.workspace = true ··· 52 53 [dev-dependencies] 54 async-trait = "0.1" 55 + chrono = { workspace = true } 56 tokio = { workspace = true, features = ["macros", "rt"] } 57 58 [features]
+123 -221
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 and prevents replay attacks by: 10 - 1. Preparing records with `$sig` metadata containing `$type` and `repository` fields 11 2. Generating content identifiers (CIDs) using DAG-CBOR serialization 12 3. Signing CID bytes with elliptic curve cryptography (for inline attestations) 13 - 4. Embedding signatures or storing CIDs in proof records 14 - 5. Verifying signatures against resolved public keys with repository validation 15 16 - **Critical Security Feature**: The `repository` field in `$sig` metadata binds attestations to specific repositories, preventing replay attacks where an attacker might attempt to clone records from one repository into their own 17 18 ## Features 19 ··· 21 - **Remote attestations**: Create separate proof records with CID-based strongRef references 22 - **CID-first workflow**: Deterministic signing based on content identifiers 23 - **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curves 24 - - **Signature normalization**: Automatic low-S normalization for ECDSA signatures 25 - - **Key resolution**: Resolve verification keys from DID documents or did:key identifiers 26 - - **Flexible verification**: Verify individual signatures or all signatures in a record 27 - - **Structured reporting**: Detailed verification reports with success/failure status 28 29 ## CLI Tools 30 ··· 40 Inline attestations embed the signature bytes directly in the record: 41 42 ```rust 43 - use atproto_identity::key::{identify_key, to_public}; 44 - use atproto_attestation::create_inline_attestation; 45 use serde_json::json; 46 47 - #[tokio::main] 48 - async fn main() -> anyhow::Result<()> { 49 - // Parse the signing key from a did:key 50 - let private_key = identify_key("did:key:zQ3sh...")?; 51 let public_key = to_public(&private_key)?; 52 let key_reference = format!("{}", &public_key); 53 ··· 62 let repository_did = "did:plc:repo123"; 63 64 // Attestation metadata (required: $type and key for inline attestations) 65 - // Note: repository field is automatically added during CID generation but not stored in final signature 66 let sig_metadata = json!({ 67 "$type": "com.example.inlineSignature", 68 "key": &key_reference, ··· 71 }); 72 73 // Create inline attestation (repository_did is bound into the CID) 74 - let signed_record = create_inline_attestation( 75 - &record, 76 - &sig_metadata, 77 repository_did, 78 &private_key 79 )?; ··· 97 "key": "did:key:zQ3sh...", 98 "issuer": "did:plc:issuer123", 99 "issuedAt": "2024-01-01T00:00:00.000Z", 100 "signature": { 101 - "$bytes": "base64-encoded-signature-bytes" 102 } 103 } 104 ] ··· 110 Remote attestations create a separate proof record that must be stored in a repository: 111 112 ```rust 113 - use atproto_attestation::{create_remote_attestation, create_remote_attestation_reference}; 114 use serde_json::json; 115 116 - let record = json!({ 117 - "$type": "app.bsky.feed.post", 118 - "text": "Hello world!" 119 - }); 120 121 - // Repository housing the original record (for replay attack prevention) 122 - let repository_did = "did:plc:repo123"; 123 124 - // DID of the entity creating the attestation (will store the proof record) 125 - let attestor_did = "did:plc:attestor456"; 126 127 - let metadata = json!({ 128 - "$type": "com.example.attestation", 129 - "issuer": "did:plc:issuer123", 130 - "purpose": "verification" 131 - }); 132 133 - // Create the proof record (contains the CID with repository binding) 134 - // Note: repository field is used during CID generation but not stored in proof 135 - let proof_record = create_remote_attestation(&record, &metadata, repository_did)?; 136 137 - // Create the source record with strongRef pointing to the proof 138 - let attested_record = create_remote_attestation_reference( 139 - &record, 140 - &proof_record, 141 - attestor_did // DID where proof record will be stored 142 - )?; 143 144 - // The proof_record should be stored in the attestor's repository 145 - // The attested_record contains the strongRef reference 146 ``` 147 148 ### Verifying Signatures 149 150 - Verify signatures embedded in records with repository validation: 151 152 ```rust 153 - use atproto_attestation::{verify_all_signatures, VerificationStatus}; 154 155 #[tokio::main] 156 async fn main() -> anyhow::Result<()> { ··· 161 // CRITICAL: This must match the repository used during signing to prevent replay attacks 162 let repository_did = "did:plc:repo123"; 163 164 - // Verify all signatures with repository validation 165 - // Remote attestations will be unverified without a record resolver 166 - let reports = verify_all_signatures(&signed_record, repository_did, None).await?; 167 - 168 - for report in reports { 169 - match report.status { 170 - VerificationStatus::Valid { cid } => { 171 - println!("✓ Signature {} is valid (CID: {})", report.index, cid); 172 - } 173 - VerificationStatus::Invalid { error } => { 174 - println!("✗ Signature {} is invalid: {}", report.index, error); 175 - } 176 - VerificationStatus::Unverified { reason } => { 177 - println!("? Signature {} unverified: {}", report.index, reason); 178 - } 179 - } 180 - } 181 - 182 - Ok(()) 183 - } 184 - ``` 185 - 186 - ### Verifying with Custom Key Resolver 187 - 188 - For signatures that reference DID document keys (not did:key), provide a key resolver: 189 - 190 - ```rust 191 - use atproto_attestation::verify_all_signatures; 192 - use atproto_identity::key::IdentityDocumentKeyResolver; 193 - use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 194 - use std::sync::Arc; 195 - 196 - #[tokio::main] 197 - async fn main() -> anyhow::Result<()> { 198 - let http_client = reqwest::Client::new(); 199 - let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 200 - 201 - // Create identity and key resolvers 202 - let identity_resolver = Arc::new(InnerIdentityResolver { 203 - http_client: http_client.clone(), 204 - dns_resolver: Arc::new(dns_resolver), 205 - plc_hostname: "plc.directory".to_string(), 206 - }); 207 - let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver); 208 209 - let signed_record = /* ... */; 210 - let repository_did = "did:plc:repo123"; 211 - 212 - // Verify with key resolver for DID document keys and repository validation 213 - let reports = verify_all_signatures( 214 - &signed_record, 215 repository_did, 216 - Some(&key_resolver) 217 ).await?; 218 219 - Ok(()) 220 - } 221 - ``` 222 - 223 - ### Verifying Remote Attestations 224 - 225 - To verify remote attestations (strongRef), use `verify_all_signatures_with_resolver` and provide a `RecordResolver` that can fetch proof records: 226 - 227 - ```rust 228 - use atproto_attestation::verify_all_signatures_with_resolver; 229 - use atproto_client::record_resolver::RecordResolver; 230 - use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 231 - use atproto_identity::traits::IdentityResolver; 232 - use std::sync::Arc; 233 - 234 - // Custom record resolver that resolves DIDs to find PDS endpoints 235 - struct MyRecordResolver { 236 - http_client: reqwest::Client, 237 - identity_resolver: InnerIdentityResolver, 238 - } 239 - 240 - #[async_trait::async_trait] 241 - impl RecordResolver for MyRecordResolver { 242 - async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T> 243 - where 244 - T: serde::de::DeserializeOwned + Send, 245 - { 246 - // Parse AT-URI, resolve DID to PDS, fetch record 247 - // See atproto-attestation-verify.rs for full implementation 248 - todo!() 249 - } 250 - } 251 - 252 - #[tokio::main] 253 - async fn main() -> anyhow::Result<()> { 254 - let http_client = reqwest::Client::new(); 255 - let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 256 - 257 - let identity_resolver = InnerIdentityResolver { 258 - http_client: http_client.clone(), 259 - dns_resolver: Arc::new(dns_resolver), 260 - plc_hostname: "plc.directory".to_string(), 261 - }; 262 - 263 - let record_resolver = MyRecordResolver { 264 - http_client, 265 - identity_resolver, 266 - }; 267 - 268 - let signed_record = /* ... */; 269 - let repository_did = "did:plc:repo123"; 270 - 271 - // Verify all signatures including remote attestations with repository validation 272 - let reports = verify_all_signatures_with_resolver( 273 - &signed_record, 274 - repository_did, 275 - None, 276 - Some(&record_resolver) 277 - ).await?; 278 279 Ok(()) 280 } 281 ``` 282 283 - ### Manual CID Generation 284 - 285 - For advanced use cases, manually generate CIDs: 286 - 287 - ```rust 288 - use atproto_attestation::{prepare_signing_record, create_cid}; 289 - use serde_json::json; 290 - 291 - let record = json!({ 292 - "$type": "app.bsky.feed.post", 293 - "text": "Manual CID generation" 294 - }); 295 - 296 - let metadata = json!({ 297 - "$type": "com.example.signature", 298 - "key": "did:key:z..." 299 - }); 300 - 301 - let repository_did = "did:plc:repo123"; 302 - 303 - // Prepare the signing record (adds $sig with repository field, removes signatures) 304 - // The repository field is automatically added to prevent replay attacks 305 - let signing_record = prepare_signing_record(&record, &metadata, repository_did)?; 306 - 307 - // Generate the CID (incorporates the repository binding) 308 - let cid = create_cid(&signing_record)?; 309 - println!("CID: {}", cid); 310 - ``` 311 - 312 ## Command Line Usage 313 314 ### Signing Records ··· 349 metadata.json 350 351 # This outputs TWO JSON objects: 352 - # 1. Proof record (store this in the repository) 353 # 2. Source record with strongRef attestation 354 ``` 355 356 ### Verifying Signatures 357 358 - #### Verify All Signatures in a Record 359 - 360 ```bash 361 # Verify all signatures in a record from file 362 cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 363 - ./signed_record.json 364 - 365 - # Verify all signatures from AT-URI (fetches from PDS) 366 - cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 367 - at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g 368 369 # Verify from stdin 370 - cat signed_record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- - 371 372 # Verify from inline JSON 373 cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 374 - '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' 375 376 - # Output shows each signature status: 377 - # ✓ Signature 0 valid (key: did:key:zQ3sh...pb3) [CID: bafyrei...] 378 - # ? Signature 1 unverified: Remote attestations require fetching the proof record via strongRef. 379 - # 380 - # Summary: 2 total, 1 valid 381 ``` 382 383 - #### Verify Specific Attestation Against Record 384 385 - ```bash 386 - # Verify a specific attestation record (both from files) 387 - cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 388 - ./record.json \ 389 - ./attestation.json 390 391 - # Verify attestation from AT-URI against local record 392 - cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 393 - ./record.json \ 394 - at://did:plc:xyz/com.example.attestation/abc123 395 396 - # On success, outputs: 397 - # OK 398 - # CID: bafyrei... 399 - ``` 400 401 ## Attestation Specification 402 ··· 404 405 1. **Deterministic signing**: Records are serialized to DAG-CBOR with `$sig` metadata, producing consistent CIDs 406 2. **Content addressing**: Signatures are over CID bytes, not the full record 407 - 3. **Flexible metadata**: Custom fields in `$sig` are preserved and included in the CID calculation 408 - 4. **Signature normalization**: ECDSA signatures are normalized to low-S form 409 - 5. **Multiple attestations**: Records can have multiple signatures in the `signatures` array 410 411 ### Signature Structure 412 ··· 416 "$type": "com.example.signature", 417 "key": "did:key:z...", 418 "issuer": "did:plc:...", 419 "signature": { 420 - "$bytes": "base64-signature" 421 } 422 } 423 ``` ··· 441 - `SignatureCreationFailed`: Key signing operation failed 442 - `SignatureValidationFailed`: Signature verification failed 443 - `SignatureNotNormalized`: ECDSA signature not in low-S form 444 - `KeyResolutionFailed`: Could not resolve verification key 445 - `UnsupportedKeyType`: Key type not supported for signing/verification 446 447 ## Security Considerations 448 ··· 461 All ECDSA signatures are automatically normalized to low-S form to prevent signature malleability attacks: 462 463 - The library enforces low-S normalization during signature creation 464 - - Verification rejects non-normalized signatures 465 - This prevents attackers from creating alternate valid signatures for the same content 466 467 ### Key Management Best Practices ··· 481 482 When creating attestations: 483 484 - - The `$type` field is always required in `$sig` metadata to scope the attestation 485 - The `repository` field is automatically added and must not be manually set 486 - Custom metadata fields are preserved and included in CID calculation 487 488 ### Remote Attestation Considerations 489
··· 1 # atproto-attestation 2 3 + Utilities for creating 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 and prevents replay attacks by: 10 + 1. Automatically preparing records with `$sig` metadata containing `$type` and `repository` fields 11 2. Generating content identifiers (CIDs) using DAG-CBOR serialization 12 3. Signing CID bytes with elliptic curve cryptography (for inline attestations) 13 + 4. Normalizing signatures to low-S form to prevent malleability attacks 14 + 5. Embedding signatures or creating proof records with strongRef references 15 16 + **Critical Security Feature**: The `repository` field in `$sig` metadata binds attestations to specific repositories, preventing replay attacks where an attacker might attempt to clone records from one repository into their own. 17 18 ## Features 19 ··· 21 - **Remote attestations**: Create separate proof records with CID-based strongRef references 22 - **CID-first workflow**: Deterministic signing based on content identifiers 23 - **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curves 24 + - **Signature normalization**: Automatic low-S normalization for ECDSA signatures to prevent malleability 25 + - **Flexible input types**: Accept records as JSON strings, JSON values, or typed lexicons 26 + - **Repository binding**: Automatic prevention of replay attacks 27 28 ## CLI Tools 29 ··· 39 Inline attestations embed the signature bytes directly in the record: 40 41 ```rust 42 + use atproto_identity::key::{generate_key, to_public, KeyType}; 43 + use atproto_attestation::{create_inline_attestation, input::{AnyInput, PhantomSignature}}; 44 use serde_json::json; 45 46 + fn main() -> anyhow::Result<()> { 47 + // Generate a signing key 48 + let private_key = generate_key(KeyType::K256Private)?; 49 let public_key = to_public(&private_key)?; 50 let key_reference = format!("{}", &public_key); 51 ··· 60 let repository_did = "did:plc:repo123"; 61 62 // Attestation metadata (required: $type and key for inline attestations) 63 + // Note: repository field is automatically added during CID generation 64 let sig_metadata = json!({ 65 "$type": "com.example.inlineSignature", 66 "key": &key_reference, ··· 69 }); 70 71 // Create inline attestation (repository_did is bound into the CID) 72 + // Signature is automatically normalized to low-S form 73 + let signed_record = create_inline_attestation::<PhantomSignature, PhantomSignature>( 74 + AnyInput::Json(record), 75 + AnyInput::Json(sig_metadata), 76 repository_did, 77 &private_key 78 )?; ··· 96 "key": "did:key:zQ3sh...", 97 "issuer": "did:plc:issuer123", 98 "issuedAt": "2024-01-01T00:00:00.000Z", 99 + "cid": "bafyrei...", 100 "signature": { 101 + "$bytes": "base64-encoded-normalized-signature-bytes" 102 } 103 } 104 ] ··· 110 Remote attestations create a separate proof record that must be stored in a repository: 111 112 ```rust 113 + use atproto_attestation::{create_remote_attestation, input::{AnyInput, PhantomSignature}}; 114 use serde_json::json; 115 116 + fn main() -> anyhow::Result<()> { 117 + let record = json!({ 118 + "$type": "app.bsky.feed.post", 119 + "text": "Hello world!" 120 + }); 121 122 + // Repository housing the original record (for replay attack prevention) 123 + let repository_did = "did:plc:repo123"; 124 125 + // DID of the entity creating the attestation (will store the proof record) 126 + let attestor_did = "did:plc:attestor456"; 127 128 + let metadata = json!({ 129 + "$type": "com.example.attestation", 130 + "issuer": "did:plc:issuer123", 131 + "purpose": "verification" 132 + }); 133 134 + // Create both the attested record and proof record in one call 135 + // Returns: (attested_record_with_strongRef, proof_record) 136 + let (attested_record, proof_record) = create_remote_attestation::<PhantomSignature, PhantomSignature>( 137 + AnyInput::Json(record), 138 + AnyInput::Json(metadata), 139 + repository_did, // Repository housing the original record 140 + attestor_did // Repository that will store the proof record 141 + )?; 142 143 + // The proof_record should be stored in the attestor's repository 144 + // The attested_record contains the strongRef reference 145 + println!("Proof record:\n{}", serde_json::to_string_pretty(&proof_record)?); 146 + println!("Attested record:\n{}", serde_json::to_string_pretty(&attested_record)?); 147 148 + Ok(()) 149 + } 150 ``` 151 152 ### Verifying Signatures 153 154 + Verify all signatures in a record: 155 156 ```rust 157 + use atproto_attestation::{verify_record, input::AnyInput}; 158 + use atproto_identity::key::IdentityDocumentKeyResolver; 159 + use atproto_client::record_resolver::HttpRecordResolver; 160 161 #[tokio::main] 162 async fn main() -> anyhow::Result<()> { ··· 167 // CRITICAL: This must match the repository used during signing to prevent replay attacks 168 let repository_did = "did:plc:repo123"; 169 170 + // Create resolvers for key and record fetching 171 + let key_resolver = /* ... */; // IdentityDocumentKeyResolver 172 + let record_resolver = HttpRecordResolver::new(/* ... */); 173 174 + // Verify all signatures with repository validation 175 + verify_record( 176 + AnyInput::Json(signed_record), 177 repository_did, 178 + key_resolver, 179 + record_resolver 180 ).await?; 181 182 + println!("✓ All signatures verified successfully"); 183 184 Ok(()) 185 } 186 ``` 187 188 ## Command Line Usage 189 190 ### Signing Records ··· 225 metadata.json 226 227 # This outputs TWO JSON objects: 228 + # 1. Proof record (store this in the attestor's repository) 229 # 2. Source record with strongRef attestation 230 ``` 231 232 ### Verifying Signatures 233 234 ```bash 235 # Verify all signatures in a record from file 236 cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 237 + ./signed_record.json \ 238 + did:plc:repo123 239 240 # Verify from stdin 241 + cat signed_record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 242 + - \ 243 + did:plc:repo123 244 245 # Verify from inline JSON 246 cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 247 + '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' \ 248 + did:plc:repo123 249 250 + # Verify specific attestation against record 251 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 252 + ./record.json \ 253 + did:plc:repo123 \ 254 + ./attestation.json 255 ``` 256 257 + ## Public API 258 + 259 + The crate exposes the following public functions: 260 261 + ### Attestation Creation 262 263 + - **`create_inline_attestation`**: Create a signed record with embedded signature 264 + - Automatically normalizes signatures to low-S form 265 + - Binds attestation to repository DID 266 + - Returns signed record with `signatures` array 267 268 + - **`create_remote_attestation`**: Create separate proof record and strongRef 269 + - Returns tuple of (attested_record, proof_record) 270 + - Proof record must be stored in attestor's repository 271 + 272 + ### CID Generation 273 + 274 + - **`create_cid`**: Generate CID for a record with `$sig` metadata 275 + - **`create_dagbor_cid`**: Generate CID for any serializable data 276 + - **`create_attestation_cid`**: High-level CID generation with automatic `$sig` preparation 277 + 278 + ### Signature Operations 279 + 280 + - **`normalize_signature`**: Normalize raw signature bytes to low-S form 281 + - Prevents signature malleability attacks 282 + - Supports P-256, P-384, and K-256 curves 283 + 284 + ### Verification 285 + 286 + - **`verify_record`**: Verify all signatures in a record 287 + - Validates repository binding 288 + - Supports both inline and remote attestations 289 + - Requires key and record resolvers 290 + 291 + ### Input Types 292 + 293 + - **`AnyInput`**: Flexible input enum supporting: 294 + - `String`: JSON string to parse 295 + - `Json`: serde_json::Value 296 + - `TypedLexicon`: Strongly-typed lexicon records 297 298 ## Attestation Specification 299 ··· 301 302 1. **Deterministic signing**: Records are serialized to DAG-CBOR with `$sig` metadata, producing consistent CIDs 303 2. **Content addressing**: Signatures are over CID bytes, not the full record 304 + 3. **Repository binding**: Every attestation is bound to a specific repository DID to prevent replay attacks 305 + 4. **Signature normalization**: ECDSA signatures are normalized to low-S form to prevent malleability 306 + 5. **Flexible metadata**: Custom fields in `$sig` are preserved and included in the CID calculation 307 + 6. **Multiple attestations**: Records can have multiple signatures in the `signatures` array 308 309 ### Signature Structure 310 ··· 314 "$type": "com.example.signature", 315 "key": "did:key:z...", 316 "issuer": "did:plc:...", 317 + "cid": "bafyrei...", 318 "signature": { 319 + "$bytes": "base64-normalized-signature" 320 } 321 } 322 ``` ··· 340 - `SignatureCreationFailed`: Key signing operation failed 341 - `SignatureValidationFailed`: Signature verification failed 342 - `SignatureNotNormalized`: ECDSA signature not in low-S form 343 + - `SignatureLengthInvalid`: Signature bytes have incorrect length 344 - `KeyResolutionFailed`: Could not resolve verification key 345 - `UnsupportedKeyType`: Key type not supported for signing/verification 346 + - `RemoteAttestationFetchFailed`: Failed to fetch remote proof record 347 348 ## Security Considerations 349 ··· 362 All ECDSA signatures are automatically normalized to low-S form to prevent signature malleability attacks: 363 364 - The library enforces low-S normalization during signature creation 365 + - Verification accepts only normalized signatures 366 - This prevents attackers from creating alternate valid signatures for the same content 367 368 ### Key Management Best Practices ··· 382 383 When creating attestations: 384 385 + - The `$type` field is always required in metadata to scope the attestation 386 - The `repository` field is automatically added and must not be manually set 387 - Custom metadata fields are preserved and included in CID calculation 388 + - The `cid` field is automatically added to inline attestation metadata 389 390 ### Remote Attestation Considerations 391
+419 -307
crates/atproto-attestation/src/attestation.rs
··· 1 //! Core attestation creation functions. 2 //! 3 - //! This module provides functions for creating inline and remote attestations, 4 - //! preparing records for signing, and attaching attestation references. 5 6 - use crate::cid::{create_cid, create_plain_cid}; 7 use crate::errors::AttestationError; 8 use crate::signature::normalize_signature; 9 - use crate::utils::{extract_signatures_vec, BASE64, STRONG_REF_TYPE}; 10 - use atproto_identity::key::{KeyData, sign}; 11 use atproto_record::tid::Tid; 12 use base64::Engine; 13 - use serde_json::{json, Value}; 14 15 - /// Prepare a record for signing by removing attestation artifacts and adding `$sig`. 16 /// 17 - /// - Removes any existing `signatures`, `sigs`, and `$sig` fields. 18 - /// - Inserts the provided `attestation` metadata as the new `$sig` object. 19 - /// - Ensures the metadata contains a string `$type` discriminator. 20 - /// - Ensures the metadata contains a `repository` field with the repository DID to prevent replay attacks. 21 /// 22 /// # Arguments 23 /// 24 - /// * `record` - The record to prepare for signing 25 - /// * `attestation` - The attestation metadata to include as `$sig` 26 - /// * `repository_did` - The DID of the repository housing this record 27 /// 28 /// # Returns 29 /// 30 - /// The prepared record with `$sig` metadata 31 /// 32 /// # Errors 33 /// 34 /// Returns an error if: 35 - /// - The record or attestation are not JSON objects 36 - /// - The attestation metadata is missing the required `$type` field 37 - pub fn prepare_signing_record( 38 - record: &Value, 39 - attestation: &Value, 40 - repository_did: &str, 41 - ) -> Result<Value, AttestationError> { 42 - let mut prepared = record 43 - .as_object() 44 - .cloned() 45 - .ok_or(AttestationError::RecordMustBeObject)?; 46 47 - let mut sig_metadata = attestation 48 - .as_object() 49 - .cloned() 50 - .ok_or(AttestationError::MetadataMustBeObject)?; 51 52 - if sig_metadata 53 - .get("$type") 54 - .and_then(Value::as_str) 55 - .filter(|value| !value.is_empty()).is_none() 56 - { 57 - return Err(AttestationError::MetadataMissingSigType); 58 - } 59 60 - // CRITICAL: Always set repository field for attestations to prevent replay attacks 61 - sig_metadata.insert("repository".to_string(), Value::String(repository_did.to_string())); 62 63 - sig_metadata.remove("signature"); 64 - sig_metadata.remove("cid"); 65 66 - prepared.remove("signatures"); 67 - prepared.remove("sigs"); 68 - prepared.remove("$sig"); 69 - prepared.insert("$sig".to_string(), Value::Object(sig_metadata)); 70 71 - Ok(Value::Object(prepared)) 72 } 73 74 - /// Creates an inline attestation by signing the prepared record with the provided key. 75 /// 76 - /// Signs the prepared record with the provided key and includes the repository DID 77 - /// in the `$sig` metadata during CID generation to bind the attestation to a specific repository. 78 /// 79 /// # Arguments 80 /// 81 - /// * `record` - The record to sign 82 - /// * `attestation_metadata` - The attestation metadata (must include `$type` and `key`) 83 - /// * `repository_did` - The DID of the repository housing this record 84 - /// * `signing_key` - The private key to use for signing 85 /// 86 /// # Returns 87 /// 88 - /// The signed record with an inline attestation in the `signatures` array 89 /// 90 /// # Errors 91 /// 92 /// Returns an error if: 93 - /// - Record preparation fails 94 /// - CID generation fails 95 - /// - Signature creation fails 96 - pub fn create_inline_attestation( 97 - record: &Value, 98 - attestation_metadata: &Value, 99 - repository_did: &str, 100 - signing_key: &KeyData, 101 ) -> Result<Value, AttestationError> { 102 - let signing_record = prepare_signing_record(record, attestation_metadata, repository_did)?; 103 - let cid = create_cid(&signing_record)?; 104 105 - let raw_signature = sign(signing_key, &cid.to_bytes()) 106 - .map_err(|error| AttestationError::SignatureCreationFailed { error })?; 107 - let signature_bytes = normalize_signature(raw_signature, signing_key.key_type())?; 108 109 - let mut inline_object = attestation_metadata 110 - .as_object() 111 - .cloned() 112 - .ok_or(AttestationError::MetadataMustBeObject)?; 113 114 - inline_object.remove("signature"); 115 - inline_object.remove("cid"); 116 - inline_object.remove("repository"); // Don't include repository in final attestation object 117 - inline_object.insert( 118 - "signature".to_string(), 119 - json!({"$bytes": BASE64.encode(signature_bytes)}), 120 - ); 121 122 - create_inline_attestation_reference(record, &Value::Object(inline_object)) 123 } 124 125 - /// Creates a remote attestation by generating a proof record and strongRef entry. 126 /// 127 - /// Generates a proof record containing the CID with the repository DID included 128 - /// in the `$sig` metadata during CID generation to bind the attestation to a specific repository. 129 /// 130 /// # Arguments 131 /// 132 - /// * `record` - The record to attest 133 - /// * `attestation_metadata` - The attestation metadata (must include `$type`) 134 - /// * `repository_did` - The DID of the repository housing the original record 135 /// 136 /// # Returns 137 /// 138 - /// The remote proof record for storage in a repository 139 /// 140 /// # Errors 141 /// 142 /// Returns an error if: 143 - /// - The attestation metadata is not a JSON object 144 - /// - Record preparation fails 145 - /// - CID generation fails 146 - pub fn create_remote_attestation( 147 - record: &Value, 148 - attestation_metadata: &Value, 149 - repository_did: &str, 150 - ) -> Result<Value, AttestationError> { 151 - let metadata = attestation_metadata 152 - .as_object() 153 - .cloned() 154 - .ok_or(AttestationError::MetadataMustBeObject)?; 155 - 156 - let metadata_value = Value::Object(metadata.clone()); 157 - let signing_record = prepare_signing_record(record, &metadata_value, repository_did)?; 158 - let cid = create_cid(&signing_record)?; 159 - 160 - let mut remote_attestation = metadata.clone(); 161 - remote_attestation.remove("repository"); // Don't include repository in final proof record 162 - remote_attestation.insert("cid".to_string(), Value::String(cid.to_string())); 163 - 164 - Ok(Value::Object(remote_attestation)) 165 - } 166 - 167 - /// Attach a remote attestation entry (strongRef) to the record. 168 /// 169 - /// The `attestation` value must be an object containing: 170 - /// - `$type`: The type of the proof record 171 - /// - `cid`: The CID of the attested content 172 /// 173 - /// # Arguments 174 /// 175 - /// * `record` - The record to add the attestation to 176 - /// * `attestation` - The proof record that will be referenced 177 - /// * `did` - The DID where the proof record is stored 178 /// 179 - /// # Returns 180 /// 181 - /// The record with a strongRef attestation in the `signatures` array 182 /// 183 - /// # Errors 184 /// 185 - /// Returns an error if: 186 - /// - The record or attestation are not JSON objects 187 - /// - The attestation is missing required fields 188 - pub fn create_remote_attestation_reference( 189 - record: &Value, 190 - attestation: &Value, 191 - did: &str, 192 - ) -> Result<Value, AttestationError> { 193 - let mut result = record 194 - .as_object() 195 - .cloned() 196 - .ok_or(AttestationError::RecordMustBeObject)?; 197 198 - let attestation = attestation 199 - .as_object() 200 - .cloned() 201 - .ok_or(AttestationError::MetadataMustBeObject)?; 202 203 - let remote_object_type = attestation 204 - .get("$type") 205 .and_then(Value::as_str) 206 .filter(|value| !value.is_empty()) 207 - .ok_or(AttestationError::RemoteAttestationMissingCid)?; 208 209 - let tid = Tid::new(); 210 211 - let attestation_cid = create_plain_cid(&serde_json::Value::Object(attestation.clone()))?; 212 213 - let remote_object = json!({ 214 - "$type": STRONG_REF_TYPE, 215 - "uri": format!("at://{did}/{remote_object_type}/{tid}"), 216 - "cid": attestation_cid.to_string() 217 }); 218 219 - let mut signatures = extract_signatures_vec(&mut result)?; 220 - signatures.push(remote_object); 221 - result.insert("signatures".to_string(), Value::Array(signatures)); 222 223 - Ok(Value::Object(result)) 224 } 225 226 - /// Attach an inline attestation entry containing signature bytes. 227 /// 228 - /// The `attestation` value must be an object containing: 229 - /// - `$type`: union discriminator (must NOT be `com.atproto.repo.strongRef`) 230 - /// - `key`: verification method reference used to sign 231 - /// - `signature`: object with `$bytes` base64 signature 232 /// 233 - /// Additional custom fields are preserved for `$sig` metadata. 234 /// 235 /// # Arguments 236 /// 237 - /// * `record` - The record to add the attestation to 238 - /// * `attestation` - The inline attestation object with signature 239 /// 240 /// # Returns 241 /// 242 - /// The record with an inline attestation in the `signatures` array 243 /// 244 /// # Errors 245 /// 246 /// Returns an error if: 247 - /// - The record or attestation are not JSON objects 248 - /// - The attestation is missing required fields or has invalid type 249 - /// - The signature bytes are malformed 250 - pub fn create_inline_attestation_reference( 251 - record: &Value, 252 - attestation: &Value, 253 - ) -> Result<Value, AttestationError> { 254 - let mut result = record 255 - .as_object() 256 - .cloned() 257 - .ok_or(AttestationError::RecordMustBeObject)?; 258 259 - let inline_object = attestation 260 - .as_object() 261 - .cloned() 262 - .ok_or(AttestationError::MetadataMustBeObject)?; 263 264 - let signature_type = inline_object 265 - .get("$type") 266 - .and_then(Value::as_str) 267 - .ok_or_else(|| AttestationError::MetadataMissingField { 268 - field: "$type".to_string(), 269 - })?; 270 271 - if signature_type == STRONG_REF_TYPE { 272 - return Err(AttestationError::InlineAttestationTypeInvalid); 273 - } 274 - 275 - inline_object 276 .get("key") 277 .and_then(Value::as_str) 278 .filter(|value| !value.is_empty()) 279 - .ok_or_else(|| AttestationError::SignatureMissingField { 280 field: "key".to_string(), 281 })?; 282 283 - let signature_bytes = inline_object 284 .get("signature") 285 .and_then(Value::as_object) 286 .and_then(|object| object.get("$bytes")) 287 .and_then(Value::as_str) 288 - .filter(|value| !value.is_empty()) 289 .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 290 291 - // Ensure the signature bytes decode cleanly to catch malformed input early. 292 - let _ = BASE64 293 .decode(signature_bytes) 294 .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 295 296 - let mut signatures = extract_signatures_vec(&mut result)?; 297 - signatures.push(Value::Object(inline_object)); 298 - result.insert("signatures".to_string(), Value::Array(signatures)); 299 - result.remove("$sig"); 300 301 - Ok(Value::Object(result)) 302 } 303 304 #[cfg(test)] ··· 308 use serde_json::json; 309 310 #[test] 311 - fn prepare_signing_record_removes_signatures() -> Result<(), AttestationError> { 312 - let repository_did = "did:plc:test"; 313 - let record = json!({ 314 - "$type": "app.bsky.feed.post", 315 - "text": "hello", 316 - "signatures": [ 317 - {"$type": "example.sig", "signature": {"$bytes": "dGVzdA=="}, "key": "did:key:zabc"} 318 - ] 319 - }); 320 321 - let metadata = json!({ 322 - "$type": "com.example.inlineSignature", 323 - "key": "did:key:zabc", 324 - "purpose": "demo", 325 - "signature": {"$bytes": "trim"}, 326 - "cid": "bafyignored" 327 - }); 328 - 329 - let prepared = prepare_signing_record(&record, &metadata, repository_did)?; 330 - let object = prepared.as_object().unwrap(); 331 - assert!(object.get("signatures").is_none()); 332 - assert!(object.get("sigs").is_none()); 333 - assert!(object.get("$sig").is_some()); 334 - 335 - let sig_object = object.get("$sig").unwrap().as_object().unwrap(); 336 - assert_eq!( 337 - sig_object.get("$type").and_then(Value::as_str), 338 - Some("com.example.inlineSignature") 339 - ); 340 - assert_eq!( 341 - sig_object.get("repository").and_then(Value::as_str), 342 - Some(repository_did) 343 - ); 344 - assert_eq!( 345 - sig_object.get("purpose").and_then(Value::as_str), 346 - Some("demo") 347 - ); 348 - assert!(sig_object.get("signature").is_none()); 349 - assert!(sig_object.get("cid").is_none()); 350 - 351 - Ok(()) 352 - } 353 - 354 - #[test] 355 - fn create_inline_attestation_appends_signature() -> Result<(), AttestationError> { 356 - let record = json!({ 357 - "$type": "app.example.record", 358 - "body": "Important content" 359 - }); 360 - 361 - let inline = json!({ 362 - "$type": "com.example.inlineSignature", 363 - "key": "did:key:zabc", 364 - "signature": {"$bytes": "ZHVtbXk="} 365 - }); 366 - 367 - let updated = create_inline_attestation_reference(&record, &inline)?; 368 - let signatures = updated 369 - .get("signatures") 370 - .and_then(Value::as_array) 371 - .expect("signatures array should exist"); 372 - assert_eq!(signatures.len(), 1); 373 - assert_eq!( 374 - signatures[0].get("$type").and_then(Value::as_str), 375 - Some("com.example.inlineSignature") 376 - ); 377 - 378 - Ok(()) 379 - } 380 - 381 - #[test] 382 - fn create_remote_attestation_produces_proof_record() -> Result<(), Box<dyn std::error::Error>> { 383 let record = json!({ 384 "$type": "app.example.record", 385 "body": "remote attestation" ··· 389 "$type": "com.example.attestation" 390 }); 391 392 - let proof_record = create_remote_attestation(&record, &metadata, "did:plc:test")?; 393 394 - let proof_object = proof_record 395 - .as_object() 396 - .expect("proof should be an object"); 397 assert_eq!( 398 proof_object.get("$type").and_then(Value::as_str), 399 Some("com.example.attestation") ··· 407 "repository should not be in final proof record" 408 ); 409 410 - Ok(()) 411 - } 412 - 413 - #[test] 414 - fn prepare_signing_record_enforces_repository() -> Result<(), AttestationError> { 415 - let record = json!({ 416 - "$type": "app.example.record", 417 - "text": "Test content" 418 - }); 419 - 420 - let metadata = json!({ 421 - "$type": "com.example.attestationType", 422 - "purpose": "test" 423 - }); 424 - 425 - let repository_did = "did:plc:testrepo123"; 426 427 - // Prepare with repository field 428 - let prepared = prepare_signing_record(&record, &metadata, repository_did)?; 429 - let prepared_obj = prepared.as_object().unwrap(); 430 - let sig_obj = prepared_obj.get("$sig").unwrap().as_object().unwrap(); 431 - 432 - // Verify repository field is set correctly 433 assert_eq!( 434 - sig_obj.get("repository").and_then(Value::as_str), 435 - Some(repository_did) 436 ); 437 - 438 - // Verify $type is preserved 439 - assert_eq!( 440 - sig_obj.get("$type").and_then(Value::as_str), 441 - Some("com.example.attestationType") 442 ); 443 - 444 - // Verify original metadata fields are preserved 445 - assert_eq!( 446 - sig_obj.get("purpose").and_then(Value::as_str), 447 - Some("test") 448 ); 449 450 Ok(()) ··· 469 }); 470 471 let signed = create_inline_attestation( 472 - &base_record, 473 - &sig_metadata, 474 repository_did, 475 &private_key, 476 )?; ··· 489 ); 490 assert!(sig.get("signature").is_some()); 491 assert!(sig.get("key").is_some()); 492 - assert!(sig.get("repository").is_none()); // Should not be in final signature 493 494 Ok(()) 495 } 496 - }
··· 1 //! Core attestation creation functions. 2 //! 3 + //! This module provides functions for creating inline and remote attestations 4 + //! and attaching attestation references. 5 6 + use crate::cid::{create_attestation_cid, create_dagbor_cid}; 7 use crate::errors::AttestationError; 8 + pub use crate::input::AnyInput; 9 use crate::signature::normalize_signature; 10 + use crate::utils::BASE64; 11 + use atproto_identity::key::{KeyData, KeyResolver, sign, validate}; 12 + use atproto_record::lexicon::com::atproto::repo::STRONG_REF_NSID; 13 use atproto_record::tid::Tid; 14 use base64::Engine; 15 + use serde::Serialize; 16 + use serde_json::{Value, json, Map}; 17 + use std::convert::TryInto; 18 19 + /// Helper function to extract and validate signatures array from a record 20 + fn extract_signatures(record_obj: &Map<String, Value>) -> Result<Vec<Value>, AttestationError> { 21 + match record_obj.get("signatures") { 22 + Some(value) => value 23 + .as_array() 24 + .ok_or(AttestationError::SignaturesFieldInvalid) 25 + .cloned(), 26 + None => Ok(vec![]), 27 + } 28 + } 29 + 30 + /// Helper function to append a signature to a record and return the modified record 31 + fn append_signature_to_record( 32 + mut record_obj: Map<String, Value>, 33 + signature: Value, 34 + ) -> Result<Value, AttestationError> { 35 + let mut signatures = extract_signatures(&record_obj)?; 36 + signatures.push(signature); 37 + 38 + record_obj.insert( 39 + "signatures".to_string(), 40 + Value::Array(signatures), 41 + ); 42 + 43 + Ok(Value::Object(record_obj)) 44 + } 45 + 46 + /// Creates a remote attestation with both the attested record and proof record. 47 + /// 48 + /// This is the recommended way to create remote attestations. It generates both: 49 + /// 1. The attested record with a strongRef in the signatures array 50 + /// 2. The proof record containing the CID to be stored in the attestation repository 51 /// 52 + /// The CID is generated with the repository DID included in the `$sig` metadata 53 + /// to bind the attestation to a specific repository and prevent replay attacks. 54 /// 55 /// # Arguments 56 /// 57 + /// * `record_input` - The record to attest (as AnyInput: String, Json, or TypedLexicon) 58 + /// * `metadata_input` - The attestation metadata (must include `$type`) 59 + /// * `repository` - The DID of the repository housing the original record 60 + /// * `attestation_repository` - The DID of the repository that will store the proof record 61 /// 62 /// # Returns 63 /// 64 + /// A tuple containing: 65 + /// * `(attested_record, proof_record)` - Both records needed for remote attestation 66 /// 67 /// # Errors 68 /// 69 /// Returns an error if: 70 + /// - The record or metadata are not valid JSON objects 71 + /// - The metadata is missing the required `$type` field 72 + /// - CID generation fails 73 + /// 74 + /// # Example 75 + /// 76 + /// ```rust 77 + /// use atproto_attestation::{create_remote_attestation, input::AnyInput}; 78 + /// use serde_json::json; 79 + /// 80 + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 81 + /// let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 82 + /// let metadata = json!({"$type": "com.example.attestation"}); 83 + /// 84 + /// let (attested_record, proof_record) = create_remote_attestation( 85 + /// AnyInput::Serialize(record), 86 + /// AnyInput::Serialize(metadata), 87 + /// "did:plc:repo123", // Source repository 88 + /// "did:plc:attestor456" // Attestation repository 89 + /// )?; 90 + /// # Ok(()) 91 + /// # } 92 + /// ``` 93 + pub fn create_remote_attestation< 94 + R: Serialize + Clone, 95 + M: Serialize + Clone, 96 + >( 97 + record_input: AnyInput<R>, 98 + metadata_input: AnyInput<M>, 99 + repository: &str, 100 + attestation_repository: &str, 101 + ) -> Result<(Value, Value), AttestationError> { 102 + // Step 1: Create a content CID 103 + let content_cid = 104 + create_attestation_cid(record_input.clone(), metadata_input.clone(), repository)?; 105 106 + let record_obj: Map<String, Value> = record_input 107 + .try_into() 108 + .map_err(|_| AttestationError::RecordMustBeObject)?; 109 + 110 + // Step 2: Create the remote attestation record 111 + let (remote_attestation_record, remote_attestation_type) = { 112 + let mut metadata_obj: Map<String, Value> = metadata_input 113 + .try_into() 114 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 115 + 116 + // Extract the type from metadata before modifying it 117 + let remote_type = metadata_obj 118 + .get("$type") 119 + .and_then(Value::as_str) 120 + .ok_or(AttestationError::MetadataMissingType)? 121 + .to_string(); 122 123 + metadata_obj.insert("cid".to_string(), Value::String(content_cid.to_string())); 124 + (serde_json::Value::Object(metadata_obj), remote_type) 125 + }; 126 127 + // Step 3: Create the remote attestation reference (type, AT-URI, and CID) 128 + let remote_attestation_record_key = Tid::new(); 129 + let remote_attestation_cid = create_dagbor_cid(&remote_attestation_record)?; 130 131 + let attestation_reference = json!({ 132 + "$type": STRONG_REF_NSID, 133 + "uri": format!("at://{attestation_repository}/{remote_attestation_type}/{remote_attestation_record_key}"), 134 + "cid": remote_attestation_cid.to_string() 135 + }); 136 137 + // Step 4: Append the attestation reference to the record "signatures" array 138 + let attested_record = append_signature_to_record(record_obj, attestation_reference)?; 139 140 + Ok((attested_record, remote_attestation_record)) 141 } 142 143 + /// Creates an inline attestation with signature embedded in the record. 144 + /// 145 + /// This is the v2 API that supports flexible input types (String, Json, TypedLexicon) 146 + /// and provides a more streamlined interface for creating inline attestations. 147 /// 148 + /// The CID is generated with the repository DID included in the `$sig` metadata 149 + /// to bind the attestation to a specific repository and prevent replay attacks. 150 /// 151 /// # Arguments 152 /// 153 + /// * `record_input` - The record to sign (as AnyInput: String, Json, or TypedLexicon) 154 + /// * `metadata_input` - The attestation metadata (must include `$type` and `key`) 155 + /// * `repository` - The DID of the repository that will house this record 156 + /// * `private_key_data` - The private key to use for signing 157 /// 158 /// # Returns 159 /// 160 + /// The record with an inline attestation embedded in the signatures array 161 /// 162 /// # Errors 163 /// 164 /// Returns an error if: 165 + /// - The record or metadata are not valid JSON objects 166 + /// - The metadata is missing required fields 167 + /// - Signature creation fails 168 /// - CID generation fails 169 + /// 170 + /// # Example 171 + /// 172 + /// ```rust 173 + /// use atproto_attestation::{create_inline_attestation, input::AnyInput}; 174 + /// use atproto_identity::key::{KeyType, generate_key, to_public}; 175 + /// use serde_json::json; 176 + /// 177 + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 178 + /// let private_key = generate_key(KeyType::K256Private)?; 179 + /// let public_key = to_public(&private_key)?; 180 + /// 181 + /// let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 182 + /// let metadata = json!({ 183 + /// "$type": "com.example.signature", 184 + /// "key": format!("{}", public_key) 185 + /// }); 186 + /// 187 + /// let signed_record = create_inline_attestation( 188 + /// AnyInput::Serialize(record), 189 + /// AnyInput::Serialize(metadata), 190 + /// "did:plc:repo123", 191 + /// &private_key 192 + /// )?; 193 + /// # Ok(()) 194 + /// # } 195 + /// ``` 196 + pub fn create_inline_attestation< 197 + R: Serialize + Clone, 198 + M: Serialize + Clone, 199 + >( 200 + record_input: AnyInput<R>, 201 + metadata_input: AnyInput<M>, 202 + repository: &str, 203 + private_key_data: &KeyData, 204 ) -> Result<Value, AttestationError> { 205 + // Step 1: Create a content CID 206 + let content_cid = 207 + create_attestation_cid(record_input.clone(), metadata_input.clone(), repository)?; 208 + 209 + let record_obj: Map<String, Value> = record_input 210 + .try_into() 211 + .map_err(|_| AttestationError::RecordMustBeObject)?; 212 + 213 + // Step 2: Create the inline attestation record 214 + let inline_attestation_record = { 215 + let mut metadata_obj: Map<String, Value> = metadata_input 216 + .try_into() 217 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 218 + 219 + metadata_obj.insert("cid".to_string(), Value::String(content_cid.to_string())); 220 221 + let raw_signature = sign(private_key_data, &content_cid.to_bytes()) 222 + .map_err(|error| AttestationError::SignatureCreationFailed { error })?; 223 + let signature_bytes = normalize_signature(raw_signature, private_key_data.key_type())?; 224 225 + metadata_obj.insert( 226 + "signature".to_string(), 227 + json!({"$bytes": BASE64.encode(signature_bytes)}), 228 + ); 229 230 + serde_json::Value::Object(metadata_obj) 231 + }; 232 233 + // Step 4: Append the attestation reference to the record "signatures" array 234 + append_signature_to_record(record_obj, inline_attestation_record) 235 } 236 237 + /// Validates an existing proof record and appends a strongRef to it in the record's signatures array. 238 + /// 239 + /// This function validates that an existing proof record (attestation metadata with CID) 240 + /// is valid for the given record and repository, then creates and appends a strongRef to it. 241 + /// 242 + /// Unlike `create_remote_attestation` which creates a new proof record, this function validates 243 + /// an existing proof record that was already created and stored in an attestor's repository. 244 /// 245 + /// # Security 246 + /// 247 + /// - **Repository binding validation**: Ensures the attestation was created for the specified repository DID 248 + /// - **CID verification**: Validates the proof record's CID matches the computed CID 249 + /// - **Content validation**: Ensures the proof record content matches what should be attested 250 + /// 251 + /// # Workflow 252 + /// 253 + /// 1. Compute the content CID from record + metadata + repository (same as attestation creation) 254 + /// 2. Extract the claimed CID from the proof record metadata 255 + /// 3. Verify the claimed CID matches the computed CID 256 + /// 4. Extract the proof record's storage CID (DAG-CBOR CID of the full proof record) 257 + /// 5. Create a strongRef with the AT-URI and proof record CID 258 + /// 6. Append the strongRef to the record's signatures array 259 /// 260 /// # Arguments 261 /// 262 + /// * `record_input` - The record to append the attestation to (as AnyInput) 263 + /// * `metadata_input` - The proof record metadata (must include `$type`, `cid`, and attestation fields) 264 + /// * `repository` - The repository DID where the source record is stored (for replay attack prevention) 265 + /// * `attestation_uri` - The AT-URI where the proof record is stored (e.g., "at://did:plc:attestor/com.example.attestation/abc123") 266 /// 267 /// # Returns 268 /// 269 + /// The modified record with the strongRef appended to its `signatures` array 270 /// 271 /// # Errors 272 /// 273 /// Returns an error if: 274 + /// - The record or metadata are not valid JSON objects 275 + /// - The metadata is missing the `cid` field 276 + /// - The computed CID doesn't match the claimed CID in the metadata 277 + /// - The metadata is missing required attestation fields 278 /// 279 + /// # Type Parameters 280 /// 281 + /// * `R` - The record type (must implement Serialize + LexiconType + PartialEq + Clone) 282 + /// * `A` - The attestation type (must implement Serialize + LexiconType + PartialEq + Clone) 283 /// 284 + /// # Example 285 /// 286 + /// ```ignore 287 + /// use atproto_attestation::{append_remote_attestation, input::AnyInput}; 288 + /// use serde_json::json; 289 /// 290 + /// let record = json!({ 291 + /// "$type": "app.bsky.feed.post", 292 + /// "text": "Hello world!" 293 + /// }); 294 /// 295 + /// // This is the proof record that was previously created and stored 296 + /// let proof_metadata = json!({ 297 + /// "$type": "com.example.attestation", 298 + /// "issuer": "did:plc:issuer", 299 + /// "cid": "bafyrei...", // Content CID computed from record+metadata+repository 300 + /// // ... other attestation fields 301 + /// }); 302 /// 303 + /// let repository_did = "did:plc:repo123"; 304 + /// let attestation_uri = "at://did:plc:attestor456/com.example.attestation/abc123"; 305 + /// 306 + /// let signed_record = append_remote_attestation( 307 + /// AnyInput::Serialize(record), 308 + /// AnyInput::Serialize(proof_metadata), 309 + /// repository_did, 310 + /// attestation_uri 311 + /// )?; 312 + /// ``` 313 + pub fn append_remote_attestation<R, A>( 314 + record_input: AnyInput<R>, 315 + metadata_input: AnyInput<A>, 316 + repository: &str, 317 + attestation_uri: &str, 318 + ) -> Result<Value, AttestationError> 319 + where 320 + R: Serialize + Clone, 321 + A: Serialize + Clone, 322 + { 323 + // Step 1: Compute the content CID (same as create_remote_attestation) 324 + let content_cid = 325 + create_attestation_cid(record_input.clone(), metadata_input.clone(), repository)?; 326 327 + // Step 2: Convert metadata to JSON and extract the claimed CID 328 + let metadata_obj: Map<String, Value> = metadata_input 329 + .try_into() 330 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 331 332 + let claimed_cid = metadata_obj 333 + .get("cid") 334 .and_then(Value::as_str) 335 .filter(|value| !value.is_empty()) 336 + .ok_or(AttestationError::SignatureMissingField { 337 + field: "cid".to_string(), 338 + })?; 339 340 + // Step 3: Verify the claimed CID matches the computed content CID 341 + if content_cid.to_string() != claimed_cid { 342 + return Err(AttestationError::RemoteAttestationCidMismatch { 343 + expected: claimed_cid.to_string(), 344 + actual: content_cid.to_string(), 345 + }); 346 + } 347 348 + // Step 4: Compute the proof record's DAG-CBOR CID 349 + let proof_record_cid = create_dagbor_cid(&metadata_obj)?; 350 351 + // Step 5: Create the strongRef 352 + let strongref = json!({ 353 + "$type": STRONG_REF_NSID, 354 + "uri": attestation_uri, 355 + "cid": proof_record_cid.to_string() 356 }); 357 358 + // Step 6: Convert record to JSON object and append the strongRef 359 + let record_obj: Map<String, Value> = record_input 360 + .try_into() 361 + .map_err(|_| AttestationError::RecordMustBeObject)?; 362 363 + append_signature_to_record(record_obj, strongref) 364 } 365 366 + /// Validates an inline attestation and appends it to a record's signatures array. 367 /// 368 + /// Inline attestations contain cryptographic signatures embedded directly in the record. 369 + /// This function validates the attestation signature against the record and repository, 370 + /// then appends it if validation succeeds. 371 + /// 372 + /// # Security 373 /// 374 + /// - **Repository binding validation**: Ensures the attestation was created for the specified repository DID 375 + /// - **CID verification**: Validates the CID in the attestation matches the computed CID 376 + /// - **Signature verification**: Cryptographically verifies the ECDSA signature 377 + /// - **Key resolution**: Resolves and validates the verification key 378 /// 379 /// # Arguments 380 /// 381 + /// * `record_input` - The record to append the attestation to (as AnyInput) 382 + /// * `attestation_input` - The inline attestation to validate and append (as AnyInput) 383 + /// * `repository` - The repository DID where this record is stored (for replay attack prevention) 384 + /// * `key_resolver` - Resolver for looking up verification keys from DIDs 385 /// 386 /// # Returns 387 /// 388 + /// The modified record with the validated attestation appended to its `signatures` array 389 /// 390 /// # Errors 391 /// 392 /// Returns an error if: 393 + /// - The record or attestation are not valid JSON objects 394 + /// - The attestation is missing required fields (`$type`, `key`, `cid`, `signature`) 395 + /// - The attestation CID doesn't match the computed CID for the record 396 + /// - The signature bytes are invalid or not base64-encoded 397 + /// - Signature verification fails 398 + /// - Key resolution fails 399 + /// 400 + /// # Type Parameters 401 + /// 402 + /// * `R` - The record type (must implement Serialize + LexiconType + PartialEq + Clone) 403 + /// * `A` - The attestation type (must implement Serialize + LexiconType + PartialEq + Clone) 404 + /// * `KR` - The key resolver type (must implement KeyResolver) 405 + /// 406 + /// # Example 407 + /// 408 + /// ```ignore 409 + /// use atproto_attestation::{append_inline_attestation, input::AnyInput}; 410 + /// use serde_json::json; 411 + /// 412 + /// let record = json!({ 413 + /// "$type": "app.bsky.feed.post", 414 + /// "text": "Hello world!" 415 + /// }); 416 + /// 417 + /// let attestation = json!({ 418 + /// "$type": "com.example.inlineSignature", 419 + /// "key": "did:key:zQ3sh...", 420 + /// "cid": "bafyrei...", 421 + /// "signature": {"$bytes": "base64-signature-bytes"} 422 + /// }); 423 + /// 424 + /// let repository_did = "did:plc:repo123"; 425 + /// let key_resolver = /* your KeyResolver implementation */; 426 + /// 427 + /// let signed_record = append_inline_attestation( 428 + /// AnyInput::Serialize(record), 429 + /// AnyInput::Serialize(attestation), 430 + /// repository_did, 431 + /// key_resolver 432 + /// ).await?; 433 + /// ``` 434 + pub async fn append_inline_attestation<R, A, KR>( 435 + record_input: AnyInput<R>, 436 + attestation_input: AnyInput<A>, 437 + repository: &str, 438 + key_resolver: KR, 439 + ) -> Result<Value, AttestationError> 440 + where 441 + R: Serialize + Clone, 442 + A: Serialize + Clone, 443 + KR: KeyResolver, 444 + { 445 + // Step 1: Create a content CID 446 + let content_cid = 447 + create_attestation_cid(record_input.clone(), attestation_input.clone(), repository)?; 448 449 + let record_obj: Map<String, Value> = record_input 450 + .try_into() 451 + .map_err(|_| AttestationError::RecordMustBeObject)?; 452 453 + let attestation_obj: Map<String, Value> = attestation_input 454 + .try_into() 455 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 456 457 + let key = attestation_obj 458 .get("key") 459 .and_then(Value::as_str) 460 .filter(|value| !value.is_empty()) 461 + .ok_or(AttestationError::SignatureMissingField { 462 field: "key".to_string(), 463 })?; 464 + let key_data = 465 + key_resolver 466 + .resolve(key) 467 + .await 468 + .map_err(|error| AttestationError::KeyResolutionFailed { 469 + key: key.to_string(), 470 + error, 471 + })?; 472 473 + let signature_bytes = attestation_obj 474 .get("signature") 475 .and_then(Value::as_object) 476 .and_then(|object| object.get("$bytes")) 477 .and_then(Value::as_str) 478 .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 479 480 + let signature_bytes = BASE64 481 .decode(signature_bytes) 482 .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 483 484 + let computed_cid_bytes = content_cid.to_bytes(); 485 + 486 + validate(&key_data, &signature_bytes, &computed_cid_bytes) 487 + .map_err(|error| AttestationError::SignatureValidationFailed { error })?; 488 489 + // Step 6: Append the validated attestation to the signatures array 490 + append_signature_to_record(record_obj, json!(attestation_obj)) 491 } 492 493 #[cfg(test)] ··· 497 use serde_json::json; 498 499 #[test] 500 + fn create_remote_attestation_produces_both_records() -> Result<(), Box<dyn std::error::Error>> { 501 502 let record = json!({ 503 "$type": "app.example.record", 504 "body": "remote attestation" ··· 508 "$type": "com.example.attestation" 509 }); 510 511 + let source_repository = "did:plc:test"; 512 + let attestation_repository = "did:plc:attestor"; 513 + 514 + let (attested_record, proof_record) = 515 + create_remote_attestation( 516 + AnyInput::Serialize(record.clone()), 517 + AnyInput::Serialize(metadata), 518 + source_repository, 519 + attestation_repository, 520 + )?; 521 522 + // Verify proof record structure 523 + let proof_object = proof_record.as_object().expect("proof should be an object"); 524 assert_eq!( 525 proof_object.get("$type").and_then(Value::as_str), 526 Some("com.example.attestation") ··· 534 "repository should not be in final proof record" 535 ); 536 537 + // Verify attested record has strongRef 538 + let attested_object = attested_record 539 + .as_object() 540 + .expect("attested record should be an object"); 541 + let signatures = attested_object 542 + .get("signatures") 543 + .and_then(Value::as_array) 544 + .expect("attested record should have signatures array"); 545 + assert_eq!(signatures.len(), 1, "should have one signature"); 546 547 + let signature = &signatures[0]; 548 assert_eq!( 549 + signature.get("$type").and_then(Value::as_str), 550 + Some("com.atproto.repo.strongRef"), 551 + "signature should be a strongRef" 552 ); 553 + assert!( 554 + signature.get("uri").and_then(Value::as_str).is_some(), 555 + "strongRef must contain a uri" 556 ); 557 + assert!( 558 + signature.get("cid").and_then(Value::as_str).is_some(), 559 + "strongRef must contain a cid" 560 ); 561 562 Ok(()) ··· 581 }); 582 583 let signed = create_inline_attestation( 584 + AnyInput::Serialize(base_record), 585 + AnyInput::Serialize(sig_metadata), 586 repository_did, 587 &private_key, 588 )?; ··· 601 ); 602 assert!(sig.get("signature").is_some()); 603 assert!(sig.get("key").is_some()); 604 + assert!(sig.get("repository").is_none()); // Should not be in final signature 605 606 Ok(()) 607 } 608 + }
+32 -17
crates/atproto-attestation/src/bin/atproto-attestation-sign.rs
··· 52 53 use anyhow::{Context, Result, anyhow}; 54 use atproto_attestation::{ 55 - create_inline_attestation, create_remote_attestation, create_remote_attestation_reference, 56 }; 57 use atproto_identity::key::identify_key; 58 use clap::{Parser, Subcommand}; ··· 181 source_record, 182 attestation_repository_did, 183 metadata_record, 184 - } => { 185 - handle_remote_attestation(&source_record, &source_repository_did, &metadata_record, &attestation_repository_did)? 186 - } 187 188 Commands::Inline { 189 source_record, 190 repository_did, 191 signing_key, 192 metadata_record, 193 - } => handle_inline_attestation(&source_record, &repository_did, &signing_key, &metadata_record)?, 194 } 195 196 Ok(()) ··· 237 )); 238 } 239 240 - // Create the remote attestation proof record with source repository binding 241 - let proof_record = create_remote_attestation(&record_json, &metadata_json, source_repository_did) 242 - .context("Failed to create remote attestation proof record")?; 243 - 244 - // Create the source record with strongRef reference pointing to attestation repository 245 - let attested_record = 246 - create_remote_attestation_reference(&record_json, &proof_record, attestation_repository_did) 247 - .context("Failed to create remote attestation reference")?; 248 249 // Output both records 250 println!("=== Proof Record (store in repository) ==="); ··· 291 let key_data = identify_key(signing_key) 292 .with_context(|| format!("Failed to parse signing key: {}", signing_key))?; 293 294 - // Create inline attestation with repository binding 295 - let signed_record = 296 - create_inline_attestation(&record_json, &metadata_json, repository_did, &key_data) 297 - .context("Failed to create inline attestation")?; 298 299 // Output the signed record 300 println!("{}", serde_json::to_string_pretty(&signed_record)?);
··· 52 53 use anyhow::{Context, Result, anyhow}; 54 use atproto_attestation::{ 55 + create_inline_attestation, create_remote_attestation, 56 + input::AnyInput, 57 }; 58 use atproto_identity::key::identify_key; 59 use clap::{Parser, Subcommand}; ··· 182 source_record, 183 attestation_repository_did, 184 metadata_record, 185 + } => handle_remote_attestation( 186 + &source_record, 187 + &source_repository_did, 188 + &metadata_record, 189 + &attestation_repository_did, 190 + )?, 191 192 Commands::Inline { 193 source_record, 194 repository_did, 195 signing_key, 196 metadata_record, 197 + } => handle_inline_attestation( 198 + &source_record, 199 + &repository_did, 200 + &signing_key, 201 + &metadata_record, 202 + )?, 203 } 204 205 Ok(()) ··· 246 )); 247 } 248 249 + // Create the remote attestation using v2 API 250 + // This creates both the attested record with strongRef and the proof record in one call 251 + let (attested_record, proof_record) = 252 + create_remote_attestation( 253 + AnyInput::Serialize(record_json), 254 + AnyInput::Serialize(metadata_json), 255 + source_repository_did, 256 + attestation_repository_did, 257 + ) 258 + .context("Failed to create remote attestation")?; 259 260 // Output both records 261 println!("=== Proof Record (store in repository) ==="); ··· 302 let key_data = identify_key(signing_key) 303 .with_context(|| format!("Failed to parse signing key: {}", signing_key))?; 304 305 + // Create inline attestation with repository binding using v2 API 306 + let signed_record = create_inline_attestation( 307 + AnyInput::Serialize(record_json), 308 + AnyInput::Serialize(metadata_json), 309 + repository_did, 310 + &key_data, 311 + ) 312 + .context("Failed to create inline attestation")?; 313 314 // Output the signed record 315 println!("{}", serde_json::to_string_pretty(&signed_record)?);
+19 -135
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
··· 46 //! ``` 47 48 use anyhow::{Context, Result, anyhow}; 49 - use atproto_attestation::VerificationStatus; 50 use clap::Parser; 51 use serde_json::Value; 52 use std::{ ··· 73 74 USAGE: 75 atproto-attestation-verify <record> <repository_did> Verify all signatures 76 - atproto-attestation-verify <record> <repository_did> <attestation> Verify specific attestation 77 78 PARAMETER FORMATS: 79 Each parameter accepts JSON strings, file paths, or AT-URIs: ··· 115 attestation: Option<String>, 116 } 117 118 #[tokio::main] 119 async fn main() -> Result<()> { 120 let args = Args::parse(); ··· 137 } 138 139 // Determine verification mode 140 - match args.attestation { 141 - None => { 142 - // Mode 1: Verify all signatures in the record 143 - verify_all_mode(&record, &args.repository_did).await 144 - } 145 - Some(attestation_input) => { 146 - // Mode 2: Verify specific attestation against record 147 - let attestation = load_input(&attestation_input, false) 148 - .await 149 - .context("Failed to load attestation")?; 150 - 151 - if !attestation.is_object() { 152 - return Err(anyhow!("Attestation must be a JSON object")); 153 - } 154 - 155 - verify_attestation_mode(&record, &attestation, &args.repository_did).await 156 - } 157 - } 158 } 159 160 /// Mode 1: Verify all signatures contained in the record. ··· 183 identity_resolver, 184 }; 185 186 - let reports = atproto_attestation::verify_all_signatures_with_resolver( 187 - record, 188 - repository_did, 189 - None, 190 - Some(&record_resolver), 191 - ) 192 - .await 193 - .context("Failed to verify signatures")?; 194 - 195 - if reports.is_empty() { 196 - return Err(anyhow!("No signatures found in record")); 197 - } 198 - 199 - let mut all_valid = true; 200 - let mut has_errors = false; 201 - 202 - for report in &reports { 203 - match &report.status { 204 - VerificationStatus::Valid { cid } => { 205 - let key_info = report 206 - .key 207 - .as_deref() 208 - .map(|k| format!(" (key: {})", truncate_did(k))) 209 - .unwrap_or_default(); 210 - println!( 211 - "✓ Signature {} valid{} [CID: {}]", 212 - report.index, key_info, cid 213 - ); 214 - } 215 - VerificationStatus::Invalid { error } => { 216 - println!("✗ Signature {} invalid: {}", report.index, error); 217 - all_valid = false; 218 - has_errors = true; 219 - } 220 - VerificationStatus::Unverified { reason } => { 221 - println!("? Signature {} unverified: {}", report.index, reason); 222 - all_valid = false; 223 - } 224 - } 225 - } 226 - 227 - println!(); 228 - println!( 229 - "Summary: {} total, {} valid", 230 - reports.len(), 231 - reports 232 - .iter() 233 - .filter(|r| matches!(r.status, VerificationStatus::Valid { .. })) 234 - .count() 235 - ); 236 - 237 - if has_errors { 238 - Err(anyhow!("One or more signatures are invalid")) 239 - } else if !all_valid { 240 - Err(anyhow!("One or more signatures could not be verified")) 241 - } else { 242 - Ok(()) 243 - } 244 - } 245 - 246 - /// Mode 2: Verify a specific attestation record against the provided record. 247 - /// 248 - /// The attestation should be a standalone attestation object (e.g., from a remote proof record) 249 - /// that will be verified against the record's content. 250 - async fn verify_attestation_mode( 251 - record: &Value, 252 - attestation: &Value, 253 - repository_did: &str, 254 - ) -> Result<()> { 255 - // The attestation should have a CID field that we can use to verify 256 - let attestation_obj = attestation 257 - .as_object() 258 - .ok_or_else(|| anyhow!("Attestation must be a JSON object"))?; 259 - 260 - // Get the CID from the attestation 261 - let cid_str = attestation_obj 262 - .get("cid") 263 - .and_then(Value::as_str) 264 - .ok_or_else(|| anyhow!("Attestation must contain a 'cid' field"))?; 265 266 - // Prepare the signing record with the attestation metadata and repository DID 267 - let mut signing_metadata = attestation_obj.clone(); 268 - signing_metadata.remove("cid"); 269 - signing_metadata.remove("signature"); 270 - 271 - let signing_record = atproto_attestation::prepare_signing_record( 272 - record, 273 - &Value::Object(signing_metadata), 274 repository_did, 275 ) 276 - .context("Failed to prepare signing record")?; 277 - 278 - // Generate the CID from the signing record 279 - let computed_cid = 280 - atproto_attestation::create_cid(&signing_record).context("Failed to generate CID")?; 281 - 282 - // Compare CIDs 283 - if computed_cid.to_string() != cid_str { 284 - return Err(anyhow!( 285 - "CID mismatch: attestation claims {}, but computed {}", 286 - cid_str, 287 - computed_cid 288 - )); 289 - } 290 - 291 - println!("OK"); 292 - println!("CID: {}", computed_cid); 293 - 294 - Ok(()) 295 } 296 297 /// Load input from various sources: JSON string, file path, AT-URI, or stdin. ··· 395 atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 396 Err(anyhow!("Failed to fetch record: {}", error.error_message())) 397 } 398 - } 399 - } 400 - 401 - /// Truncate a DID or did:key for display purposes. 402 - fn truncate_did(did: &str) -> String { 403 - if did.len() > 40 { 404 - format!("{}...{}", &did[..20], &did[did.len() - 12..]) 405 - } else { 406 - did.to_string() 407 } 408 } 409
··· 46 //! ``` 47 48 use anyhow::{Context, Result, anyhow}; 49 + use atproto_attestation::AnyInput; 50 + use atproto_identity::key::{KeyData, KeyResolver}; 51 use clap::Parser; 52 use serde_json::Value; 53 use std::{ ··· 74 75 USAGE: 76 atproto-attestation-verify <record> <repository_did> Verify all signatures 77 78 PARAMETER FORMATS: 79 Each parameter accepts JSON strings, file paths, or AT-URIs: ··· 115 attestation: Option<String>, 116 } 117 118 + struct FakeKeyResolver {} 119 + 120 + #[async_trait::async_trait] 121 + impl KeyResolver for FakeKeyResolver { 122 + async fn resolve(&self, _subject: &str) -> Result<KeyData> { 123 + todo!() 124 + } 125 + } 126 + 127 #[tokio::main] 128 async fn main() -> Result<()> { 129 let args = Args::parse(); ··· 146 } 147 148 // Determine verification mode 149 + verify_all_mode(&record, &args.repository_did).await 150 } 151 152 /// Mode 1: Verify all signatures contained in the record. ··· 175 identity_resolver, 176 }; 177 178 + let key_resolver = FakeKeyResolver {}; 179 180 + atproto_attestation::verify_record( 181 + AnyInput::Serialize(record.clone()), 182 repository_did, 183 + key_resolver, 184 + record_resolver, 185 ) 186 + .await 187 + .context("Failed to verify signatures") 188 } 189 190 /// Load input from various sources: JSON string, file path, AT-URI, or stdin. ··· 288 atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 289 Err(anyhow!("Failed to fetch record: {}", error.error_message())) 290 } 291 } 292 } 293
+477 -79
crates/atproto-attestation/src/cid.rs
··· 3 //! This module implements the CID-first attestation workflow, generating 4 //! deterministic content identifiers using DAG-CBOR serialization and SHA-256 hashing. 5 6 - use crate::errors::AttestationError; 7 use cid::Cid; 8 use multihash::Multihash; 9 - use serde_json::Value; 10 use sha2::{Digest, Sha256}; 11 12 - /// Create a deterministic CID for a record prepared with `prepare_signing_record`. 13 /// 14 - /// The record **must** contain a `$sig` object with at least a `$type` string 15 - /// to scope the signature and a `repository` field to prevent replay attacks. 16 - /// The returned CID uses the blessed parameters: 17 - /// CIDv1, dag-cbor codec (0x71), and sha2-256 multihash. 18 /// 19 /// # Arguments 20 /// 21 - /// * `record` - The prepared record containing a `$sig` metadata object 22 /// 23 /// # Returns 24 /// 25 - /// The generated CID for the record 26 /// 27 /// # Errors 28 /// 29 /// Returns an error if: 30 - /// - The record is not a JSON object 31 - /// - The `$sig` field is missing or not an object 32 - /// - The `$sig` object is missing the required `$type` field 33 - /// - The `$sig` object is missing the required `repository` field 34 - pub fn create_cid(record: &Value) -> Result<Cid, AttestationError> { 35 - let record_object = record 36 - .as_object() 37 - .ok_or(AttestationError::RecordMustBeObject)?; 38 39 - let sig_value = record_object 40 - .get("$sig") 41 - .ok_or(AttestationError::SigMetadataMissing)?; 42 43 - let sig_object = sig_value 44 - .as_object() 45 - .ok_or(AttestationError::SigMetadataNotObject)?; 46 47 - if sig_object 48 .get("$type") 49 .and_then(Value::as_str) 50 - .filter(|value| !value.is_empty()).is_none() 51 { 52 - return Err(AttestationError::SigMetadataMissingType); 53 } 54 55 - if sig_object 56 - .get("repository") 57 .and_then(Value::as_str) 58 - .filter(|value| !value.is_empty()).is_none() 59 { 60 - return Err(AttestationError::SigMetadataMissingType); 61 } 62 63 - let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?; 64 - let digest = Sha256::digest(&dag_cbor_bytes); 65 - let multihash = Multihash::wrap(0x12, &digest) 66 - .map_err(|error| AttestationError::MultihashWrapFailed { error })?; 67 68 - Ok(Cid::new_v1(0x71, multihash)) 69 } 70 71 - /// Create a CID for a plain record without `$sig` validation. 72 /// 73 - /// This is used internally for generating CIDs of attestation records themselves. 74 /// 75 /// # Arguments 76 /// 77 - /// * `record` - The record to generate a CID for 78 /// 79 /// # Returns 80 /// 81 - /// The generated CID for the record 82 - pub(crate) fn create_plain_cid(record: &Value) -> Result<Cid, AttestationError> { 83 - let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?; 84 - let digest = Sha256::digest(&dag_cbor_bytes); 85 - let multihash = Multihash::wrap(0x12, &digest) 86 - .map_err(|error| AttestationError::MultihashWrapFailed { error })?; 87 88 - Ok(Cid::new_v1(0x71, multihash)) 89 } 90 91 #[cfg(test)] 92 mod tests { 93 use super::*; 94 - use serde_json::json; 95 96 - #[test] 97 - fn create_cid_produces_expected_codec_and_length() -> Result<(), AttestationError> { 98 - let prepared = json!({ 99 - "$type": "app.example.record", 100 - "text": "cid demo", 101 - "$sig": { 102 - "$type": "com.example.inlineSignature", 103 - "key": "did:key:zabc", 104 - "repository": "did:plc:test" 105 } 106 - }); 107 108 - let cid = create_cid(&prepared)?; 109 - assert_eq!(cid.codec(), 0x71); 110 - assert_eq!(cid.hash().code(), 0x12); 111 - assert_eq!(cid.hash().digest().len(), 32); 112 - assert_eq!(cid.to_bytes().len(), 36); 113 114 Ok(()) 115 } 116 117 - #[test] 118 - fn create_cid_requires_sig_type() { 119 - let record = json!({ 120 - "$type": "app.example.record", 121 - "$sig": { 122 - "repository": "did:plc:test" 123 } 124 - }); 125 126 - let result = create_cid(&record); 127 - assert!(matches!(result, Err(AttestationError::SigMetadataMissingType))); 128 } 129 130 #[test] 131 - fn create_cid_requires_repository() { 132 - let record = json!({ 133 - "$type": "app.example.record", 134 - "$sig": { 135 - "$type": "com.example.sig" 136 } 137 - }); 138 139 - let result = create_cid(&record); 140 - assert!(matches!(result, Err(AttestationError::SigMetadataMissingType))); 141 } 142 - }
··· 3 //! This module implements the CID-first attestation workflow, generating 4 //! deterministic content identifiers using DAG-CBOR serialization and SHA-256 hashing. 5 6 + use crate::{errors::AttestationError, input::AnyInput}; 7 + #[cfg(test)] 8 + use atproto_record::typed::LexiconType; 9 use cid::Cid; 10 use multihash::Multihash; 11 + use serde::Serialize; 12 + use serde_json::{Value, Map}; 13 use sha2::{Digest, Sha256}; 14 + use std::convert::TryInto; 15 16 + /// DAG-CBOR codec identifier used in AT Protocol CIDs. 17 + /// 18 + /// This codec (0x71) indicates that the data is encoded using DAG-CBOR, 19 + /// a deterministic subset of CBOR designed for content-addressable systems. 20 + pub const DAG_CBOR_CODEC: u64 = 0x71; 21 + 22 + /// SHA-256 multihash code used in AT Protocol CIDs. 23 + /// 24 + /// This code (0x12) identifies SHA-256 as the hash function used to generate 25 + /// the content identifier. SHA-256 provides 256-bit cryptographic security. 26 + pub const MULTIHASH_SHA256: u64 = 0x12; 27 + 28 + /// Create a CID from any serializable data using DAG-CBOR encoding. 29 /// 30 + /// This function generates a content identifier (CID) for arbitrary data by: 31 + /// 1. Serializing the input to DAG-CBOR format 32 + /// 2. Computing a SHA-256 hash of the serialized bytes 33 + /// 3. Creating a CIDv1 with dag-cbor codec (0x71) 34 /// 35 /// # Arguments 36 /// 37 + /// * `record` - The data to generate a CID for (must implement `Serialize`) 38 /// 39 /// # Returns 40 /// 41 + /// The generated CID for the data using CIDv1 with dag-cbor codec (0x71) and sha2-256 hash 42 + /// 43 + /// # Type Parameters 44 + /// 45 + /// * `T` - Any type that implements `Serialize` and is compatible with DAG-CBOR encoding 46 /// 47 /// # Errors 48 /// 49 /// Returns an error if: 50 + /// - DAG-CBOR serialization fails 51 + /// - Multihash wrapping fails 52 + /// 53 + /// # Example 54 + /// 55 + /// ```rust 56 + /// use atproto_attestation::cid::create_dagbor_cid; 57 + /// use serde_json::json; 58 + /// 59 + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 60 + /// let data = json!({"text": "Hello, world!"}); 61 + /// let cid = create_dagbor_cid(&data)?; 62 + /// assert_eq!(cid.codec(), 0x71); // dag-cbor codec 63 + /// # Ok(()) 64 + /// # } 65 + /// ``` 66 + pub fn create_dagbor_cid<T: Serialize>(record: &T) -> Result<Cid, AttestationError> { 67 + let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?; 68 + let digest = Sha256::digest(&dag_cbor_bytes); 69 + let multihash = Multihash::wrap(MULTIHASH_SHA256, &digest) 70 + .map_err(|error| AttestationError::MultihashWrapFailed { error })?; 71 72 + Ok(Cid::new_v1(DAG_CBOR_CODEC, multihash)) 73 + } 74 75 + /// Create a CID for an attestation with automatic `$sig` metadata preparation. 76 + /// 77 + /// This is the high-level function used internally by attestation creation functions. 78 + /// It handles the full workflow of preparing a signing record with `$sig` metadata 79 + /// and generating the CID. 80 + /// 81 + /// # Arguments 82 + /// 83 + /// * `record_input` - The record to attest (as AnyInput: String, Json, or TypedLexicon) 84 + /// * `metadata_input` - The attestation metadata (must include `$type`) 85 + /// * `repository` - The repository DID to bind the attestation to (prevents replay attacks) 86 + /// 87 + /// # Returns 88 + /// 89 + /// The generated CID for the prepared attestation record 90 + /// 91 + /// # Errors 92 + /// 93 + /// Returns an error if: 94 + /// - The record or metadata are not valid JSON objects 95 + /// - The record is missing the required `$type` field 96 + /// - The metadata is missing the required `$type` field 97 + /// - DAG-CBOR serialization fails 98 + pub fn create_attestation_cid< 99 + R: Serialize + Clone, 100 + M: Serialize + Clone, 101 + >( 102 + record_input: AnyInput<R>, 103 + metadata_input: AnyInput<M>, 104 + repository: &str, 105 + ) -> Result<Cid, AttestationError> { 106 + let mut record_obj: Map<String, Value> = record_input 107 + .try_into() 108 + .map_err(|_| AttestationError::RecordMustBeObject)?; 109 110 + if record_obj 111 .get("$type") 112 .and_then(Value::as_str) 113 + .filter(|value| !value.is_empty()) 114 + .is_none() 115 { 116 + return Err(AttestationError::RecordMissingType); 117 } 118 119 + let mut metadata_obj: Map<String, Value> = metadata_input 120 + .try_into() 121 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 122 + 123 + if metadata_obj 124 + .get("$type") 125 .and_then(Value::as_str) 126 + .filter(|value| !value.is_empty()) 127 + .is_none() 128 { 129 + return Err(AttestationError::MetadataMissingSigType); 130 } 131 132 + record_obj.remove("signatures"); 133 134 + metadata_obj.remove("cid"); 135 + metadata_obj.remove("signature"); 136 + metadata_obj.insert( 137 + "repository".to_string(), 138 + Value::String(repository.to_string()), 139 + ); 140 + 141 + record_obj.insert("$sig".to_string(), Value::Object(metadata_obj.clone())); 142 + 143 + // Directly pass the Map<String, Value> - no need to wrap in Value::Object 144 + create_dagbor_cid(&record_obj) 145 } 146 147 + /// Validates that a CID string conforms to AT Protocol attestation requirements. 148 + /// 149 + /// This function performs strict validation to ensure the CID meets the exact 150 + /// specifications required for AT Protocol attestations: 151 + /// 152 + /// 1. **Valid format**: The string must be a parseable CID 153 + /// 2. **Version**: Must be CIDv1 (not CIDv0) 154 + /// 3. **Codec**: Must use DAG-CBOR codec (0x71) 155 + /// 4. **Hash algorithm**: Must use SHA-256 (multihash code 0x12) 156 + /// 5. **Hash length**: Must have exactly 32 bytes (SHA-256 standard) 157 /// 158 + /// These requirements ensure consistency and security across the AT Protocol 159 + /// ecosystem, particularly for content addressing and attestation verification. 160 /// 161 /// # Arguments 162 /// 163 + /// * `cid` - A string slice containing the CID to validate 164 /// 165 /// # Returns 166 /// 167 + /// * `true` if the CID meets all AT Protocol requirements 168 + /// * `false` if the CID is invalid or doesn't meet any requirement 169 + /// 170 + /// # Examples 171 + /// 172 + /// ```rust 173 + /// use atproto_attestation::cid::validate_cid_format; 174 + /// 175 + /// // Valid AT Protocol CID (CIDv1, DAG-CBOR, SHA-256) 176 + /// let valid_cid = "bafyreigw5bqvbz6m3c3zjpqhxwl4njlnbbnw5xvptbx6dzfxjqcde6lt3y"; 177 + /// assert!(validate_cid_format(valid_cid)); 178 + /// 179 + /// // Invalid: Empty string 180 + /// assert!(!validate_cid_format("")); 181 + /// 182 + /// // Invalid: Not a CID 183 + /// assert!(!validate_cid_format("not-a-cid")); 184 + /// 185 + /// // Invalid: CIDv0 (starts with Qm) 186 + /// let cid_v0 = "QmYwAPJzv5CZsnA625ub3XtLxT3Tz5Lno5Wqv9eKewWKjE"; 187 + /// assert!(!validate_cid_format(cid_v0)); 188 + /// ``` 189 + /// 190 + /// # Use Cases 191 + /// 192 + /// This function is typically used to: 193 + /// - Validate CIDs in attestation signatures before verification 194 + /// - Ensure CIDs in remote attestations match expected format 195 + /// - Validate user-provided CIDs in API requests 196 + /// - Verify CIDs generated by external systems conform to AT Protocol standards 197 + pub fn validate_cid_format(cid: &str) -> bool { 198 + if cid.is_empty() { 199 + return false 200 + } 201 + 202 + // Parse the CID using the cid crate for proper validation 203 + let parsed_cid = match Cid::try_from(cid) { 204 + Ok(value) => value, 205 + Err(_) => return false, 206 + }; 207 + 208 + // Verify it's CIDv1 (version 1) 209 + if parsed_cid.version() != cid::Version::V1 { 210 + return false; 211 + } 212 + 213 + // Verify it uses DAG-CBOR codec (0x71) 214 + if parsed_cid.codec() != DAG_CBOR_CODEC { 215 + return false; 216 + } 217 + 218 + // Get the multihash and verify it uses SHA-256 219 + let multihash = parsed_cid.hash(); 220 + 221 + // SHA-256 code is 0x12 222 + if multihash.code() != MULTIHASH_SHA256 { 223 + return false; 224 + } 225 + 226 + // Verify the hash digest is 32 bytes (SHA-256 standard) 227 + if multihash.digest().len() != 32 { 228 + return false; 229 + } 230 231 + true 232 } 233 234 #[cfg(test)] 235 mod tests { 236 use super::*; 237 + use atproto_record::typed::TypedLexicon; 238 + use serde::Deserialize; 239 + 240 + 241 + #[tokio::test] 242 + async fn test_create_attestation_cid() -> Result<(), AttestationError> { 243 + use atproto_record::datetime::format as datetime_format; 244 + use chrono::{DateTime, Utc}; 245 + 246 + // Define test record type with createdAt and text fields 247 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 248 + #[cfg_attr(debug_assertions, derive(Debug))] 249 + struct TestRecord { 250 + #[serde(rename = "createdAt", with = "datetime_format")] 251 + created_at: DateTime<Utc>, 252 + text: String, 253 + } 254 255 + impl LexiconType for TestRecord { 256 + fn lexicon_type() -> &'static str { 257 + "com.example.testrecord" 258 } 259 + } 260 261 + // Define test metadata type 262 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 263 + #[cfg_attr(debug_assertions, derive(Debug))] 264 + struct TestMetadata { 265 + #[serde(rename = "createdAt", with = "datetime_format")] 266 + created_at: DateTime<Utc>, 267 + purpose: String, 268 + } 269 + 270 + impl LexiconType for TestMetadata { 271 + fn lexicon_type() -> &'static str { 272 + "com.example.testmetadata" 273 + } 274 + } 275 + 276 + // Create test data 277 + let created_at = DateTime::parse_from_rfc3339("2025-01-15T14:00:00.000Z") 278 + .unwrap() 279 + .with_timezone(&Utc); 280 + 281 + let record = TestRecord { 282 + created_at, 283 + text: "Hello, AT Protocol!".to_string(), 284 + }; 285 + 286 + let metadata_created_at = DateTime::parse_from_rfc3339("2025-01-15T14:05:00.000Z") 287 + .unwrap() 288 + .with_timezone(&Utc); 289 + 290 + let metadata = TestMetadata { 291 + created_at: metadata_created_at, 292 + purpose: "attestation".to_string(), 293 + }; 294 + 295 + let repository = "did:plc:test123"; 296 + 297 + // Create typed lexicons 298 + let typed_record = TypedLexicon::new(record); 299 + let typed_metadata = TypedLexicon::new(metadata); 300 + 301 + // Call the function 302 + let cid = create_attestation_cid( 303 + AnyInput::Serialize(typed_record), 304 + AnyInput::Serialize(typed_metadata), 305 + repository, 306 + )?; 307 + 308 + // Verify CID properties 309 + assert_eq!(cid.codec(), 0x71, "CID should use dag-cbor codec"); 310 + assert_eq!(cid.hash().code(), 0x12, "CID should use sha2-256 hash"); 311 + assert_eq!( 312 + cid.hash().digest().len(), 313 + 32, 314 + "Hash digest should be 32 bytes" 315 + ); 316 + assert_eq!(cid.to_bytes().len(), 36, "CID should be 36 bytes total"); 317 318 Ok(()) 319 } 320 321 + #[tokio::test] 322 + async fn test_create_attestation_cid_deterministic() -> Result<(), AttestationError> { 323 + use atproto_record::datetime::format as datetime_format; 324 + use chrono::{DateTime, Utc}; 325 + 326 + // Define simple test types 327 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 328 + struct SimpleRecord { 329 + #[serde(rename = "createdAt", with = "datetime_format")] 330 + created_at: DateTime<Utc>, 331 + text: String, 332 + } 333 + 334 + impl LexiconType for SimpleRecord { 335 + fn lexicon_type() -> &'static str { 336 + "com.example.simple" 337 + } 338 + } 339 + 340 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 341 + struct SimpleMetadata { 342 + #[serde(rename = "createdAt", with = "datetime_format")] 343 + created_at: DateTime<Utc>, 344 + } 345 + 346 + impl LexiconType for SimpleMetadata { 347 + fn lexicon_type() -> &'static str { 348 + "com.example.meta" 349 } 350 + } 351 + 352 + let created_at = DateTime::parse_from_rfc3339("2025-01-01T00:00:00.000Z") 353 + .unwrap() 354 + .with_timezone(&Utc); 355 + 356 + let record1 = SimpleRecord { 357 + created_at, 358 + text: "test".to_string(), 359 + }; 360 + let record2 = SimpleRecord { 361 + created_at, 362 + text: "test".to_string(), 363 + }; 364 + 365 + let metadata1 = SimpleMetadata { created_at }; 366 + let metadata2 = SimpleMetadata { created_at }; 367 + 368 + let repository = "did:plc:same"; 369 + 370 + // Create CIDs for identical records 371 + let cid1 = create_attestation_cid( 372 + AnyInput::Serialize(TypedLexicon::new(record1)), 373 + AnyInput::Serialize(TypedLexicon::new(metadata1)), 374 + repository, 375 + )?; 376 + 377 + let cid2 = create_attestation_cid( 378 + AnyInput::Serialize(TypedLexicon::new(record2)), 379 + AnyInput::Serialize(TypedLexicon::new(metadata2)), 380 + repository, 381 + )?; 382 383 + // Verify determinism: identical inputs produce identical CIDs 384 + assert_eq!( 385 + cid1, cid2, 386 + "Identical records should produce identical CIDs" 387 + ); 388 + 389 + Ok(()) 390 + } 391 + 392 + #[tokio::test] 393 + async fn test_create_attestation_cid_different_repositories() -> Result<(), AttestationError> { 394 + use atproto_record::datetime::format as datetime_format; 395 + use chrono::{DateTime, Utc}; 396 + 397 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 398 + struct RepoRecord { 399 + #[serde(rename = "createdAt", with = "datetime_format")] 400 + created_at: DateTime<Utc>, 401 + text: String, 402 + } 403 + 404 + impl LexiconType for RepoRecord { 405 + fn lexicon_type() -> &'static str { 406 + "com.example.repo" 407 + } 408 + } 409 + 410 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 411 + struct RepoMetadata { 412 + #[serde(rename = "createdAt", with = "datetime_format")] 413 + created_at: DateTime<Utc>, 414 + } 415 + 416 + impl LexiconType for RepoMetadata { 417 + fn lexicon_type() -> &'static str { 418 + "com.example.repometa" 419 + } 420 + } 421 + 422 + let created_at = DateTime::parse_from_rfc3339("2025-01-01T12:00:00.000Z") 423 + .unwrap() 424 + .with_timezone(&Utc); 425 + 426 + let record = RepoRecord { 427 + created_at, 428 + text: "content".to_string(), 429 + }; 430 + let metadata = RepoMetadata { created_at }; 431 + 432 + // Same record and metadata, different repositories 433 + let cid1 = create_attestation_cid( 434 + AnyInput::Serialize(TypedLexicon::new(record.clone())), 435 + AnyInput::Serialize(TypedLexicon::new(metadata.clone())), 436 + "did:plc:repo1", 437 + )?; 438 + 439 + let cid2 = create_attestation_cid( 440 + AnyInput::Serialize(TypedLexicon::new(record)), 441 + AnyInput::Serialize(TypedLexicon::new(metadata)), 442 + "did:plc:repo2", 443 + )?; 444 + 445 + // Different repositories should produce different CIDs (prevents replay attacks) 446 + assert_ne!( 447 + cid1, cid2, 448 + "Different repository DIDs should produce different CIDs" 449 + ); 450 + 451 + Ok(()) 452 } 453 454 #[test] 455 + fn test_validate_cid_format() { 456 + // Test valid CID (generated from our own create_dagbor_cid function) 457 + let valid_data = serde_json::json!({"test": "data"}); 458 + let valid_cid = create_dagbor_cid(&valid_data).unwrap(); 459 + let valid_cid_str = valid_cid.to_string(); 460 + assert!(validate_cid_format(&valid_cid_str), "Valid CID should pass validation"); 461 + 462 + // Test empty string 463 + assert!(!validate_cid_format(""), "Empty string should fail validation"); 464 + 465 + // Test invalid CID string 466 + assert!(!validate_cid_format("not-a-cid"), "Invalid string should fail validation"); 467 + assert!(!validate_cid_format("abc123"), "Invalid string should fail validation"); 468 + 469 + // Test CIDv0 (starts with Qm, uses different format) 470 + let cid_v0 = "QmYwAPJzv5CZsnA625ub3XtLxT3Tz5Lno5Wqv9eKewWKjE"; 471 + assert!(!validate_cid_format(cid_v0), "CIDv0 should fail validation"); 472 + 473 + // Test valid CID base32 format but wrong codec (not DAG-CBOR) 474 + // This is a valid CID but uses raw codec (0x55) instead of DAG-CBOR (0x71) 475 + let wrong_codec = "bafkreigw5bqvbz6m3c3zjpqhxwl4njlnbbnw5xvptbx6dzfxjqcde6lt3y"; 476 + assert!(!validate_cid_format(wrong_codec), "CID with wrong codec should fail"); 477 + 478 + // Test that our constants match what we're checking 479 + assert_eq!(DAG_CBOR_CODEC, 0x71, "DAG-CBOR codec constant should be 0x71"); 480 + assert_eq!(MULTIHASH_SHA256, 0x12, "SHA-256 multihash code should be 0x12"); 481 + } 482 + 483 + #[tokio::test] 484 + async fn phantom_data_test() -> Result<(), AttestationError> { 485 + let repository = "did:web:example.com"; 486 + 487 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 488 + struct FooRecord { 489 + text: String, 490 + } 491 + 492 + impl LexiconType for FooRecord { 493 + fn lexicon_type() -> &'static str { 494 + "com.example.foo" 495 + } 496 + } 497 + 498 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 499 + struct BarRecord { 500 + text: String, 501 + } 502 + 503 + impl LexiconType for BarRecord { 504 + fn lexicon_type() -> &'static str { 505 + "com.example.bar" 506 } 507 + } 508 + 509 + let foo = FooRecord { 510 + text: "foo".to_string(), 511 + }; 512 + let typed_foo = TypedLexicon::new(foo); 513 + 514 + let bar = BarRecord { 515 + text: "bar".to_string(), 516 + }; 517 + let typed_bar = TypedLexicon::new(bar); 518 519 + let cid1 = create_attestation_cid( 520 + AnyInput::Serialize(typed_foo.clone()), 521 + AnyInput::Serialize(typed_bar.clone()), 522 + repository, 523 + )?; 524 + 525 + let value_bar = serde_json::to_value(typed_bar).expect("bar serde_json::Value conversion"); 526 + 527 + let cid2 = create_attestation_cid( 528 + AnyInput::Serialize(typed_foo), 529 + AnyInput::Serialize(value_bar), 530 + repository, 531 + )?; 532 + 533 + assert_eq!( 534 + cid1, cid2, 535 + "Different repository DIDs should produce different CIDs" 536 + ); 537 + 538 + Ok(()) 539 } 540 + }
+8
crates/atproto-attestation/src/errors.rs
··· 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, ··· 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}")]
··· 12 #[error("error-atproto-attestation-1 Record must be a JSON object")] 13 RecordMustBeObject, 14 15 + /// Error when the record omits the `$type` discriminator. 16 + #[error("error-atproto-attestation-1 Record must include a string `$type` field")] 17 + RecordMissingType, 18 + 19 /// Error when attestation metadata is not a JSON object. 20 #[error("error-atproto-attestation-2 Attestation metadata must be a JSON object")] 21 MetadataMustBeObject, ··· 96 /// Error when `$sig` metadata omits the `$type` discriminator. 97 #[error("error-atproto-attestation-16 `$sig` metadata must include a string `$type` field")] 98 SigMetadataMissingType, 99 + 100 + /// Error when metadata omits the `$type` discriminator. 101 + #[error("error-atproto-attestation-18 Metadata must include a string `$type` field")] 102 + MetadataMissingType, 103 104 /// Error when a key resolver is required but not provided. 105 #[error("error-atproto-attestation-17 Key resolver required to resolve key reference: {key}")]
+384
crates/atproto-attestation/src/input.rs
···
··· 1 + //! Input types for attestation functions supporting multiple input formats. 2 + 3 + use serde::Serialize; 4 + use serde_json::{Map, Value}; 5 + use std::convert::TryFrom; 6 + use std::str::FromStr; 7 + use thiserror::Error; 8 + 9 + /// Flexible input type for attestation functions. 10 + /// 11 + /// Allows passing records and metadata as JSON strings or any serde serializable types. 12 + #[derive(Clone)] 13 + pub enum AnyInput<S: Serialize + Clone> { 14 + /// JSON string representation 15 + String(String), 16 + /// Serializable types 17 + Serialize(S), 18 + } 19 + 20 + /// Error types for AnyInput parsing and transformation operations. 21 + /// 22 + /// This enum provides specific error types for various failure modes when working 23 + /// with `AnyInput`, including JSON parsing errors, type conversion errors, and 24 + /// serialization failures. 25 + #[derive(Debug, Error)] 26 + pub enum AnyInputError { 27 + /// Error when parsing JSON from a string fails. 28 + #[error("Failed to parse JSON from string: {0}")] 29 + JsonParseError(#[from] serde_json::Error), 30 + 31 + /// Error when the value is not a JSON object. 32 + #[error("Expected JSON object, but got {value_type}")] 33 + NotAnObject { 34 + /// The actual type of the value. 35 + value_type: String, 36 + }, 37 + 38 + /// Error when the string contains invalid JSON. 39 + #[error("Invalid JSON string: {message}")] 40 + InvalidJson { 41 + /// Error message describing what's wrong with the JSON. 42 + message: String, 43 + }, 44 + } 45 + 46 + impl AnyInputError { 47 + /// Creates a new `NotAnObject` error with the actual type information. 48 + pub fn not_an_object(value: &Value) -> Self { 49 + let value_type = match value { 50 + Value::Null => "null".to_string(), 51 + Value::Bool(_) => "boolean".to_string(), 52 + Value::Number(_) => "number".to_string(), 53 + Value::String(_) => "string".to_string(), 54 + Value::Array(_) => "array".to_string(), 55 + Value::Object(_) => "object".to_string(), // Should not happen 56 + }; 57 + 58 + AnyInputError::NotAnObject { value_type } 59 + } 60 + } 61 + 62 + /// Implementation of `FromStr` for `AnyInput` that deserializes JSON strings. 63 + /// 64 + /// This allows parsing JSON strings directly into `AnyInput<serde_json::Value>` using 65 + /// the standard `FromStr` trait. The string is deserialized using `serde_json::from_str` 66 + /// and wrapped in `AnyInput::Serialize`. 67 + /// 68 + /// # Errors 69 + /// 70 + /// Returns `AnyInputError::JsonParseError` if the string contains invalid JSON. 71 + /// 72 + /// # Example 73 + /// 74 + /// ``` 75 + /// use atproto_attestation::input::AnyInput; 76 + /// use std::str::FromStr; 77 + /// 78 + /// let input: AnyInput<serde_json::Value> = r#"{"type": "post", "text": "Hello"}"#.parse().unwrap(); 79 + /// ``` 80 + impl FromStr for AnyInput<serde_json::Value> { 81 + type Err = AnyInputError; 82 + 83 + fn from_str(s: &str) -> Result<Self, Self::Err> { 84 + let value = serde_json::from_str(s)?; 85 + Ok(AnyInput::Serialize(value)) 86 + } 87 + } 88 + 89 + impl<S: Serialize + Clone> From<S> for AnyInput<S> { 90 + fn from(value: S) -> Self { 91 + AnyInput::Serialize(value) 92 + } 93 + } 94 + 95 + /// Implementation of `TryFrom` for converting `AnyInput` into a JSON object map. 96 + /// 97 + /// This allows converting any `AnyInput` into a `serde_json::Map<String, Value>`, which 98 + /// represents a JSON object. Both string and serializable inputs are converted to JSON 99 + /// objects, with appropriate error handling for non-object values. 100 + /// 101 + /// # Example 102 + /// 103 + /// ``` 104 + /// use atproto_attestation::input::AnyInput; 105 + /// use serde_json::{json, Map, Value}; 106 + /// use std::convert::TryInto; 107 + /// 108 + /// let input = AnyInput::Serialize(json!({"type": "post", "text": "Hello"})); 109 + /// let map: Map<String, Value> = input.try_into().unwrap(); 110 + /// assert_eq!(map.get("type").unwrap(), "post"); 111 + /// ``` 112 + impl<S: Serialize + Clone> TryFrom<AnyInput<S>> for Map<String, Value> { 113 + type Error = AnyInputError; 114 + 115 + fn try_from(input: AnyInput<S>) -> Result<Self, Self::Error> { 116 + match input { 117 + AnyInput::String(value) => { 118 + // Parse string as JSON 119 + let json_value = serde_json::from_str::<Value>(&value)?; 120 + 121 + // Extract as object 122 + json_value 123 + .as_object() 124 + .cloned() 125 + .ok_or_else(|| AnyInputError::not_an_object(&json_value)) 126 + } 127 + AnyInput::Serialize(value) => { 128 + // Convert to JSON value 129 + let json_value = serde_json::to_value(value)?; 130 + 131 + // Extract as object 132 + json_value 133 + .as_object() 134 + .cloned() 135 + .ok_or_else(|| AnyInputError::not_an_object(&json_value)) 136 + } 137 + } 138 + } 139 + } 140 + 141 + /// Default phantom type for AnyInput when no specific lexicon type is needed. 142 + /// 143 + /// This type serves as the default generic parameter for `AnyInput`, allowing 144 + /// for simpler usage when working with untyped JSON values. 145 + #[derive(Serialize, PartialEq, Clone)] 146 + pub struct PhantomSignature {} 147 + 148 + #[cfg(test)] 149 + mod tests { 150 + use super::*; 151 + 152 + #[test] 153 + fn test_from_str_valid_json() { 154 + let json_str = r#"{"type": "post", "text": "Hello", "count": 42}"#; 155 + let result: Result<AnyInput<serde_json::Value>, _> = json_str.parse(); 156 + 157 + assert!(result.is_ok()); 158 + 159 + let input = result.unwrap(); 160 + match input { 161 + AnyInput::Serialize(value) => { 162 + assert_eq!(value["type"], "post"); 163 + assert_eq!(value["text"], "Hello"); 164 + assert_eq!(value["count"], 42); 165 + } 166 + _ => panic!("Expected AnyInput::Serialize variant"), 167 + } 168 + } 169 + 170 + #[test] 171 + fn test_from_str_invalid_json() { 172 + let invalid_json = r#"{"type": "post", "text": "Hello" invalid json"#; 173 + let result: Result<AnyInput<serde_json::Value>, _> = invalid_json.parse(); 174 + 175 + assert!(result.is_err()); 176 + } 177 + 178 + #[test] 179 + fn test_from_str_array() { 180 + let json_array = r#"[1, 2, 3, "four"]"#; 181 + let result: Result<AnyInput<serde_json::Value>, _> = json_array.parse(); 182 + 183 + assert!(result.is_ok()); 184 + 185 + let input = result.unwrap(); 186 + match input { 187 + AnyInput::Serialize(value) => { 188 + assert!(value.is_array()); 189 + let array = value.as_array().unwrap(); 190 + assert_eq!(array.len(), 4); 191 + assert_eq!(array[0], 1); 192 + assert_eq!(array[3], "four"); 193 + } 194 + _ => panic!("Expected AnyInput::Serialize variant"), 195 + } 196 + } 197 + 198 + #[test] 199 + fn test_from_str_null() { 200 + let null_str = "null"; 201 + let result: Result<AnyInput<serde_json::Value>, _> = null_str.parse(); 202 + 203 + assert!(result.is_ok()); 204 + 205 + let input = result.unwrap(); 206 + match input { 207 + AnyInput::Serialize(value) => { 208 + assert!(value.is_null()); 209 + } 210 + _ => panic!("Expected AnyInput::Serialize variant"), 211 + } 212 + } 213 + 214 + #[test] 215 + fn test_from_str_with_use() { 216 + // Test using the parse method directly with type inference 217 + let input: AnyInput<serde_json::Value> = r#"{"$type": "app.bsky.feed.post"}"# 218 + .parse() 219 + .expect("Failed to parse JSON"); 220 + 221 + match input { 222 + AnyInput::Serialize(value) => { 223 + assert_eq!(value["$type"], "app.bsky.feed.post"); 224 + } 225 + _ => panic!("Expected AnyInput::Serialize variant"), 226 + } 227 + } 228 + 229 + #[test] 230 + fn test_try_into_from_string() { 231 + use std::convert::TryInto; 232 + 233 + let input = AnyInput::<Value>::String(r#"{"type": "post", "text": "Hello"}"#.to_string()); 234 + let result: Result<Map<String, Value>, _> = input.try_into(); 235 + 236 + assert!(result.is_ok()); 237 + let map = result.unwrap(); 238 + assert_eq!(map.get("type").unwrap(), "post"); 239 + assert_eq!(map.get("text").unwrap(), "Hello"); 240 + } 241 + 242 + #[test] 243 + fn test_try_into_from_serialize() { 244 + use serde_json::json; 245 + use std::convert::TryInto; 246 + 247 + let input = AnyInput::Serialize(json!({"$type": "app.bsky.feed.post", "count": 42})); 248 + let result: Result<Map<String, Value>, _> = input.try_into(); 249 + 250 + assert!(result.is_ok()); 251 + let map = result.unwrap(); 252 + assert_eq!(map.get("$type").unwrap(), "app.bsky.feed.post"); 253 + assert_eq!(map.get("count").unwrap(), 42); 254 + } 255 + 256 + #[test] 257 + fn test_try_into_string_not_object() { 258 + use std::convert::TryInto; 259 + 260 + let input = AnyInput::<Value>::String(r#"["array", "not", "object"]"#.to_string()); 261 + let result: Result<Map<String, Value>, AnyInputError> = input.try_into(); 262 + 263 + assert!(result.is_err()); 264 + match result.unwrap_err() { 265 + AnyInputError::NotAnObject { value_type } => { 266 + assert_eq!(value_type, "array"); 267 + } 268 + _ => panic!("Expected NotAnObject error"), 269 + } 270 + } 271 + 272 + #[test] 273 + fn test_try_into_serialize_not_object() { 274 + use serde_json::json; 275 + use std::convert::TryInto; 276 + 277 + let input = AnyInput::Serialize(json!([1, 2, 3])); 278 + let result: Result<Map<String, Value>, AnyInputError> = input.try_into(); 279 + 280 + assert!(result.is_err()); 281 + match result.unwrap_err() { 282 + AnyInputError::NotAnObject { value_type } => { 283 + assert_eq!(value_type, "array"); 284 + } 285 + _ => panic!("Expected NotAnObject error"), 286 + } 287 + } 288 + 289 + #[test] 290 + fn test_try_into_invalid_json_string() { 291 + use std::convert::TryInto; 292 + 293 + let input = AnyInput::<Value>::String("not valid json".to_string()); 294 + let result: Result<Map<String, Value>, AnyInputError> = input.try_into(); 295 + 296 + assert!(result.is_err()); 297 + match result.unwrap_err() { 298 + AnyInputError::JsonParseError(_) => {} 299 + _ => panic!("Expected JsonParseError"), 300 + } 301 + } 302 + 303 + #[test] 304 + fn test_try_into_null() { 305 + use serde_json::json; 306 + use std::convert::TryInto; 307 + 308 + let input = AnyInput::Serialize(json!(null)); 309 + let result: Result<Map<String, Value>, AnyInputError> = input.try_into(); 310 + 311 + assert!(result.is_err()); 312 + match result.unwrap_err() { 313 + AnyInputError::NotAnObject { value_type } => { 314 + assert_eq!(value_type, "null"); 315 + } 316 + _ => panic!("Expected NotAnObject error"), 317 + } 318 + } 319 + 320 + #[test] 321 + fn test_any_input_error_not_an_object() { 322 + use serde_json::json; 323 + 324 + // Test null 325 + let err = AnyInputError::not_an_object(&json!(null)); 326 + match err { 327 + AnyInputError::NotAnObject { value_type } => { 328 + assert_eq!(value_type, "null"); 329 + } 330 + _ => panic!("Expected NotAnObject error"), 331 + } 332 + 333 + // Test boolean 334 + let err = AnyInputError::not_an_object(&json!(true)); 335 + match err { 336 + AnyInputError::NotAnObject { value_type } => { 337 + assert_eq!(value_type, "boolean"); 338 + } 339 + _ => panic!("Expected NotAnObject error"), 340 + } 341 + 342 + // Test number 343 + let err = AnyInputError::not_an_object(&json!(42)); 344 + match err { 345 + AnyInputError::NotAnObject { value_type } => { 346 + assert_eq!(value_type, "number"); 347 + } 348 + _ => panic!("Expected NotAnObject error"), 349 + } 350 + 351 + // Test string 352 + let err = AnyInputError::not_an_object(&json!("hello")); 353 + match err { 354 + AnyInputError::NotAnObject { value_type } => { 355 + assert_eq!(value_type, "string"); 356 + } 357 + _ => panic!("Expected NotAnObject error"), 358 + } 359 + 360 + // Test array 361 + let err = AnyInputError::not_an_object(&json!([1, 2, 3])); 362 + match err { 363 + AnyInputError::NotAnObject { value_type } => { 364 + assert_eq!(value_type, "array"); 365 + } 366 + _ => panic!("Expected NotAnObject error"), 367 + } 368 + } 369 + 370 + #[test] 371 + fn test_error_display() { 372 + use serde_json::json; 373 + 374 + // Test NotAnObject error display 375 + let err = AnyInputError::not_an_object(&json!(42)); 376 + assert_eq!(err.to_string(), "Expected JSON object, but got number"); 377 + 378 + // Test InvalidJson display 379 + let err = AnyInputError::InvalidJson { 380 + message: "unexpected token".to_string() 381 + }; 382 + assert_eq!(err.to_string(), "Invalid JSON string: unexpected token"); 383 + } 384 + }
+40 -31
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 // Public modules 23 pub mod errors; 24 25 // Internal modules 26 mod attestation; 27 - mod cid; 28 mod signature; 29 - mod types; 30 mod utils; 31 mod verification; 32 33 // Re-export error type 34 pub use errors::AttestationError; 35 36 - // Re-export types 37 - pub use types::{AttestationKind, VerificationReport, VerificationStatus}; 38 - 39 - // Re-export CID generation 40 - pub use cid::create_cid; 41 42 // Re-export signature normalization 43 pub use signature::normalize_signature; 44 45 // Re-export attestation functions 46 pub use attestation::{ 47 - create_inline_attestation, 48 - create_inline_attestation_reference, 49 create_remote_attestation, 50 - create_remote_attestation_reference, 51 - prepare_signing_record, 52 }; 53 54 // Re-export verification functions 55 - pub use verification::{ 56 - verify_all_signatures, 57 - verify_all_signatures_with_resolver, 58 - verify_signature, 59 - verify_signature_with_resolver, 60 - }; 61 62 /// Resolver trait for retrieving remote attestation records by AT URI. 63 /// 64 /// This trait is re-exported from atproto_client for convenience. 65 - pub use atproto_client::record_resolver::RecordResolver;
··· 1 //! AT Protocol record attestation utilities based on the CID-first specification. 2 //! 3 + //! This crate implements helpers for creating inline and remote attestations 4 + //! and verifying signatures against DID verification methods. It follows the 5 + //! requirements documented in `bluesky-attestation-tee/documentation/spec/attestation.md`. 6 //! 7 + //! ## Inline Attestations 8 + //! 9 + //! Use `create_inline_attestation` to create a signed record with an embedded signature: 10 + //! 11 + //! ```no_run 12 + //! use atproto_attestation::{create_inline_attestation, AnyInput}; 13 + //! use atproto_identity::key::{generate_key, KeyType}; 14 + //! use serde_json::json; 15 + //! 16 + //! # fn main() -> Result<(), Box<dyn std::error::Error>> { 17 + //! let key = generate_key(KeyType::P256Private)?; 18 + //! let record = json!({"$type": "app.example.post", "text": "Hello!"}); 19 + //! let metadata = json!({"$type": "com.example.sig", "key": "did:key:..."}); 20 + //! 21 + //! let signed = create_inline_attestation( 22 + //! AnyInput::Serialize(record), 23 + //! AnyInput::Serialize(metadata), 24 + //! "did:plc:repository", 25 + //! &key 26 + //! )?; 27 + //! # Ok(()) 28 + //! # } 29 + //! ``` 30 + //! 31 + //! ## Remote Attestations 32 //! 33 + //! Use `create_remote_attestation` to generate both the proof record and the 34 + //! attested record with strongRef in a single call. 35 36 #![forbid(unsafe_code)] 37 #![warn(missing_docs)] 38 39 // Public modules 40 + pub mod cid; 41 pub mod errors; 42 + pub mod input; 43 44 // Internal modules 45 mod attestation; 46 mod signature; 47 mod utils; 48 mod verification; 49 50 // Re-export error type 51 pub use errors::AttestationError; 52 53 + // Re-export CID generation functions 54 + pub use cid::{create_dagbor_cid}; 55 56 // Re-export signature normalization 57 pub use signature::normalize_signature; 58 59 // Re-export attestation functions 60 pub use attestation::{ 61 + append_inline_attestation, append_remote_attestation, create_inline_attestation, 62 create_remote_attestation, 63 }; 64 65 + // Re-export input types 66 + pub use input::{AnyInput, AnyInputError}; 67 + 68 // Re-export verification functions 69 + pub use verification::verify_record; 70 71 /// Resolver trait for retrieving remote attestation records by AT URI. 72 /// 73 /// This trait is re-exported from atproto_client for convenience. 74 + pub use atproto_client::record_resolver::RecordResolver;
+4 -123
crates/atproto-attestation/src/signature.rs
··· 1 - //! ECDSA signature normalization and validation. 2 //! 3 //! This module handles signature normalization to the low-S form required by 4 //! the AT Protocol attestation specification, preventing signature malleability attacks. 5 6 use crate::errors::AttestationError; 7 - use atproto_identity::key::{KeyData, KeyType}; 8 - use elliptic_curve::scalar::IsHigh; 9 use k256::ecdsa::Signature as K256Signature; 10 use p256::ecdsa::Signature as P256Signature; 11 ··· 36 KeyType::P256Private | KeyType::P256Public => normalize_p256(signature), 37 KeyType::K256Private | KeyType::K256Public => normalize_k256(signature), 38 other => Err(AttestationError::UnsupportedKeyType { 39 - key_type: other.clone(), 40 }), 41 } 42 } 43 44 - /// Ensure a signature is in normalized low-S form. 45 - /// 46 - /// Used during verification to reject non-normalized signatures. 47 - /// 48 - /// # Arguments 49 - /// 50 - /// * `key_data` - The key data containing the key type 51 - /// * `signature` - The signature bytes to validate 52 - /// 53 - /// # Returns 54 - /// 55 - /// Ok if the signature is normalized, error otherwise 56 - pub(crate) fn ensure_normalized_signature( 57 - key_data: &KeyData, 58 - signature: &[u8], 59 - ) -> Result<(), AttestationError> { 60 - match key_data.key_type() { 61 - KeyType::P256Private | KeyType::P256Public => { 62 - if signature.len() != 64 { 63 - return Err(AttestationError::SignatureLengthInvalid { 64 - expected: 64, 65 - actual: signature.len(), 66 - }); 67 - } 68 - 69 - let parsed = P256Signature::from_slice(signature).map_err(|_| { 70 - AttestationError::SignatureLengthInvalid { 71 - expected: 64, 72 - actual: signature.len(), 73 - } 74 - })?; 75 - 76 - if bool::from(parsed.s().is_high()) { 77 - return Err(AttestationError::SignatureNotNormalized); 78 - } 79 - } 80 - KeyType::K256Private | KeyType::K256Public => { 81 - if signature.len() != 64 { 82 - return Err(AttestationError::SignatureLengthInvalid { 83 - expected: 64, 84 - actual: signature.len(), 85 - }); 86 - } 87 - 88 - let parsed = K256Signature::from_slice(signature).map_err(|_| { 89 - AttestationError::SignatureLengthInvalid { 90 - expected: 64, 91 - actual: signature.len(), 92 - } 93 - })?; 94 - 95 - if bool::from(parsed.s().is_high()) { 96 - return Err(AttestationError::SignatureNotNormalized); 97 - } 98 - } 99 - other => { 100 - return Err(AttestationError::UnsupportedKeyType { 101 - key_type: other.clone(), 102 - }); 103 - } 104 - } 105 - 106 - Ok(()) 107 - } 108 - 109 /// Normalize a P-256 signature to low-S form. 110 fn normalize_p256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> { 111 if signature.len() != 64 { ··· 151 #[cfg(test)] 152 mod tests { 153 use super::*; 154 - use atproto_identity::key::{generate_key, sign, to_public}; 155 - 156 - #[test] 157 - fn normalize_p256_signature() -> Result<(), Box<dyn std::error::Error>> { 158 - // Create a real signature using P-256 key 159 - let private_key = generate_key(KeyType::P256Private)?; 160 - let message = b"test message"; 161 - let signature = sign(&private_key, message)?; 162 - 163 - let result = normalize_p256(signature.clone())?; 164 - assert_eq!(result.len(), 64); 165 - 166 - // Verify the signature is normalized (low-S) 167 - let parsed = P256Signature::from_slice(&result)?; 168 - assert!(!bool::from(parsed.s().is_high())); 169 - 170 - Ok(()) 171 - } 172 - 173 - #[test] 174 - fn normalize_k256_signature() -> Result<(), Box<dyn std::error::Error>> { 175 - // Create a real signature using K-256 key 176 - let private_key = generate_key(KeyType::K256Private)?; 177 - let message = b"test message"; 178 - let signature = sign(&private_key, message)?; 179 - 180 - let result = normalize_k256(signature.clone())?; 181 - assert_eq!(result.len(), 64); 182 - 183 - // Verify the signature is normalized (low-S) 184 - let parsed = K256Signature::from_slice(&result)?; 185 - assert!(!bool::from(parsed.s().is_high())); 186 - 187 - Ok(()) 188 - } 189 190 #[test] 191 fn reject_invalid_signature_length() { ··· 196 Err(AttestationError::SignatureLengthInvalid { expected: 64, .. }) 197 )); 198 } 199 - 200 - #[test] 201 - fn ensure_normalized_accepts_low_s() -> Result<(), Box<dyn std::error::Error>> { 202 - // Create a valid, normalized signature 203 - let key = generate_key(KeyType::K256Private)?; 204 - let public_key = to_public(&key)?; 205 - let message = b"test message"; 206 - let signature = sign(&key, message)?; 207 - 208 - // Normalize it first to ensure low-S 209 - let normalized = normalize_k256(signature)?; 210 - 211 - // This should succeed because the signature is normalized 212 - let result = ensure_normalized_signature(&public_key, &normalized); 213 - assert!(result.is_ok()); 214 - 215 - Ok(()) 216 - } 217 - }
··· 1 + //! ECDSA signature normalization. 2 //! 3 //! This module handles signature normalization to the low-S form required by 4 //! the AT Protocol attestation specification, preventing signature malleability attacks. 5 6 use crate::errors::AttestationError; 7 + use atproto_identity::key::KeyType; 8 use k256::ecdsa::Signature as K256Signature; 9 use p256::ecdsa::Signature as P256Signature; 10 ··· 35 KeyType::P256Private | KeyType::P256Public => normalize_p256(signature), 36 KeyType::K256Private | KeyType::K256Public => normalize_k256(signature), 37 other => Err(AttestationError::UnsupportedKeyType { 38 + key_type: (*other).clone(), 39 }), 40 } 41 } 42 43 /// Normalize a P-256 signature to low-S form. 44 fn normalize_p256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> { 45 if signature.len() != 64 { ··· 85 #[cfg(test)] 86 mod tests { 87 use super::*; 88 89 #[test] 90 fn reject_invalid_signature_length() { ··· 95 Err(AttestationError::SignatureLengthInvalid { expected: 64, .. }) 96 )); 97 } 98 + }
-51
crates/atproto-attestation/src/types.rs
··· 1 - //! Type definitions for AT Protocol attestations. 2 - //! 3 - //! This module defines the core types used throughout the attestation framework, 4 - //! including attestation kinds, verification statuses, and report structures. 5 - 6 - use crate::errors::AttestationError; 7 - use cid::Cid; 8 - 9 - /// Kind of attestation represented within the `signatures` array. 10 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 11 - pub enum AttestationKind { 12 - /// Inline attestation containing signature bytes. 13 - Inline, 14 - /// Remote attestation referencing a proof record via strongRef. 15 - Remote, 16 - } 17 - 18 - /// Result of verifying a single attestation entry. 19 - #[derive(Debug)] 20 - pub enum VerificationStatus { 21 - /// Signature is valid for the reconstructed signing payload. 22 - Valid { 23 - /// CID produced for the reconstructed record. 24 - cid: Cid, 25 - }, 26 - /// Signature verification or metadata validation failed. 27 - Invalid { 28 - /// Failure reason. 29 - error: AttestationError, 30 - }, 31 - /// Attestation cannot be verified locally (e.g., remote references). 32 - Unverified { 33 - /// Explanation for why verification was skipped. 34 - reason: String, 35 - }, 36 - } 37 - 38 - /// Structured verification report for a single attestation entry. 39 - #[derive(Debug)] 40 - pub struct VerificationReport { 41 - /// Zero-based index of the signature in the record's `signatures` array. 42 - pub index: usize, 43 - /// Detected attestation kind. 44 - pub kind: AttestationKind, 45 - /// `$type` discriminator from the attestation entry, if present. 46 - pub signature_type: Option<String>, 47 - /// Key reference for inline signatures (if available). 48 - pub key: Option<String>, 49 - /// Verification outcome. 50 - pub status: VerificationStatus, 51 - }
···
+1 -34
crates/atproto-attestation/src/utils.rs
··· 1 //! Utility functions and constants for attestation operations. 2 //! 3 //! This module provides common utilities used throughout the attestation framework, 4 - //! including signature array manipulation and base64 encoding/decoding. 5 6 - use crate::errors::AttestationError; 7 use base64::{ 8 alphabet::STANDARD as STANDARD_ALPHABET, 9 engine::{ ··· 11 general_purpose::{GeneralPurpose, GeneralPurposeConfig}, 12 }, 13 }; 14 - use serde_json::{Map, Value}; 15 16 /// Base64 engine that accepts both padded and unpadded input for maximum compatibility 17 /// with various AT Protocol implementations. Uses standard encoding with padding for output, ··· 22 .with_encode_padding(true) 23 .with_decode_padding_mode(DecodePaddingMode::Indifferent), 24 ); 25 - 26 - /// Type identifier for AT Protocol strongRef objects. 27 - pub(crate) const STRONG_REF_TYPE: &str = "com.atproto.repo.strongRef"; 28 - 29 - /// Extract the signatures array from a record for verification. 30 - /// 31 - /// Returns an error if the signatures field is missing or not an array. 32 - pub(crate) fn extract_signatures_array(record: &Value) -> Result<&Vec<Value>, AttestationError> { 33 - let signatures = record.get("signatures"); 34 - 35 - match signatures { 36 - Some(value) => value 37 - .as_array() 38 - .ok_or(AttestationError::SignaturesFieldInvalid), 39 - None => Err(AttestationError::SignaturesArrayMissing), 40 - } 41 - } 42 - 43 - /// Extract and remove the signatures array from a record for modification. 44 - /// 45 - /// Returns the existing signatures array or an empty vector if not present. 46 - /// The signatures field is removed from the record map. 47 - pub(crate) fn extract_signatures_vec(record: &mut Map<String, Value>) -> Result<Vec<Value>, AttestationError> { 48 - let existing = record.remove("signatures"); 49 - 50 - match existing { 51 - Some(Value::Array(array)) => Ok(array), 52 - Some(_) => Err(AttestationError::SignaturesFieldInvalid), 53 - None => Ok(Vec::new()), 54 - } 55 - }
··· 1 //! Utility functions and constants for attestation operations. 2 //! 3 //! This module provides common utilities used throughout the attestation framework, 4 + //! including base64 encoding/decoding with flexible padding support. 5 6 use base64::{ 7 alphabet::STANDARD as STANDARD_ALPHABET, 8 engine::{ ··· 10 general_purpose::{GeneralPurpose, GeneralPurposeConfig}, 11 }, 12 }; 13 14 /// Base64 engine that accepts both padded and unpadded input for maximum compatibility 15 /// with various AT Protocol implementations. Uses standard encoding with padding for output, ··· 20 .with_encode_padding(true) 21 .with_decode_padding_mode(DecodePaddingMode::Indifferent), 22 );
+120 -533
crates/atproto-attestation/src/verification.rs
··· 1 //! Signature verification for AT Protocol attestations. 2 //! 3 - //! This module provides comprehensive verification functions for both inline 4 - //! and remote attestations, with support for custom key and record resolvers. 5 6 - use crate::attestation::prepare_signing_record; 7 - use crate::cid::{create_cid, create_plain_cid}; 8 use crate::errors::AttestationError; 9 - use crate::signature::ensure_normalized_signature; 10 - use crate::types::{AttestationKind, VerificationReport, VerificationStatus}; 11 - use crate::utils::{extract_signatures_array, BASE64, STRONG_REF_TYPE}; 12 - use atproto_identity::key::{KeyData, KeyResolver, identify_key, validate}; 13 use base64::Engine; 14 - use cid::Cid; 15 - use serde_json::{Map, Value}; 16 17 - /// Verify a single attestation entry with repository binding. 18 - /// 19 - /// Validates that the attestation was created for the specified repository DID 20 - /// to prevent replay attacks. 21 - /// 22 - /// # Arguments 23 - /// 24 - /// * `record` - The record containing signatures to verify 25 - /// * `index` - Zero-based index of the signature to verify 26 - /// * `repository_did` - The DID of the repository housing this record 27 - /// * `key_resolver` - Optional resolver for DID document keys 28 - /// 29 - /// # Returns 30 - /// 31 - /// A verification report with the validation outcome 32 - pub async fn verify_signature( 33 - record: &Value, 34 - index: usize, 35 - repository_did: &str, 36 - key_resolver: Option<&dyn KeyResolver>, 37 - ) -> Result<VerificationReport, AttestationError> { 38 - verify_signature_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>( 39 - record, 40 - index, 41 - repository_did, 42 - key_resolver, 43 - None, 44 - ) 45 - .await 46 } 47 48 - /// Verify a single attestation entry with repository binding and optional record resolver. 49 /// 50 - /// Validates that the attestation was created for the specified repository DID 51 - /// to prevent replay attacks across different repositories. 52 /// 53 /// # Arguments 54 /// 55 - /// * `record` - The record containing signatures to verify 56 - /// * `index` - Zero-based index of the signature to verify 57 - /// * `repository_did` - The DID of the repository housing this record 58 - /// * `key_resolver` - Optional resolver for DID document keys 59 - /// * `record_resolver` - Optional resolver for fetching remote attestation records 60 /// 61 /// # Returns 62 /// 63 - /// A verification report with the validation outcome 64 - pub async fn verify_signature_with_resolver<R>( 65 - record: &Value, 66 - index: usize, 67 - repository_did: &str, 68 - key_resolver: Option<&dyn KeyResolver>, 69 - record_resolver: Option<&R>, 70 - ) -> Result<VerificationReport, AttestationError> 71 - where 72 - R: atproto_client::record_resolver::RecordResolver, 73 - { 74 - let signatures_array = extract_signatures_array(record)?; 75 - let signature_entry = signatures_array 76 - .get(index) 77 - .ok_or(AttestationError::SignatureIndexOutOfBounds { index })?; 78 - 79 - let signature_map = 80 - signature_entry 81 - .as_object() 82 - .ok_or_else(|| AttestationError::SignatureMissingField { 83 - field: "object".to_string(), 84 - })?; 85 - 86 - let signature_type = signature_map 87 - .get("$type") 88 - .and_then(Value::as_str) 89 - .map(ToOwned::to_owned); 90 - 91 - let report_kind = match signature_type.as_deref() { 92 - Some(STRONG_REF_TYPE) => AttestationKind::Remote, 93 - _ => AttestationKind::Inline, 94 - }; 95 - 96 - let key_reference = signature_map 97 - .get("key") 98 - .and_then(Value::as_str) 99 - .map(ToOwned::to_owned); 100 - 101 - let status = match report_kind { 102 - AttestationKind::Remote => { 103 - match record_resolver { 104 - Some(resolver) => { 105 - match verify_remote_attestation(record, signature_map, repository_did, resolver).await { 106 - Ok(cid) => VerificationStatus::Valid { cid }, 107 - Err(error) => VerificationStatus::Invalid { error }, 108 - } 109 - } 110 - None => VerificationStatus::Unverified { 111 - reason: "Remote attestations require a record resolver to fetch the proof record via strongRef.".to_string(), 112 - }, 113 - } 114 - } 115 - AttestationKind::Inline => { 116 - match verify_inline_attestation(record, signature_map, repository_did, key_resolver).await { 117 - Ok(cid) => VerificationStatus::Valid { cid }, 118 - Err(error) => VerificationStatus::Invalid { error }, 119 - } 120 - } 121 - }; 122 - 123 - Ok(VerificationReport { 124 - index, 125 - kind: report_kind, 126 - signature_type, 127 - key: key_reference, 128 - status, 129 - }) 130 - } 131 - 132 - /// Verify all attestation entries with repository binding. 133 /// 134 - /// Validates that attestations were created for the specified repository DID 135 - /// to prevent replay attacks. 136 /// 137 - /// # Arguments 138 /// 139 - /// * `record` - The record containing signatures to verify 140 - /// * `repository_did` - The DID of the repository housing this record 141 - /// * `key_resolver` - Optional resolver for DID document keys 142 /// 143 - /// # Returns 144 - /// 145 - /// A vector of verification reports, one for each signature 146 - pub async fn verify_all_signatures( 147 - record: &Value, 148 - repository_did: &str, 149 - key_resolver: Option<&dyn KeyResolver>, 150 - ) -> Result<Vec<VerificationReport>, AttestationError> { 151 - verify_all_signatures_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>( 152 - record, 153 - repository_did, 154 - key_resolver, 155 - None, 156 - ) 157 - .await 158 - } 159 - 160 - /// Verify all attestation entries with repository binding and optional record resolver. 161 - /// 162 - /// Validates that all attestations were created for the specified repository DID 163 - /// to prevent replay attacks across different repositories. 164 - /// 165 - /// # Arguments 166 - /// 167 - /// * `record` - The record containing signatures to verify 168 - /// * `repository_did` - The DID of the repository housing this record 169 - /// * `key_resolver` - Optional resolver for DID document keys 170 - /// * `record_resolver` - Optional resolver for fetching remote attestation records 171 - /// 172 - /// # Returns 173 - /// 174 - /// A vector of verification reports, one for each signature 175 - pub async fn verify_all_signatures_with_resolver<R>( 176 - record: &Value, 177 - repository_did: &str, 178 - key_resolver: Option<&dyn KeyResolver>, 179 - record_resolver: Option<&R>, 180 - ) -> Result<Vec<VerificationReport>, AttestationError> 181 where 182 - R: atproto_client::record_resolver::RecordResolver, 183 { 184 - let signatures_array = extract_signatures_array(record)?; 185 - let mut reports = Vec::with_capacity(signatures_array.len()); 186 187 - for index in 0..signatures_array.len() { 188 - reports.push( 189 - verify_signature_with_resolver( 190 - record, 191 - index, 192 - repository_did, 193 - key_resolver, 194 - record_resolver 195 - ).await?, 196 - ); 197 - } 198 199 - Ok(reports) 200 - } 201 - 202 - /// Verify a remote attestation by fetching and validating the proof record. 203 - async fn verify_remote_attestation<R>( 204 - record: &Value, 205 - signature_object: &Map<String, Value>, 206 - repository_did: &str, 207 - record_resolver: &R, 208 - ) -> Result<Cid, AttestationError> 209 - where 210 - R: atproto_client::record_resolver::RecordResolver, 211 - { 212 - // Extract the strongRef URI and CID 213 - let uri = signature_object 214 - .get("uri") 215 - .and_then(Value::as_str) 216 - .ok_or_else(|| AttestationError::SignatureMissingField { 217 - field: "uri".to_string(), 218 - })?; 219 - 220 - let expected_cid_str = signature_object 221 - .get("cid") 222 - .and_then(Value::as_str) 223 - .ok_or_else(|| AttestationError::SignatureMissingField { 224 - field: "cid".to_string(), 225 - })?; 226 - 227 - // Fetch the proof record from the URI 228 - let proof_record: Value = record_resolver.resolve(uri).await.map_err(|error| { 229 - AttestationError::RemoteAttestationFetchFailed { 230 - uri: uri.to_string(), 231 - error, 232 - } 233 - })?; 234 - 235 - // Verify the proof record CID matches 236 - let proof_cid = create_plain_cid(&proof_record)?; 237 - if proof_cid.to_string() != expected_cid_str { 238 - return Err(AttestationError::RemoteAttestationCidMismatch { 239 - expected: expected_cid_str.to_string(), 240 - actual: proof_cid.to_string(), 241 - }); 242 } 243 244 - // Extract the CID from the proof record 245 - let attestation_cid_str = proof_record 246 - .get("cid") 247 - .and_then(Value::as_str) 248 - .ok_or_else(|| AttestationError::SignatureMissingField { 249 - field: "cid".to_string(), 250 - })?; 251 252 - // Parse the attestation CID 253 - let attestation_cid = 254 - attestation_cid_str 255 - .parse::<Cid>() 256 - .map_err(|_| AttestationError::InvalidCid { 257 - cid: attestation_cid_str.to_string(), 258 - })?; 259 - 260 - // Prepare the signing record using the proof record as metadata (without the CID field) 261 - let mut proof_metadata = proof_record 262 - .as_object() 263 - .cloned() 264 - .ok_or(AttestationError::RecordMustBeObject)?; 265 - proof_metadata.remove("cid"); 266 267 - let signing_record = prepare_signing_record(record, &Value::Object(proof_metadata), repository_did)?; 268 - let computed_cid = create_cid(&signing_record)?; 269 270 - // Verify the CID matches 271 - if computed_cid != attestation_cid { 272 - return Err(AttestationError::RemoteAttestationCidMismatch { 273 - expected: attestation_cid.to_string(), 274 - actual: computed_cid.to_string(), 275 - }); 276 - } 277 - 278 - Ok(computed_cid) 279 - } 280 - 281 - /// Verify an inline attestation by validating the signature. 282 - async fn verify_inline_attestation( 283 - record: &Value, 284 - signature_object: &Map<String, Value>, 285 - repository_did: &str, 286 - key_resolver: Option<&dyn KeyResolver>, 287 - ) -> Result<Cid, AttestationError> { 288 - let key_reference = signature_object 289 - .get("key") 290 - .and_then(Value::as_str) 291 - .ok_or_else(|| AttestationError::SignatureMissingField { 292 - field: "key".to_string(), 293 - })?; 294 - 295 - let key_data = resolve_key_reference(key_reference, key_resolver).await?; 296 - 297 - let signature_bytes = signature_object 298 - .get("signature") 299 - .and_then(Value::as_object) 300 - .and_then(|object| object.get("$bytes")) 301 - .and_then(Value::as_str) 302 - .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 303 - 304 - let signature_bytes = BASE64 305 - .decode(signature_bytes) 306 - .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 307 - 308 - ensure_normalized_signature(&key_data, &signature_bytes)?; 309 - 310 - let mut sig_metadata = signature_object.clone(); 311 - sig_metadata.remove("signature"); 312 - 313 - let signing_record = prepare_signing_record(record, &Value::Object(sig_metadata), repository_did)?; 314 - let cid = create_cid(&signing_record)?; 315 - let cid_bytes = cid.to_bytes(); 316 - 317 - validate(&key_data, &signature_bytes, &cid_bytes) 318 - .map_err(|error| AttestationError::SignatureValidationFailed { error })?; 319 - 320 - Ok(cid) 321 - } 322 - 323 - /// Resolve a key reference to key data using available resolution methods. 324 - async fn resolve_key_reference( 325 - key_reference: &str, 326 - key_resolver: Option<&dyn KeyResolver>, 327 - ) -> Result<KeyData, AttestationError> { 328 - // Try to parse as did:key directly 329 - if let Some(base) = key_reference.split('#').next() 330 - && let Ok(key_data) = identify_key(base) { 331 - return Ok(key_data); 332 - } 333 - 334 - // Try the full reference as did:key 335 - if let Ok(key_data) = identify_key(key_reference) { 336 - return Ok(key_data); 337 - } 338 - 339 - // Fall back to key resolver for DID document keys 340 - let resolver = key_resolver.ok_or_else(|| AttestationError::KeyResolverRequired { 341 - key: key_reference.to_string(), 342 - })?; 343 - 344 - resolver 345 - .resolve(key_reference) 346 - .await 347 - .map_err(|error| AttestationError::KeyResolutionFailed { 348 - key: key_reference.to_string(), 349 - error, 350 - }) 351 - } 352 - 353 - #[cfg(test)] 354 - mod tests { 355 - use super::*; 356 - use crate::attestation::create_inline_attestation; 357 - use atproto_identity::key::{IdentityDocumentKeyResolver, KeyType, generate_key, to_public}; 358 - use atproto_identity::model::{Document, DocumentBuilder, VerificationMethod}; 359 - use atproto_identity::resolve::IdentityResolver; 360 - use serde_json::json; 361 - use std::sync::Arc; 362 - 363 - struct StaticResolver { 364 - document: Document, 365 - } 366 - 367 - #[async_trait::async_trait] 368 - impl IdentityResolver for StaticResolver { 369 - async fn resolve(&self, _subject: &str) -> anyhow::Result<Document> { 370 - Ok(self.document.clone()) 371 - } 372 - } 373 - 374 - #[tokio::test] 375 - async fn verify_inline_signature_with_did_key() -> Result<(), Box<dyn std::error::Error>> { 376 - let private_key = generate_key(KeyType::K256Private)?; 377 - let public_key = to_public(&private_key)?; 378 - let key_reference = format!("{}", &public_key); 379 - let repository_did = "did:plc:testrepository123"; 380 - 381 - let base_record = json!({ 382 - "$type": "app.example.record", 383 - "body": "Sign me" 384 - }); 385 - 386 - let sig_metadata = json!({ 387 - "$type": "com.example.inlineSignature", 388 - "key": key_reference, 389 - "purpose": "unit-test" 390 - }); 391 - 392 - let signed = create_inline_attestation( 393 - &base_record, 394 - &sig_metadata, 395 - repository_did, 396 - &private_key, 397 )?; 398 399 - let report = verify_signature(&signed, 0, repository_did, None).await?; 400 - match report.status { 401 - VerificationStatus::Valid { .. } => {} 402 - other => panic!("expected valid signature, got {:?}", other), 403 - } 404 - 405 - Ok(()) 406 - } 407 - 408 - #[tokio::test] 409 - async fn verify_inline_signature_with_resolver() -> Result<(), Box<dyn std::error::Error>> { 410 - let private_key = generate_key(KeyType::P256Private)?; 411 - let public_key = to_public(&private_key)?; 412 - let key_multibase = format!("{}", &public_key); 413 - let key_reference = "did:plc:resolvertest#atproto".to_string(); 414 - let repository_did = "did:plc:resolvertest"; 415 - 416 - let document = DocumentBuilder::new() 417 - .id("did:plc:resolvertest") 418 - .add_verification_method(VerificationMethod::Multikey { 419 - id: key_reference.clone(), 420 - controller: "did:plc:resolvertest".to_string(), 421 - public_key_multibase: key_multibase 422 - .strip_prefix("did:key:") 423 - .unwrap_or(&key_multibase) 424 - .to_string(), 425 - extra: std::collections::HashMap::new(), 426 - }) 427 - .build() 428 - .unwrap(); 429 - 430 - let identity_resolver = Arc::new(StaticResolver { document }); 431 - let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver.clone()); 432 - 433 - let base_record = json!({ 434 - "$type": "app.example.record", 435 - "body": "resolver test" 436 - }); 437 - 438 - let sig_metadata = json!({ 439 - "$type": "com.example.inlineSignature", 440 - "key": key_reference, 441 - "scope": "resolver" 442 - }); 443 444 - let signed = create_inline_attestation( 445 - &base_record, 446 - &sig_metadata, 447 - repository_did, 448 - &private_key, 449 - )?; 450 - 451 - let report = 452 - verify_signature(&signed, 0, repository_did, Some(&key_resolver)) 453 - .await?; 454 - match report.status { 455 - VerificationStatus::Valid { .. } => {} 456 - other => panic!("expected valid signature, got {:?}", other), 457 } 458 459 - Ok(()) 460 - } 461 - 462 - #[tokio::test] 463 - async fn verify_all_signatures_reports_remote() -> Result<(), Box<dyn std::error::Error>> { 464 - let repository_did = "did:plc:example"; 465 - let record = json!({ 466 - "$type": "app.example.record", 467 - "signatures": [ 468 - { 469 - "$type": STRONG_REF_TYPE, 470 - "cid": "bafyreid473y2gjzvzgjwdj3vpbk2bdzodf5hvbgxncjc62xmy3zsmb3pxq", 471 - "uri": "at://did:plc:example/com.example.attestation/abc123" 472 - } 473 - ] 474 - }); 475 - 476 - let reports = verify_all_signatures(&record, repository_did, None).await?; 477 - assert_eq!(reports.len(), 1); 478 - match &reports[0].status { 479 - VerificationStatus::Unverified { reason } => { 480 - assert!(reason.contains("Remote attestations")); 481 } 482 - other => panic!("expected unverified status, got {:?}", other), 483 - } 484 485 - Ok(()) 486 - } 487 488 - #[tokio::test] 489 - async fn verify_detects_tampering() -> Result<(), Box<dyn std::error::Error>> { 490 - let private_key = generate_key(KeyType::K256Private)?; 491 - let public_key = to_public(&private_key)?; 492 - let key_reference = format!("{}", &public_key); 493 - let repository_did = "did:plc:tampertest"; 494 495 - let base_record = json!({ 496 - "$type": "app.example.record", 497 - "body": "original" 498 - }); 499 500 - let sig_metadata = json!({ 501 - "$type": "com.example.inlineSignature", 502 - "key": key_reference 503 - }); 504 - 505 - let mut signed = create_inline_attestation( 506 - &base_record, 507 - &sig_metadata, 508 - repository_did, 509 - &private_key, 510 - )?; 511 - if let Some(object) = signed.as_object_mut() { 512 - object.insert("body".to_string(), json!("tampered")); 513 - } 514 - 515 - let report = verify_signature(&signed, 0, repository_did, None).await?; 516 - match report.status { 517 - VerificationStatus::Invalid { .. } => {} 518 - other => panic!("expected invalid signature, got {:?}", other), 519 - } 520 - 521 - Ok(()) 522 } 523 524 - #[tokio::test] 525 - async fn verify_repository_field_prevents_replay_attack( 526 - ) -> Result<(), Box<dyn std::error::Error>> { 527 - let private_key = generate_key(KeyType::K256Private)?; 528 - let public_key = to_public(&private_key)?; 529 - let key_reference = format!("{}", &public_key); 530 - let original_repository = "did:plc:originalrepo"; 531 - let attacker_repository = "did:plc:attackerrepo"; 532 - 533 - let base_record = json!({ 534 - "$type": "app.example.record", 535 - "body": "Important content" 536 - }); 537 - 538 - let sig_metadata = json!({ 539 - "$type": "com.example.inlineSignature", 540 - "key": key_reference, 541 - "purpose": "original-attestation" 542 - }); 543 - 544 - // Create attestation for original repository 545 - let signed = create_inline_attestation( 546 - &base_record, 547 - &sig_metadata, 548 - original_repository, 549 - &private_key, 550 - )?; 551 - 552 - // Verify succeeds with correct repository 553 - let report = 554 - verify_signature(&signed, 0, original_repository, None).await?; 555 - match report.status { 556 - VerificationStatus::Valid { .. } => {} 557 - other => panic!("expected valid signature for original repo, got {:?}", other), 558 - } 559 - 560 - // Verify FAILS with different repository (simulating replay attack) 561 - let report = 562 - verify_signature(&signed, 0, attacker_repository, None).await?; 563 - match report.status { 564 - VerificationStatus::Invalid { .. } => {} 565 - other => panic!( 566 - "expected invalid signature for attacker repo, got {:?}", 567 - other 568 - ), 569 - } 570 - 571 - Ok(()) 572 - } 573 - }
··· 1 //! Signature verification for AT Protocol attestations. 2 //! 3 + //! This module provides verification functions for AT Protocol record attestations. 4 5 + use crate::cid::create_attestation_cid; 6 use crate::errors::AttestationError; 7 + use crate::input::AnyInput; 8 + use crate::utils::BASE64; 9 + use atproto_identity::key::{KeyResolver, validate}; 10 + use atproto_record::lexicon::com::atproto::repo::STRONG_REF_NSID; 11 use base64::Engine; 12 + use serde::Serialize; 13 + use serde_json::{Value, Map}; 14 + use std::convert::TryInto; 15 16 + /// Helper function to extract and validate signatures array from a record 17 + fn extract_signatures(record_object: &Map<String, Value>) -> Result<Vec<Value>, AttestationError> { 18 + match record_object.get("signatures") { 19 + Some(value) => value 20 + .as_array() 21 + .ok_or(AttestationError::SignaturesFieldInvalid) 22 + .cloned(), 23 + None => Ok(vec![]), 24 + } 25 } 26 27 + /// Verify all signatures in a record with flexible input types. 28 /// 29 + /// This is a high-level verification function that accepts records in multiple formats 30 + /// (String, Json, or TypedLexicon) and verifies all signatures with custom resolvers. 31 /// 32 /// # Arguments 33 /// 34 + /// * `verify_input` - The record to verify (as AnyInput: String, Json, or TypedLexicon) 35 + /// * `repository` - The repository DID to validate against (prevents replay attacks) 36 + /// * `key_resolver` - Resolver for looking up verification keys from DIDs 37 + /// * `record_resolver` - Resolver for fetching remote attestation proof records 38 /// 39 /// # Returns 40 /// 41 + /// Returns `Ok(())` if all signatures are valid, or an error if any verification fails. 42 /// 43 + /// # Errors 44 /// 45 + /// Returns an error if: 46 + /// - The input is not a valid record object 47 + /// - Any signature verification fails 48 + /// - Key or record resolution fails 49 /// 50 + /// # Type Parameters 51 /// 52 + /// * `R` - The record type (must implement Serialize + LexiconType + PartialEq + Clone) 53 + /// * `RR` - The record resolver type (must implement RecordResolver) 54 + /// * `KR` - The key resolver type (must implement KeyResolver) 55 + pub async fn verify_record<R, RR, KR>( 56 + verify_input: AnyInput<R>, 57 + repository: &str, 58 + key_resolver: KR, 59 + record_resolver: RR, 60 + ) -> Result<(), AttestationError> 61 where 62 + R: Serialize + Clone, 63 + RR: atproto_client::record_resolver::RecordResolver, 64 + KR: KeyResolver, 65 { 66 + let record_object: Map<String, Value> = verify_input 67 + .clone() 68 + .try_into() 69 + .map_err(|_| AttestationError::RecordMustBeObject)?; 70 71 + let signatures = extract_signatures(&record_object)?; 72 73 + if signatures.is_empty() { 74 + return Ok(()); 75 } 76 77 + for signature in signatures { 78 + let signature_refernce_type = signature 79 + .get("$type") 80 + .and_then(Value::as_str) 81 + .filter(|value| !value.is_empty()) 82 + .ok_or(AttestationError::SigMetadataMissingType)?; 83 84 + let metadata = if signature_refernce_type == STRONG_REF_NSID { 85 + let aturi = signature 86 + .get("uri") 87 + .and_then(Value::as_str) 88 + .filter(|value| !value.is_empty()) 89 + .ok_or(AttestationError::SignatureMissingField { 90 + field: "uri".to_string(), 91 + })?; 92 93 + record_resolver 94 + .resolve::<serde_json::Value>(aturi) 95 + .await 96 + .map_err(|error| AttestationError::RemoteAttestationFetchFailed { 97 + uri: aturi.to_string(), 98 + error, 99 + })? 100 + } else { 101 + signature.clone() 102 + }; 103 104 + let computed_cid = create_attestation_cid( 105 + verify_input.clone(), 106 + AnyInput::Serialize(metadata.clone()), 107 + repository, 108 )?; 109 110 + if signature_refernce_type == STRONG_REF_NSID { 111 + let attestation_cid = metadata 112 + .get("cid") 113 + .and_then(Value::as_str) 114 + .filter(|value| !value.is_empty()) 115 + .ok_or(AttestationError::SignatureMissingField { 116 + field: "cid".to_string(), 117 + })?; 118 119 + if computed_cid.to_string() != attestation_cid { 120 + return Err(AttestationError::RemoteAttestationCidMismatch { 121 + expected: attestation_cid.to_string(), 122 + actual: computed_cid.to_string(), 123 + }); 124 + } 125 + continue; 126 } 127 128 + let key = metadata 129 + .get("key") 130 + .and_then(Value::as_str) 131 + .filter(|value| !value.is_empty()) 132 + .ok_or(AttestationError::SignatureMissingField { 133 + field: "key".to_string(), 134 + })?; 135 + let key_data = key_resolver.resolve(key).await.map_err(|error| { 136 + AttestationError::KeyResolutionFailed { 137 + key: key.to_string(), 138 + error, 139 } 140 + })?; 141 142 + let signature_bytes = metadata 143 + .get("signature") 144 + .and_then(Value::as_object) 145 + .and_then(|object| object.get("$bytes")) 146 + .and_then(Value::as_str) 147 + .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 148 149 + let signature_bytes = BASE64 150 + .decode(signature_bytes) 151 + .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 152 153 + let computed_cid_bytes = computed_cid.to_bytes(); 154 155 + validate(&key_data, &signature_bytes, &computed_cid_bytes) 156 + .map_err(|error| AttestationError::SignatureValidationFailed { error })?; 157 } 158 159 + Ok(()) 160 + }