A library for ATProtocol identities.

refactor: atproto-attestation dx and ergonomics

+2
Cargo.lock
··· 115 115 "atproto-identity", 116 116 "atproto-record", 117 117 "base64", 118 + "chrono", 118 119 "cid", 119 120 "clap", 120 121 "elliptic-curve", ··· 2300 2301 source = "registry+https://github.com/rust-lang/crates.io-index" 2301 2302 checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 2302 2303 dependencies = [ 2304 + "indexmap", 2303 2305 "itoa", 2304 2306 "memchr", 2305 2307 "ryu",
+12 -4
README.md
··· 88 88 89 89 ```rust 90 90 use atproto_identity::key::{identify_key, to_public}; 91 - use atproto_attestation::{create_inline_attestation, verify_all_signatures, VerificationStatus}; 91 + use atproto_attestation::{ 92 + create_inline_attestation, verify_all_signatures, VerificationStatus, 93 + input::{AnyInput, PhantomSignature} 94 + }; 92 95 use serde_json::json; 93 96 94 97 #[tokio::main] ··· 96 99 let private_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 97 100 let public_key = to_public(&private_key)?; 98 101 let key_reference = format!("{}", &public_key); 102 + let repository_did = "did:plc:repo123"; 99 103 100 104 let record = json!({ 101 105 "$type": "app.bsky.feed.post", ··· 110 114 "issuedAt": "2024-01-01T00:00:00.000Z" 111 115 }); 112 116 113 - let signed_record = 114 - create_inline_attestation(&record, &sig_metadata, &private_key)?; 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 + )?; 115 123 116 - let reports = verify_all_signatures(&signed_record, None).await?; 124 + let reports = verify_all_signatures(&signed_record, repository_did, None).await?; 117 125 assert!(reports.iter().all(|report| matches!(report.status, VerificationStatus::Valid { .. }))); 118 126 119 127 Ok(())
+2 -1
crates/atproto-attestation/Cargo.toml
··· 34 34 anyhow.workspace = true 35 35 base64.workspace = true 36 36 serde.workspace = true 37 - serde_json.workspace = true 37 + serde_json = {workspace = true, features = ["preserve_order"]} 38 38 serde_ipld_dagcbor.workspace = true 39 39 sha2.workspace = true 40 40 thiserror.workspace = true ··· 52 52 53 53 [dev-dependencies] 54 54 async-trait = "0.1" 55 + chrono = { workspace = true } 55 56 tokio = { workspace = true, features = ["macros", "rt"] } 56 57 57 58 [features]
+123 -221
crates/atproto-attestation/README.md
··· 1 1 # atproto-attestation 2 2 3 - Utilities for preparing, signing, and verifying AT Protocol record attestations using the CID-first workflow. 3 + Utilities for creating and verifying AT Protocol record attestations using the CID-first workflow. 4 4 5 5 ## Overview 6 6 7 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 8 9 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 10 + 1. Automatically preparing records with `$sig` metadata containing `$type` and `repository` fields 11 11 2. Generating content identifiers (CIDs) using DAG-CBOR serialization 12 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 13 + 4. Normalizing signatures to low-S form to prevent malleability attacks 14 + 5. Embedding signatures or creating proof records with strongRef references 15 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 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 17 18 18 ## Features 19 19 ··· 21 21 - **Remote attestations**: Create separate proof records with CID-based strongRef references 22 22 - **CID-first workflow**: Deterministic signing based on content identifiers 23 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 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 28 27 29 28 ## CLI Tools 30 29 ··· 40 39 Inline attestations embed the signature bytes directly in the record: 41 40 42 41 ```rust 43 - use atproto_identity::key::{identify_key, to_public}; 44 - use atproto_attestation::create_inline_attestation; 42 + use atproto_identity::key::{generate_key, to_public, KeyType}; 43 + use atproto_attestation::{create_inline_attestation, input::{AnyInput, PhantomSignature}}; 45 44 use serde_json::json; 46 45 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...")?; 46 + fn main() -> anyhow::Result<()> { 47 + // Generate a signing key 48 + let private_key = generate_key(KeyType::K256Private)?; 51 49 let public_key = to_public(&private_key)?; 52 50 let key_reference = format!("{}", &public_key); 53 51 ··· 62 60 let repository_did = "did:plc:repo123"; 63 61 64 62 // 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 63 + // Note: repository field is automatically added during CID generation 66 64 let sig_metadata = json!({ 67 65 "$type": "com.example.inlineSignature", 68 66 "key": &key_reference, ··· 71 69 }); 72 70 73 71 // Create inline attestation (repository_did is bound into the CID) 74 - let signed_record = create_inline_attestation( 75 - &record, 76 - &sig_metadata, 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), 77 76 repository_did, 78 77 &private_key 79 78 )?; ··· 97 96 "key": "did:key:zQ3sh...", 98 97 "issuer": "did:plc:issuer123", 99 98 "issuedAt": "2024-01-01T00:00:00.000Z", 99 + "cid": "bafyrei...", 100 100 "signature": { 101 - "$bytes": "base64-encoded-signature-bytes" 101 + "$bytes": "base64-encoded-normalized-signature-bytes" 102 102 } 103 103 } 104 104 ] ··· 110 110 Remote attestations create a separate proof record that must be stored in a repository: 111 111 112 112 ```rust 113 - use atproto_attestation::{create_remote_attestation, create_remote_attestation_reference}; 113 + use atproto_attestation::{create_remote_attestation, input::{AnyInput, PhantomSignature}}; 114 114 use serde_json::json; 115 115 116 - let record = json!({ 117 - "$type": "app.bsky.feed.post", 118 - "text": "Hello world!" 119 - }); 116 + fn main() -> anyhow::Result<()> { 117 + let record = json!({ 118 + "$type": "app.bsky.feed.post", 119 + "text": "Hello world!" 120 + }); 120 121 121 - // Repository housing the original record (for replay attack prevention) 122 - let repository_did = "did:plc:repo123"; 122 + // Repository housing the original record (for replay attack prevention) 123 + let repository_did = "did:plc:repo123"; 123 124 124 - // DID of the entity creating the attestation (will store the proof record) 125 - let attestor_did = "did:plc:attestor456"; 125 + // DID of the entity creating the attestation (will store the proof record) 126 + let attestor_did = "did:plc:attestor456"; 126 127 127 - let metadata = json!({ 128 - "$type": "com.example.attestation", 129 - "issuer": "did:plc:issuer123", 130 - "purpose": "verification" 131 - }); 128 + let metadata = json!({ 129 + "$type": "com.example.attestation", 130 + "issuer": "did:plc:issuer123", 131 + "purpose": "verification" 132 + }); 132 133 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)?; 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 + )?; 136 142 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 + // 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)?); 143 147 144 - // The proof_record should be stored in the attestor's repository 145 - // The attested_record contains the strongRef reference 148 + Ok(()) 149 + } 146 150 ``` 147 151 148 152 ### Verifying Signatures 149 153 150 - Verify signatures embedded in records with repository validation: 154 + Verify all signatures in a record: 151 155 152 156 ```rust 153 - use atproto_attestation::{verify_all_signatures, VerificationStatus}; 157 + use atproto_attestation::{verify_record, input::AnyInput}; 158 + use atproto_identity::key::IdentityDocumentKeyResolver; 159 + use atproto_client::record_resolver::HttpRecordResolver; 154 160 155 161 #[tokio::main] 156 162 async fn main() -> anyhow::Result<()> { ··· 161 167 // CRITICAL: This must match the repository used during signing to prevent replay attacks 162 168 let repository_did = "did:plc:repo123"; 163 169 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); 170 + // Create resolvers for key and record fetching 171 + let key_resolver = /* ... */; // IdentityDocumentKeyResolver 172 + let record_resolver = HttpRecordResolver::new(/* ... */); 208 173 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, 174 + // Verify all signatures with repository validation 175 + verify_record( 176 + AnyInput::Json(signed_record), 215 177 repository_did, 216 - Some(&key_resolver) 178 + key_resolver, 179 + record_resolver 217 180 ).await?; 218 181 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?; 182 + println!("✓ All signatures verified successfully"); 278 183 279 184 Ok(()) 280 185 } 281 186 ``` 282 187 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 188 ## Command Line Usage 313 189 314 190 ### Signing Records ··· 349 225 metadata.json 350 226 351 227 # This outputs TWO JSON objects: 352 - # 1. Proof record (store this in the repository) 228 + # 1. Proof record (store this in the attestor's repository) 353 229 # 2. Source record with strongRef attestation 354 230 ``` 355 231 356 232 ### Verifying Signatures 357 233 358 - #### Verify All Signatures in a Record 359 - 360 234 ```bash 361 235 # Verify all signatures in a record from file 362 236 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 237 + ./signed_record.json \ 238 + did:plc:repo123 368 239 369 240 # Verify from stdin 370 - cat signed_record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- - 241 + cat signed_record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 242 + - \ 243 + did:plc:repo123 371 244 372 245 # Verify from inline JSON 373 246 cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 374 - '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' 247 + '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' \ 248 + did:plc:repo123 375 249 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 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 381 255 ``` 382 256 383 - #### Verify Specific Attestation Against Record 257 + ## Public API 258 + 259 + The crate exposes the following public functions: 384 260 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 261 + ### Attestation Creation 390 262 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 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 395 267 396 - # On success, outputs: 397 - # OK 398 - # CID: bafyrei... 399 - ``` 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 400 297 401 298 ## Attestation Specification 402 299 ··· 404 301 405 302 1. **Deterministic signing**: Records are serialized to DAG-CBOR with `$sig` metadata, producing consistent CIDs 406 303 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 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 410 308 411 309 ### Signature Structure 412 310 ··· 416 314 "$type": "com.example.signature", 417 315 "key": "did:key:z...", 418 316 "issuer": "did:plc:...", 317 + "cid": "bafyrei...", 419 318 "signature": { 420 - "$bytes": "base64-signature" 319 + "$bytes": "base64-normalized-signature" 421 320 } 422 321 } 423 322 ``` ··· 441 340 - `SignatureCreationFailed`: Key signing operation failed 442 341 - `SignatureValidationFailed`: Signature verification failed 443 342 - `SignatureNotNormalized`: ECDSA signature not in low-S form 343 + - `SignatureLengthInvalid`: Signature bytes have incorrect length 444 344 - `KeyResolutionFailed`: Could not resolve verification key 445 345 - `UnsupportedKeyType`: Key type not supported for signing/verification 346 + - `RemoteAttestationFetchFailed`: Failed to fetch remote proof record 446 347 447 348 ## Security Considerations 448 349 ··· 461 362 All ECDSA signatures are automatically normalized to low-S form to prevent signature malleability attacks: 462 363 463 364 - The library enforces low-S normalization during signature creation 464 - - Verification rejects non-normalized signatures 365 + - Verification accepts only normalized signatures 465 366 - This prevents attackers from creating alternate valid signatures for the same content 466 367 467 368 ### Key Management Best Practices ··· 481 382 482 383 When creating attestations: 483 384 484 - - The `$type` field is always required in `$sig` metadata to scope the attestation 385 + - The `$type` field is always required in metadata to scope the attestation 485 386 - The `repository` field is automatically added and must not be manually set 486 387 - Custom metadata fields are preserved and included in CID calculation 388 + - The `cid` field is automatically added to inline attestation metadata 487 389 488 390 ### Remote Attestation Considerations 489 391
+419 -307
crates/atproto-attestation/src/attestation.rs
··· 1 1 //! Core attestation creation functions. 2 2 //! 3 - //! This module provides functions for creating inline and remote attestations, 4 - //! preparing records for signing, and attaching attestation references. 3 + //! This module provides functions for creating inline and remote attestations 4 + //! and attaching attestation references. 5 5 6 - use crate::cid::{create_cid, create_plain_cid}; 6 + use crate::cid::{create_attestation_cid, create_dagbor_cid}; 7 7 use crate::errors::AttestationError; 8 + pub use crate::input::AnyInput; 8 9 use crate::signature::normalize_signature; 9 - use crate::utils::{extract_signatures_vec, BASE64, STRONG_REF_TYPE}; 10 - use atproto_identity::key::{KeyData, sign}; 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; 11 13 use atproto_record::tid::Tid; 12 14 use base64::Engine; 13 - use serde_json::{json, Value}; 15 + use serde::Serialize; 16 + use serde_json::{Value, json, Map}; 17 + use std::convert::TryInto; 14 18 15 - /// Prepare a record for signing by removing attestation artifacts and adding `$sig`. 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 16 51 /// 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. 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. 21 54 /// 22 55 /// # Arguments 23 56 /// 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 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 27 61 /// 28 62 /// # Returns 29 63 /// 30 - /// The prepared record with `$sig` metadata 64 + /// A tuple containing: 65 + /// * `(attested_record, proof_record)` - Both records needed for remote attestation 31 66 /// 32 67 /// # Errors 33 68 /// 34 69 /// 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)?; 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)?; 46 105 47 - let mut sig_metadata = attestation 48 - .as_object() 49 - .cloned() 50 - .ok_or(AttestationError::MetadataMustBeObject)?; 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(); 51 122 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 - } 123 + metadata_obj.insert("cid".to_string(), Value::String(content_cid.to_string())); 124 + (serde_json::Value::Object(metadata_obj), remote_type) 125 + }; 59 126 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())); 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)?; 62 130 63 - sig_metadata.remove("signature"); 64 - sig_metadata.remove("cid"); 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 + }); 65 136 66 - prepared.remove("signatures"); 67 - prepared.remove("sigs"); 68 - prepared.remove("$sig"); 69 - prepared.insert("$sig".to_string(), Value::Object(sig_metadata)); 137 + // Step 4: Append the attestation reference to the record "signatures" array 138 + let attested_record = append_signature_to_record(record_obj, attestation_reference)?; 70 139 71 - Ok(Value::Object(prepared)) 140 + Ok((attested_record, remote_attestation_record)) 72 141 } 73 142 74 - /// Creates an inline attestation by signing the prepared record with the provided key. 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. 75 147 /// 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. 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. 78 150 /// 79 151 /// # Arguments 80 152 /// 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 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 85 157 /// 86 158 /// # Returns 87 159 /// 88 - /// The signed record with an inline attestation in the `signatures` array 160 + /// The record with an inline attestation embedded in the signatures array 89 161 /// 90 162 /// # Errors 91 163 /// 92 164 /// Returns an error if: 93 - /// - Record preparation fails 165 + /// - The record or metadata are not valid JSON objects 166 + /// - The metadata is missing required fields 167 + /// - Signature creation fails 94 168 /// - 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, 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, 101 204 ) -> Result<Value, AttestationError> { 102 - let signing_record = prepare_signing_record(record, attestation_metadata, repository_did)?; 103 - let cid = create_cid(&signing_record)?; 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())); 104 220 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())?; 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())?; 108 224 109 - let mut inline_object = attestation_metadata 110 - .as_object() 111 - .cloned() 112 - .ok_or(AttestationError::MetadataMustBeObject)?; 225 + metadata_obj.insert( 226 + "signature".to_string(), 227 + json!({"$bytes": BASE64.encode(signature_bytes)}), 228 + ); 113 229 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 - ); 230 + serde_json::Value::Object(metadata_obj) 231 + }; 121 232 122 - create_inline_attestation_reference(record, &Value::Object(inline_object)) 233 + // Step 4: Append the attestation reference to the record "signatures" array 234 + append_signature_to_record(record_obj, inline_attestation_record) 123 235 } 124 236 125 - /// Creates a remote attestation by generating a proof record and strongRef entry. 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. 126 244 /// 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. 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 129 259 /// 130 260 /// # Arguments 131 261 /// 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 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") 135 266 /// 136 267 /// # Returns 137 268 /// 138 - /// The remote proof record for storage in a repository 269 + /// The modified record with the strongRef appended to its `signatures` array 139 270 /// 140 271 /// # Errors 141 272 /// 142 273 /// 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. 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 168 278 /// 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 279 + /// # Type Parameters 172 280 /// 173 - /// # Arguments 281 + /// * `R` - The record type (must implement Serialize + LexiconType + PartialEq + Clone) 282 + /// * `A` - The attestation type (must implement Serialize + LexiconType + PartialEq + Clone) 174 283 /// 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 284 + /// # Example 178 285 /// 179 - /// # Returns 286 + /// ```ignore 287 + /// use atproto_attestation::{append_remote_attestation, input::AnyInput}; 288 + /// use serde_json::json; 180 289 /// 181 - /// The record with a strongRef attestation in the `signatures` array 290 + /// let record = json!({ 291 + /// "$type": "app.bsky.feed.post", 292 + /// "text": "Hello world!" 293 + /// }); 182 294 /// 183 - /// # Errors 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 + /// }); 184 302 /// 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)?; 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)?; 197 326 198 - let attestation = attestation 199 - .as_object() 200 - .cloned() 201 - .ok_or(AttestationError::MetadataMustBeObject)?; 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)?; 202 331 203 - let remote_object_type = attestation 204 - .get("$type") 332 + let claimed_cid = metadata_obj 333 + .get("cid") 205 334 .and_then(Value::as_str) 206 335 .filter(|value| !value.is_empty()) 207 - .ok_or(AttestationError::RemoteAttestationMissingCid)?; 336 + .ok_or(AttestationError::SignatureMissingField { 337 + field: "cid".to_string(), 338 + })?; 208 339 209 - let tid = Tid::new(); 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 + } 210 347 211 - let attestation_cid = create_plain_cid(&serde_json::Value::Object(attestation.clone()))?; 348 + // Step 4: Compute the proof record's DAG-CBOR CID 349 + let proof_record_cid = create_dagbor_cid(&metadata_obj)?; 212 350 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() 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() 217 356 }); 218 357 219 - let mut signatures = extract_signatures_vec(&mut result)?; 220 - signatures.push(remote_object); 221 - result.insert("signatures".to_string(), Value::Array(signatures)); 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)?; 222 362 223 - Ok(Value::Object(result)) 363 + append_signature_to_record(record_obj, strongref) 224 364 } 225 365 226 - /// Attach an inline attestation entry containing signature bytes. 366 + /// Validates an inline attestation and appends it to a record's signatures array. 227 367 /// 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 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 232 373 /// 233 - /// Additional custom fields are preserved for `$sig` metadata. 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 234 378 /// 235 379 /// # Arguments 236 380 /// 237 - /// * `record` - The record to add the attestation to 238 - /// * `attestation` - The inline attestation object with signature 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 239 385 /// 240 386 /// # Returns 241 387 /// 242 - /// The record with an inline attestation in the `signatures` array 388 + /// The modified record with the validated attestation appended to its `signatures` array 243 389 /// 244 390 /// # Errors 245 391 /// 246 392 /// 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)?; 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)?; 258 448 259 - let inline_object = attestation 260 - .as_object() 261 - .cloned() 262 - .ok_or(AttestationError::MetadataMustBeObject)?; 449 + let record_obj: Map<String, Value> = record_input 450 + .try_into() 451 + .map_err(|_| AttestationError::RecordMustBeObject)?; 263 452 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 - })?; 453 + let attestation_obj: Map<String, Value> = attestation_input 454 + .try_into() 455 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 270 456 271 - if signature_type == STRONG_REF_TYPE { 272 - return Err(AttestationError::InlineAttestationTypeInvalid); 273 - } 274 - 275 - inline_object 457 + let key = attestation_obj 276 458 .get("key") 277 459 .and_then(Value::as_str) 278 460 .filter(|value| !value.is_empty()) 279 - .ok_or_else(|| AttestationError::SignatureMissingField { 461 + .ok_or(AttestationError::SignatureMissingField { 280 462 field: "key".to_string(), 281 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 + })?; 282 472 283 - let signature_bytes = inline_object 473 + let signature_bytes = attestation_obj 284 474 .get("signature") 285 475 .and_then(Value::as_object) 286 476 .and_then(|object| object.get("$bytes")) 287 477 .and_then(Value::as_str) 288 - .filter(|value| !value.is_empty()) 289 478 .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 290 479 291 - // Ensure the signature bytes decode cleanly to catch malformed input early. 292 - let _ = BASE64 480 + let signature_bytes = BASE64 293 481 .decode(signature_bytes) 294 482 .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 295 483 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"); 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 })?; 300 488 301 - Ok(Value::Object(result)) 489 + // Step 6: Append the validated attestation to the signatures array 490 + append_signature_to_record(record_obj, json!(attestation_obj)) 302 491 } 303 492 304 493 #[cfg(test)] ··· 308 497 use serde_json::json; 309 498 310 499 #[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 - }); 500 + fn create_remote_attestation_produces_both_records() -> Result<(), Box<dyn std::error::Error>> { 320 501 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 502 let record = json!({ 384 503 "$type": "app.example.record", 385 504 "body": "remote attestation" ··· 389 508 "$type": "com.example.attestation" 390 509 }); 391 510 392 - let proof_record = create_remote_attestation(&record, &metadata, "did:plc:test")?; 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 + )?; 393 521 394 - let proof_object = proof_record 395 - .as_object() 396 - .expect("proof should be an object"); 522 + // Verify proof record structure 523 + let proof_object = proof_record.as_object().expect("proof should be an object"); 397 524 assert_eq!( 398 525 proof_object.get("$type").and_then(Value::as_str), 399 526 Some("com.example.attestation") ··· 407 534 "repository should not be in final proof record" 408 535 ); 409 536 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"; 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"); 426 546 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 547 + let signature = &signatures[0]; 433 548 assert_eq!( 434 - sig_obj.get("repository").and_then(Value::as_str), 435 - Some(repository_did) 549 + signature.get("$type").and_then(Value::as_str), 550 + Some("com.atproto.repo.strongRef"), 551 + "signature should be a strongRef" 436 552 ); 437 - 438 - // Verify $type is preserved 439 - assert_eq!( 440 - sig_obj.get("$type").and_then(Value::as_str), 441 - Some("com.example.attestationType") 553 + assert!( 554 + signature.get("uri").and_then(Value::as_str).is_some(), 555 + "strongRef must contain a uri" 442 556 ); 443 - 444 - // Verify original metadata fields are preserved 445 - assert_eq!( 446 - sig_obj.get("purpose").and_then(Value::as_str), 447 - Some("test") 557 + assert!( 558 + signature.get("cid").and_then(Value::as_str).is_some(), 559 + "strongRef must contain a cid" 448 560 ); 449 561 450 562 Ok(()) ··· 469 581 }); 470 582 471 583 let signed = create_inline_attestation( 472 - &base_record, 473 - &sig_metadata, 584 + AnyInput::Serialize(base_record), 585 + AnyInput::Serialize(sig_metadata), 474 586 repository_did, 475 587 &private_key, 476 588 )?; ··· 489 601 ); 490 602 assert!(sig.get("signature").is_some()); 491 603 assert!(sig.get("key").is_some()); 492 - assert!(sig.get("repository").is_none()); // Should not be in final signature 604 + assert!(sig.get("repository").is_none()); // Should not be in final signature 493 605 494 606 Ok(()) 495 607 } 496 - } 608 + }
+32 -17
crates/atproto-attestation/src/bin/atproto-attestation-sign.rs
··· 52 52 53 53 use anyhow::{Context, Result, anyhow}; 54 54 use atproto_attestation::{ 55 - create_inline_attestation, create_remote_attestation, create_remote_attestation_reference, 55 + create_inline_attestation, create_remote_attestation, 56 + input::AnyInput, 56 57 }; 57 58 use atproto_identity::key::identify_key; 58 59 use clap::{Parser, Subcommand}; ··· 181 182 source_record, 182 183 attestation_repository_did, 183 184 metadata_record, 184 - } => { 185 - handle_remote_attestation(&source_record, &source_repository_did, &metadata_record, &attestation_repository_did)? 186 - } 185 + } => handle_remote_attestation( 186 + &source_record, 187 + &source_repository_did, 188 + &metadata_record, 189 + &attestation_repository_did, 190 + )?, 187 191 188 192 Commands::Inline { 189 193 source_record, 190 194 repository_did, 191 195 signing_key, 192 196 metadata_record, 193 - } => handle_inline_attestation(&source_record, &repository_did, &signing_key, &metadata_record)?, 197 + } => handle_inline_attestation( 198 + &source_record, 199 + &repository_did, 200 + &signing_key, 201 + &metadata_record, 202 + )?, 194 203 } 195 204 196 205 Ok(()) ··· 237 246 )); 238 247 } 239 248 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")?; 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")?; 248 259 249 260 // Output both records 250 261 println!("=== Proof Record (store in repository) ==="); ··· 291 302 let key_data = identify_key(signing_key) 292 303 .with_context(|| format!("Failed to parse signing key: {}", signing_key))?; 293 304 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")?; 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")?; 298 313 299 314 // Output the signed record 300 315 println!("{}", serde_json::to_string_pretty(&signed_record)?);
+19 -135
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
··· 46 46 //! ``` 47 47 48 48 use anyhow::{Context, Result, anyhow}; 49 - use atproto_attestation::VerificationStatus; 49 + use atproto_attestation::AnyInput; 50 + use atproto_identity::key::{KeyData, KeyResolver}; 50 51 use clap::Parser; 51 52 use serde_json::Value; 52 53 use std::{ ··· 73 74 74 75 USAGE: 75 76 atproto-attestation-verify <record> <repository_did> Verify all signatures 76 - atproto-attestation-verify <record> <repository_did> <attestation> Verify specific attestation 77 77 78 78 PARAMETER FORMATS: 79 79 Each parameter accepts JSON strings, file paths, or AT-URIs: ··· 115 115 attestation: Option<String>, 116 116 } 117 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 + 118 127 #[tokio::main] 119 128 async fn main() -> Result<()> { 120 129 let args = Args::parse(); ··· 137 146 } 138 147 139 148 // 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 - } 149 + verify_all_mode(&record, &args.repository_did).await 158 150 } 159 151 160 152 /// Mode 1: Verify all signatures contained in the record. ··· 183 175 identity_resolver, 184 176 }; 185 177 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"))?; 178 + let key_resolver = FakeKeyResolver {}; 265 179 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), 180 + atproto_attestation::verify_record( 181 + AnyInput::Serialize(record.clone()), 274 182 repository_did, 183 + key_resolver, 184 + record_resolver, 275 185 ) 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(()) 186 + .await 187 + .context("Failed to verify signatures") 295 188 } 296 189 297 190 /// Load input from various sources: JSON string, file path, AT-URI, or stdin. ··· 395 288 atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 396 289 Err(anyhow!("Failed to fetch record: {}", error.error_message())) 397 290 } 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 291 } 408 292 } 409 293
+477 -79
crates/atproto-attestation/src/cid.rs
··· 3 3 //! This module implements the CID-first attestation workflow, generating 4 4 //! deterministic content identifiers using DAG-CBOR serialization and SHA-256 hashing. 5 5 6 - use crate::errors::AttestationError; 6 + use crate::{errors::AttestationError, input::AnyInput}; 7 + #[cfg(test)] 8 + use atproto_record::typed::LexiconType; 7 9 use cid::Cid; 8 10 use multihash::Multihash; 9 - use serde_json::Value; 11 + use serde::Serialize; 12 + use serde_json::{Value, Map}; 10 13 use sha2::{Digest, Sha256}; 14 + use std::convert::TryInto; 11 15 12 - /// Create a deterministic CID for a record prepared with `prepare_signing_record`. 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. 13 29 /// 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. 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) 18 34 /// 19 35 /// # Arguments 20 36 /// 21 - /// * `record` - The prepared record containing a `$sig` metadata object 37 + /// * `record` - The data to generate a CID for (must implement `Serialize`) 22 38 /// 23 39 /// # Returns 24 40 /// 25 - /// The generated CID for the record 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 26 46 /// 27 47 /// # Errors 28 48 /// 29 49 /// 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)?; 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 })?; 38 71 39 - let sig_value = record_object 40 - .get("$sig") 41 - .ok_or(AttestationError::SigMetadataMissing)?; 72 + Ok(Cid::new_v1(DAG_CBOR_CODEC, multihash)) 73 + } 42 74 43 - let sig_object = sig_value 44 - .as_object() 45 - .ok_or(AttestationError::SigMetadataNotObject)?; 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)?; 46 109 47 - if sig_object 110 + if record_obj 48 111 .get("$type") 49 112 .and_then(Value::as_str) 50 - .filter(|value| !value.is_empty()).is_none() 113 + .filter(|value| !value.is_empty()) 114 + .is_none() 51 115 { 52 - return Err(AttestationError::SigMetadataMissingType); 116 + return Err(AttestationError::RecordMissingType); 53 117 } 54 118 55 - if sig_object 56 - .get("repository") 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") 57 125 .and_then(Value::as_str) 58 - .filter(|value| !value.is_empty()).is_none() 126 + .filter(|value| !value.is_empty()) 127 + .is_none() 59 128 { 60 - return Err(AttestationError::SigMetadataMissingType); 129 + return Err(AttestationError::MetadataMissingSigType); 61 130 } 62 131 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 })?; 132 + record_obj.remove("signatures"); 67 133 68 - Ok(Cid::new_v1(0x71, multihash)) 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) 69 145 } 70 146 71 - /// Create a CID for a plain record without `$sig` validation. 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) 72 157 /// 73 - /// This is used internally for generating CIDs of attestation records themselves. 158 + /// These requirements ensure consistency and security across the AT Protocol 159 + /// ecosystem, particularly for content addressing and attestation verification. 74 160 /// 75 161 /// # Arguments 76 162 /// 77 - /// * `record` - The record to generate a CID for 163 + /// * `cid` - A string slice containing the CID to validate 78 164 /// 79 165 /// # Returns 80 166 /// 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 })?; 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 + } 87 230 88 - Ok(Cid::new_v1(0x71, multihash)) 231 + true 89 232 } 90 233 91 234 #[cfg(test)] 92 235 mod tests { 93 236 use super::*; 94 - use serde_json::json; 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 + } 95 254 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" 255 + impl LexiconType for TestRecord { 256 + fn lexicon_type() -> &'static str { 257 + "com.example.testrecord" 105 258 } 106 - }); 259 + } 107 260 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); 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"); 113 317 114 318 Ok(()) 115 319 } 116 320 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" 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" 123 349 } 124 - }); 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 + )?; 125 382 126 - let result = create_cid(&record); 127 - assert!(matches!(result, Err(AttestationError::SigMetadataMissingType))); 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(()) 128 452 } 129 453 130 454 #[test] 131 - fn create_cid_requires_repository() { 132 - let record = json!({ 133 - "$type": "app.example.record", 134 - "$sig": { 135 - "$type": "com.example.sig" 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" 136 506 } 137 - }); 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); 138 518 139 - let result = create_cid(&record); 140 - assert!(matches!(result, Err(AttestationError::SigMetadataMissingType))); 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(()) 141 539 } 142 - } 540 + }
+8
crates/atproto-attestation/src/errors.rs
··· 12 12 #[error("error-atproto-attestation-1 Record must be a JSON object")] 13 13 RecordMustBeObject, 14 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 + 15 19 /// Error when attestation metadata is not a JSON object. 16 20 #[error("error-atproto-attestation-2 Attestation metadata must be a JSON object")] 17 21 MetadataMustBeObject, ··· 92 96 /// Error when `$sig` metadata omits the `$type` discriminator. 93 97 #[error("error-atproto-attestation-16 `$sig` metadata must include a string `$type` field")] 94 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, 95 103 96 104 /// Error when a key resolver is required but not provided. 97 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 1 //! AT Protocol record attestation utilities based on the CID-first specification. 2 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`. 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`. 7 6 //! 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`]. 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 14 32 //! 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`]. 33 + //! Use `create_remote_attestation` to generate both the proof record and the 34 + //! attested record with strongRef in a single call. 18 35 19 36 #![forbid(unsafe_code)] 20 37 #![warn(missing_docs)] 21 38 22 39 // Public modules 40 + pub mod cid; 23 41 pub mod errors; 42 + pub mod input; 24 43 25 44 // Internal modules 26 45 mod attestation; 27 - mod cid; 28 46 mod signature; 29 - mod types; 30 47 mod utils; 31 48 mod verification; 32 49 33 50 // Re-export error type 34 51 pub use errors::AttestationError; 35 52 36 - // Re-export types 37 - pub use types::{AttestationKind, VerificationReport, VerificationStatus}; 38 - 39 - // Re-export CID generation 40 - pub use cid::create_cid; 53 + // Re-export CID generation functions 54 + pub use cid::{create_dagbor_cid}; 41 55 42 56 // Re-export signature normalization 43 57 pub use signature::normalize_signature; 44 58 45 59 // Re-export attestation functions 46 60 pub use attestation::{ 47 - create_inline_attestation, 48 - create_inline_attestation_reference, 61 + append_inline_attestation, append_remote_attestation, create_inline_attestation, 49 62 create_remote_attestation, 50 - create_remote_attestation_reference, 51 - prepare_signing_record, 52 63 }; 53 64 65 + // Re-export input types 66 + pub use input::{AnyInput, AnyInputError}; 67 + 54 68 // 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 - }; 69 + pub use verification::verify_record; 61 70 62 71 /// Resolver trait for retrieving remote attestation records by AT URI. 63 72 /// 64 73 /// This trait is re-exported from atproto_client for convenience. 65 - pub use atproto_client::record_resolver::RecordResolver; 74 + pub use atproto_client::record_resolver::RecordResolver;
+4 -123
crates/atproto-attestation/src/signature.rs
··· 1 - //! ECDSA signature normalization and validation. 1 + //! ECDSA signature normalization. 2 2 //! 3 3 //! This module handles signature normalization to the low-S form required by 4 4 //! the AT Protocol attestation specification, preventing signature malleability attacks. 5 5 6 6 use crate::errors::AttestationError; 7 - use atproto_identity::key::{KeyData, KeyType}; 8 - use elliptic_curve::scalar::IsHigh; 7 + use atproto_identity::key::KeyType; 9 8 use k256::ecdsa::Signature as K256Signature; 10 9 use p256::ecdsa::Signature as P256Signature; 11 10 ··· 36 35 KeyType::P256Private | KeyType::P256Public => normalize_p256(signature), 37 36 KeyType::K256Private | KeyType::K256Public => normalize_k256(signature), 38 37 other => Err(AttestationError::UnsupportedKeyType { 39 - key_type: other.clone(), 38 + key_type: (*other).clone(), 40 39 }), 41 40 } 42 41 } 43 42 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 43 /// Normalize a P-256 signature to low-S form. 110 44 fn normalize_p256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> { 111 45 if signature.len() != 64 { ··· 151 85 #[cfg(test)] 152 86 mod tests { 153 87 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 88 190 89 #[test] 191 90 fn reject_invalid_signature_length() { ··· 196 95 Err(AttestationError::SignatureLengthInvalid { expected: 64, .. }) 197 96 )); 198 97 } 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 - } 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 1 //! Utility functions and constants for attestation operations. 2 2 //! 3 3 //! This module provides common utilities used throughout the attestation framework, 4 - //! including signature array manipulation and base64 encoding/decoding. 4 + //! including base64 encoding/decoding with flexible padding support. 5 5 6 - use crate::errors::AttestationError; 7 6 use base64::{ 8 7 alphabet::STANDARD as STANDARD_ALPHABET, 9 8 engine::{ ··· 11 10 general_purpose::{GeneralPurpose, GeneralPurposeConfig}, 12 11 }, 13 12 }; 14 - use serde_json::{Map, Value}; 15 13 16 14 /// Base64 engine that accepts both padded and unpadded input for maximum compatibility 17 15 /// with various AT Protocol implementations. Uses standard encoding with padding for output, ··· 22 20 .with_encode_padding(true) 23 21 .with_decode_padding_mode(DecodePaddingMode::Indifferent), 24 22 ); 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 - }
+120 -533
crates/atproto-attestation/src/verification.rs
··· 1 1 //! Signature verification for AT Protocol attestations. 2 2 //! 3 - //! This module provides comprehensive verification functions for both inline 4 - //! and remote attestations, with support for custom key and record resolvers. 3 + //! This module provides verification functions for AT Protocol record attestations. 5 4 6 - use crate::attestation::prepare_signing_record; 7 - use crate::cid::{create_cid, create_plain_cid}; 5 + use crate::cid::create_attestation_cid; 8 6 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}; 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; 13 11 use base64::Engine; 14 - use cid::Cid; 15 - use serde_json::{Map, Value}; 12 + use serde::Serialize; 13 + use serde_json::{Value, Map}; 14 + use std::convert::TryInto; 16 15 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 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 + } 46 25 } 47 26 48 - /// Verify a single attestation entry with repository binding and optional record resolver. 27 + /// Verify all signatures in a record with flexible input types. 49 28 /// 50 - /// Validates that the attestation was created for the specified repository DID 51 - /// to prevent replay attacks across different repositories. 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. 52 31 /// 53 32 /// # Arguments 54 33 /// 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 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 60 38 /// 61 39 /// # Returns 62 40 /// 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. 41 + /// Returns `Ok(())` if all signatures are valid, or an error if any verification fails. 133 42 /// 134 - /// Validates that attestations were created for the specified repository DID 135 - /// to prevent replay attacks. 43 + /// # Errors 136 44 /// 137 - /// # Arguments 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 138 49 /// 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 50 + /// # Type Parameters 142 51 /// 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> 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> 181 61 where 182 - R: atproto_client::record_resolver::RecordResolver, 62 + R: Serialize + Clone, 63 + RR: atproto_client::record_resolver::RecordResolver, 64 + KR: KeyResolver, 183 65 { 184 - let signatures_array = extract_signatures_array(record)?; 185 - let mut reports = Vec::with_capacity(signatures_array.len()); 66 + let record_object: Map<String, Value> = verify_input 67 + .clone() 68 + .try_into() 69 + .map_err(|_| AttestationError::RecordMustBeObject)?; 186 70 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 - } 71 + let signatures = extract_signatures(&record_object)?; 198 72 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 - }); 73 + if signatures.is_empty() { 74 + return Ok(()); 242 75 } 243 76 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 - })?; 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)?; 251 83 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"); 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 + })?; 266 92 267 - let signing_record = prepare_signing_record(record, &Value::Object(proof_metadata), repository_did)?; 268 - let computed_cid = create_cid(&signing_record)?; 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 + }; 269 103 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, 104 + let computed_cid = create_attestation_cid( 105 + verify_input.clone(), 106 + AnyInput::Serialize(metadata.clone()), 107 + repository, 397 108 )?; 398 109 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 - }); 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 + })?; 443 118 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), 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; 457 126 } 458 127 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")); 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, 481 139 } 482 - other => panic!("expected unverified status, got {:?}", other), 483 - } 140 + })?; 484 141 485 - Ok(()) 486 - } 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)?; 487 148 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"; 149 + let signature_bytes = BASE64 150 + .decode(signature_bytes) 151 + .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 494 152 495 - let base_record = json!({ 496 - "$type": "app.example.record", 497 - "body": "original" 498 - }); 153 + let computed_cid_bytes = computed_cid.to_bytes(); 499 154 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(()) 155 + validate(&key_data, &signature_bytes, &computed_cid_bytes) 156 + .map_err(|error| AttestationError::SignatureValidationFailed { error })?; 522 157 } 523 158 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 - } 159 + Ok(()) 160 + }