A library for ATProtocol identities.

feature: updating attestation to use repository in sig per spec

Changed files
+309 -104
crates
+75 -34
crates/atproto-attestation/src/bin/atproto-attestation-sign.rs
··· 8 8 //! 9 9 //! ### Remote Attestation 10 10 //! ```bash 11 - //! atproto-attestation-sign remote <source_record> <repository_did> <metadata_record> 11 + //! atproto-attestation-sign remote <source_repository_did> <source_record> <attestation_repository_did> <metadata_record> 12 12 //! ``` 13 13 //! 14 14 //! ### Inline Attestation 15 15 //! ```bash 16 - //! atproto-attestation-sign inline <source_record> <signing_key> <metadata_record> 16 + //! atproto-attestation-sign inline <source_record> <repository_did> <signing_key> <metadata_record> 17 17 //! ``` 18 18 //! 19 19 //! ## Arguments 20 20 //! 21 + //! - `source_repository_did`: (Remote mode) DID of the repository housing the source record (prevents replay attacks) 21 22 //! - `source_record`: JSON string or path to JSON file containing the record being attested 22 - //! - `repository_did`: (Remote mode) DID of the repository that will contain the remote attestation record 23 + //! - `attestation_repository_did`: (Remote mode) DID of the repository where the attestation proof will be stored 24 + //! - `repository_did`: (Inline mode) DID of the repository that will house the record (prevents replay attacks) 23 25 //! - `signing_key`: (Inline mode) Private key string (did:key format) used to sign the attestation 24 26 //! - `metadata_record`: JSON string or path to JSON file with attestation metadata used during CID creation 25 27 //! ··· 28 30 //! ```bash 29 31 //! # Remote attestation - creates proof record and strongRef 30 32 //! atproto-attestation-sign remote \ 33 + //! did:plc:sourceRepo... \ 31 34 //! record.json \ 32 - //! did:plc:xyz123... \ 35 + //! did:plc:attestationRepo... \ 33 36 //! metadata.json 34 37 //! 35 38 //! # Inline attestation - embeds signature in record 36 39 //! atproto-attestation-sign inline \ 37 40 //! record.json \ 41 + //! did:plc:xyz123... \ 38 42 //! did:key:z42tv1pb3... \ 39 43 //! '{"$type":"com.example.attestation","purpose":"demo"}' 40 44 //! 41 45 //! # Read from stdin 42 - //! cat record.json | atproto-attestation-sign inline \ 46 + //! cat record.json | atproto-attestation-sign remote \ 47 + //! did:plc:sourceRepo... \ 43 48 //! - \ 44 - //! did:key:z42tv1pb3... \ 49 + //! did:plc:attestationRepo... \ 45 50 //! metadata.json 46 51 //! ``` 47 52 ··· 75 80 76 81 MODES: 77 82 remote Creates a separate proof record with strongRef reference 78 - Syntax: remote <source_record> <repository_did> <metadata_record> 83 + Syntax: remote <source_repository_did> <source_record> <attestation_repository_did> <metadata_record> 79 84 80 85 inline Embeds signature bytes directly in the record 81 - Syntax: inline <source_record> <signing_key> <metadata_record> 86 + Syntax: inline <source_record> <repository_did> <signing_key> <metadata_record> 82 87 83 88 ARGUMENTS: 84 - source_record JSON string or file path to the record being attested 85 - repository_did (Remote) DID of repository containing the attestation record 86 - signing_key (Inline) Private key in did:key format for signing 87 - metadata_record JSON string or file path with attestation metadata 89 + source_repository_did (Remote) DID of repository housing the source record (for replay prevention) 90 + source_record JSON string or file path to the record being attested 91 + attestation_repository_did (Remote) DID of repository where attestation proof will be stored 92 + repository_did (Inline) DID of repository that will house the record (for replay prevention) 93 + signing_key (Inline) Private key in did:key format for signing 94 + metadata_record JSON string or file path with attestation metadata 88 95 89 96 EXAMPLES: 90 97 # Remote attestation (creates proof record + strongRef): 91 98 atproto-attestation-sign remote \\ 99 + did:plc:sourceRepo... \\ 92 100 record.json \\ 93 - did:plc:xyz123abc... \\ 101 + did:plc:attestationRepo... \\ 94 102 metadata.json 95 103 96 104 # Inline attestation (embeds signature): 97 105 atproto-attestation-sign inline \\ 98 106 record.json \\ 107 + did:plc:xyz123abc... \\ 99 108 did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\ 100 109 '{\"$type\":\"com.example.attestation\",\"purpose\":\"demo\"}' 101 110 102 111 # Read source record from stdin: 103 - cat record.json | atproto-attestation-sign inline \\ 112 + cat record.json | atproto-attestation-sign remote \\ 113 + did:plc:sourceRepo... \\ 104 114 - \\ 105 - did:key:z42tv1pb3... \\ 115 + did:plc:attestationRepo... \\ 106 116 metadata.json 107 117 108 118 OUTPUT: ··· 124 134 /// Create a remote attestation with separate proof record 125 135 /// 126 136 /// Generates a proof record containing the CID and returns both the proof 127 - /// record (to be stored in the repository) and the source record with a 128 - /// strongRef attestation reference. 137 + /// record (to be stored in the attestation repository) and the source record 138 + /// with a strongRef attestation reference. 129 139 #[command(visible_alias = "r")] 130 140 Remote { 141 + /// DID of the repository housing the source record (for replay attack prevention) 142 + source_repository_did: String, 143 + 131 144 /// Source record JSON string or file path (use '-' for stdin) 132 145 source_record: String, 133 146 134 - /// Repository DID that will contain the remote attestation record 135 - repository_did: String, 147 + /// DID of the repository where the attestation proof will be stored 148 + attestation_repository_did: String, 136 149 137 150 /// Attestation metadata JSON string or file path 138 151 metadata_record: String, ··· 147 160 /// Source record JSON string or file path (use '-' for stdin) 148 161 source_record: String, 149 162 163 + /// Repository DID that will house the record (for replay attack prevention) 164 + repository_did: String, 165 + 150 166 /// Private signing key in did:key format (e.g., did:key:z...) 151 167 signing_key: String, 152 168 ··· 161 177 162 178 match args.command { 163 179 Commands::Remote { 180 + source_repository_did, 164 181 source_record, 165 - repository_did, 182 + attestation_repository_did, 166 183 metadata_record, 167 - } => handle_remote_attestation(&source_record, &repository_did, &metadata_record)?, 184 + } => { 185 + handle_remote_attestation(&source_record, &source_repository_did, &metadata_record, &attestation_repository_did)? 186 + } 168 187 169 188 Commands::Inline { 170 189 source_record, 190 + repository_did, 171 191 signing_key, 172 192 metadata_record, 173 - } => handle_inline_attestation(&source_record, &signing_key, &metadata_record)?, 193 + } => handle_inline_attestation(&source_record, &repository_did, &signing_key, &metadata_record)?, 174 194 } 175 195 176 196 Ok(()) ··· 180 200 /// 181 201 /// Creates a proof record and appends a strongRef to the source record. 182 202 /// Outputs both the proof record and the updated source record. 203 + /// 204 + /// - `source_repository_did`: Used for signature binding (prevents replay attacks) 205 + /// - `attestation_repository_did`: Where the attestation proof record will be stored 183 206 fn handle_remote_attestation( 184 207 source_record: &str, 185 - repository_did: &str, 208 + source_repository_did: &str, 186 209 metadata_record: &str, 210 + attestation_repository_did: &str, 187 211 ) -> Result<()> { 188 212 // Load source record and metadata 189 213 let record_json = load_json_input(source_record)?; ··· 198 222 return Err(anyhow!("Metadata record must be a JSON object")); 199 223 } 200 224 201 - // Validate repository DID 202 - if !repository_did.starts_with("did:") { 225 + // Validate repository DIDs 226 + if !source_repository_did.starts_with("did:") { 203 227 return Err(anyhow!( 204 - "Repository DID must start with 'did:' prefix, got: {}", 205 - repository_did 228 + "Source repository DID must start with 'did:' prefix, got: {}", 229 + source_repository_did 206 230 )); 207 231 } 208 232 209 - // Create the remote attestation proof record 210 - let proof_record = create_remote_attestation(&record_json, &metadata_json) 233 + if !attestation_repository_did.starts_with("did:") { 234 + return Err(anyhow!( 235 + "Attestation repository DID must start with 'did:' prefix, got: {}", 236 + attestation_repository_did 237 + )); 238 + } 239 + 240 + // Create the remote attestation proof record with source repository binding 241 + let proof_record = create_remote_attestation(&record_json, &metadata_json, source_repository_did) 211 242 .context("Failed to create remote attestation proof record")?; 212 243 213 - // Create the source record with strongRef reference 244 + // Create the source record with strongRef reference pointing to attestation repository 214 245 let attested_record = 215 - create_remote_attestation_reference(&record_json, &proof_record, repository_did) 246 + create_remote_attestation_reference(&record_json, &proof_record, attestation_repository_did) 216 247 .context("Failed to create remote attestation reference")?; 217 248 218 249 // Output both records ··· 231 262 /// Outputs the record with inline attestation. 232 263 fn handle_inline_attestation( 233 264 source_record: &str, 265 + repository_did: &str, 234 266 signing_key: &str, 235 267 metadata_record: &str, 236 268 ) -> Result<()> { ··· 247 279 return Err(anyhow!("Metadata record must be a JSON object")); 248 280 } 249 281 282 + // Validate repository DID 283 + if !repository_did.starts_with("did:") { 284 + return Err(anyhow!( 285 + "Repository DID must start with 'did:' prefix, got: {}", 286 + repository_did 287 + )); 288 + } 289 + 250 290 // Parse the signing key 251 291 let key_data = identify_key(signing_key) 252 292 .with_context(|| format!("Failed to parse signing key: {}", signing_key))?; 253 293 254 - // Create inline attestation 255 - let signed_record = create_inline_attestation(&record_json, &metadata_json, &key_data) 256 - .context("Failed to create inline attestation")?; 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")?; 257 298 258 299 // Output the signed record 259 300 println!("{}", serde_json::to_string_pretty(&signed_record)?);
+53 -27
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
··· 8 8 //! 9 9 //! ### Verify all signatures in a record 10 10 //! ```bash 11 - //! atproto-attestation-verify <record> 11 + //! atproto-attestation-verify <record> <repository_did> 12 12 //! ``` 13 13 //! 14 14 //! ### Verify a specific attestation against a record 15 15 //! ```bash 16 - //! atproto-attestation-verify <record> <attestation> 16 + //! atproto-attestation-verify <record> <repository_did> <attestation> 17 17 //! ``` 18 18 //! 19 19 //! ## Parameter Formats ··· 27 27 //! 28 28 //! ```bash 29 29 //! # Verify all signatures in a record from file 30 - //! atproto-attestation-verify ./signed_post.json 30 + //! atproto-attestation-verify ./signed_post.json did:plc:repo123 31 31 //! 32 32 //! # Verify all signatures in a record from AT-URI 33 - //! atproto-attestation-verify at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g 33 + //! atproto-attestation-verify at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g did:plc:abc123 34 34 //! 35 35 //! # Verify specific attestation against a record (both from files) 36 - //! atproto-attestation-verify ./record.json ./attestation.json 36 + //! atproto-attestation-verify ./record.json did:plc:repo123 ./attestation.json 37 37 //! 38 38 //! # Verify specific attestation (from AT-URI) against record (from file) 39 - //! atproto-attestation-verify ./record.json at://did:plc:xyz/com.example.attestation/abc123 39 + //! atproto-attestation-verify ./record.json did:plc:repo123 at://did:plc:xyz/com.example.attestation/abc123 40 40 //! 41 41 //! # Read record from stdin, verify all signatures 42 - //! cat signed.json | atproto-attestation-verify - 42 + //! cat signed.json | atproto-attestation-verify - did:plc:repo123 43 43 //! 44 44 //! # Verify inline JSON 45 - //! atproto-attestation-verify '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' 45 + //! atproto-attestation-verify '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' did:plc:repo123 46 46 //! ``` 47 47 48 48 use anyhow::{Context, Result, anyhow}; 49 - use atproto_attestation::{VerificationStatus, verify_all_signatures_with_resolver}; 49 + use atproto_attestation::VerificationStatus; 50 50 use clap::Parser; 51 51 use serde_json::Value; 52 52 use std::{ ··· 60 60 /// Validates attestation signatures by reconstructing signed content and checking 61 61 /// ECDSA signatures against embedded public keys. Supports verifying all signatures 62 62 /// in a record or validating a specific attestation record. 63 + /// 64 + /// The repository DID parameter is now REQUIRED to prevent replay attacks where 65 + /// attestations might be copied to different repositories. 63 66 #[derive(Parser)] 64 67 #[command( 65 68 name = "atproto-attestation-verify", ··· 69 72 A command-line tool for verifying cryptographic signatures of AT Protocol records. 70 73 71 74 USAGE: 72 - atproto-attestation-verify <record> Verify all signatures in record 73 - atproto-attestation-verify <record> <attestation> Verify specific attestation 75 + atproto-attestation-verify <record> <repository_did> Verify all signatures 76 + atproto-attestation-verify <record> <repository_did> <attestation> Verify specific attestation 74 77 75 78 PARAMETER FORMATS: 76 79 Each parameter accepts JSON strings, file paths, or AT-URIs: ··· 81 84 82 85 EXAMPLES: 83 86 # Verify all signatures in a record: 84 - atproto-attestation-verify ./signed_post.json 85 - atproto-attestation-verify at://did:plc:abc/app.bsky.feed.post/123 87 + atproto-attestation-verify ./signed_post.json did:plc:repo123 88 + atproto-attestation-verify at://did:plc:abc/app.bsky.feed.post/123 did:plc:abc 86 89 87 90 # Verify specific attestation: 88 - atproto-attestation-verify ./record.json ./attestation.json 89 - atproto-attestation-verify ./record.json at://did:plc:xyz/com.example.attestation/abc 91 + atproto-attestation-verify ./record.json did:plc:repo123 ./attestation.json 92 + atproto-attestation-verify ./record.json did:plc:repo123 at://did:plc:xyz/com.example.attestation/abc 90 93 91 94 # Read from stdin: 92 - cat signed.json | atproto-attestation-verify - 95 + cat signed.json | atproto-attestation-verify - did:plc:repo123 93 96 94 97 OUTPUT: 95 98 Single record mode: Reports each signature with ✓ (valid), ✗ (invalid), or ? (unverified) ··· 105 108 /// Record to verify - JSON string, file path, AT-URI, or '-' for stdin 106 109 record: String, 107 110 111 + /// Repository DID that houses the record (required for replay attack prevention) 112 + repository_did: String, 113 + 108 114 /// Optional attestation record to verify against the record - JSON string, file path, or AT-URI 109 115 attestation: Option<String>, 110 116 } ··· 122 128 return Err(anyhow!("Record must be a JSON object")); 123 129 } 124 130 131 + // Validate repository DID 132 + if !args.repository_did.starts_with("did:") { 133 + return Err(anyhow!( 134 + "Repository DID must start with 'did:' prefix, got: {}", 135 + args.repository_did 136 + )); 137 + } 138 + 125 139 // Determine verification mode 126 140 match args.attestation { 127 141 None => { 128 142 // Mode 1: Verify all signatures in the record 129 - verify_all_mode(&record).await 143 + verify_all_mode(&record, &args.repository_did).await 130 144 } 131 145 Some(attestation_input) => { 132 146 // Mode 2: Verify specific attestation against record ··· 138 152 return Err(anyhow!("Attestation must be a JSON object")); 139 153 } 140 154 141 - verify_attestation_mode(&record, &attestation).await 155 + verify_attestation_mode(&record, &attestation, &args.repository_did).await 142 156 } 143 157 } 144 158 } ··· 149 163 /// - ✓ Valid signature 150 164 /// - ✗ Invalid signature 151 165 /// - ? Unverified (e.g., remote attestations requiring proof record fetch) 152 - async fn verify_all_mode(record: &Value) -> Result<()> { 166 + async fn verify_all_mode(record: &Value, repository_did: &str) -> Result<()> { 153 167 // Create an identity resolver for fetching remote attestations 154 168 use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 155 169 use std::sync::Arc; ··· 169 183 identity_resolver, 170 184 }; 171 185 172 - let reports = verify_all_signatures_with_resolver(record, None, Some(&record_resolver)) 173 - .await 174 - .context("Failed to verify signatures")?; 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")?; 175 194 176 195 if reports.is_empty() { 177 196 return Err(anyhow!("No signatures found in record")); ··· 228 247 /// 229 248 /// The attestation should be a standalone attestation object (e.g., from a remote proof record) 230 249 /// that will be verified against the record's content. 231 - async fn verify_attestation_mode(record: &Value, attestation: &Value) -> Result<()> { 250 + async fn verify_attestation_mode( 251 + record: &Value, 252 + attestation: &Value, 253 + repository_did: &str, 254 + ) -> Result<()> { 232 255 // The attestation should have a CID field that we can use to verify 233 256 let attestation_obj = attestation 234 257 .as_object() ··· 240 263 .and_then(Value::as_str) 241 264 .ok_or_else(|| anyhow!("Attestation must contain a 'cid' field"))?; 242 265 243 - // Prepare the signing record with the attestation metadata 266 + // Prepare the signing record with the attestation metadata and repository DID 244 267 let mut signing_metadata = attestation_obj.clone(); 245 268 signing_metadata.remove("cid"); 246 269 signing_metadata.remove("signature"); 247 270 248 - let signing_record = 249 - atproto_attestation::prepare_signing_record(record, &Value::Object(signing_metadata)) 250 - .context("Failed to prepare signing record")?; 271 + let signing_record = atproto_attestation::prepare_signing_record( 272 + record, 273 + &Value::Object(signing_metadata), 274 + repository_did, 275 + ) 276 + .context("Failed to prepare signing record")?; 251 277 252 278 // Generate the CID from the signing record 253 279 let computed_cid =
+181 -43
crates/atproto-attestation/src/lib.rs
··· 126 126 return Err(AttestationError::SigMetadataMissingType); 127 127 } 128 128 129 + if !sig_object 130 + .get("repository") 131 + .and_then(Value::as_str) 132 + .filter(|value| !value.is_empty()) 133 + .is_some() 134 + { 135 + return Err(AttestationError::SigMetadataMissingType); 136 + } 137 + 129 138 let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?; 130 139 let digest = Sha256::digest(&dag_cbor_bytes); 131 140 let multihash = Multihash::wrap(0x12, &digest) ··· 148 157 /// - Removes any existing `signatures`, `sigs`, and `$sig` fields. 149 158 /// - Inserts the provided `attestation` metadata as the new `$sig` object. 150 159 /// - Ensures the metadata contains a string `$type` discriminator. 160 + /// - Ensures the metadata contains a `repository` field with the repository DID to prevent replay attacks. 151 161 pub fn prepare_signing_record( 152 162 record: &Value, 153 163 attestation: &Value, 164 + repository_did: &str, 154 165 ) -> Result<Value, AttestationError> { 155 166 let mut prepared = record 156 167 .as_object() ··· 170 181 { 171 182 return Err(AttestationError::MetadataMissingSigType); 172 183 } 184 + 185 + // CRITICAL: Always set repository field for attestations to prevent replay attacks 186 + sig_metadata.insert("repository".to_string(), Value::String(repository_did.to_string())); 173 187 174 188 sig_metadata.remove("signature"); 175 189 sig_metadata.remove("cid"); ··· 183 197 } 184 198 185 199 /// Creates an inline attestation by signing the prepared record with the provided key. 200 + /// 201 + /// Signs the prepared record with the provided key and includes the repository DID 202 + /// in the `$sig` metadata during CID generation to bind the attestation to a specific repository. 186 203 pub fn create_inline_attestation( 187 204 record: &Value, 188 205 attestation_metadata: &Value, 206 + repository_did: &str, 189 207 signing_key: &KeyData, 190 208 ) -> Result<Value, AttestationError> { 191 - let signing_record = prepare_signing_record(record, attestation_metadata)?; 209 + let signing_record = prepare_signing_record(record, attestation_metadata, repository_did)?; 192 210 let cid = create_cid(&signing_record)?; 193 211 194 212 let raw_signature = sign(signing_key, &cid.to_bytes()) ··· 202 220 203 221 inline_object.remove("signature"); 204 222 inline_object.remove("cid"); 223 + inline_object.remove("repository"); // Don't include repository in final attestation object 205 224 inline_object.insert( 206 225 "signature".to_string(), 207 226 json!({"$bytes": BASE64.encode(signature_bytes)}), ··· 212 231 213 232 /// Creates a remote attestation by generating a proof record and strongRef entry. 214 233 /// 215 - /// Returns a tuple containing: 216 - /// - Remote proof record containing the CID for storage in a repository. 234 + /// Generates a proof record containing the CID with the repository DID included 235 + /// in the `$sig` metadata during CID generation to bind the attestation to a specific repository. 236 + /// 237 + /// Returns the remote proof record for storage in a repository. 217 238 pub fn create_remote_attestation( 218 239 record: &Value, 219 240 attestation_metadata: &Value, 241 + repository_did: &str, 220 242 ) -> Result<Value, AttestationError> { 221 243 let metadata = attestation_metadata 222 244 .as_object() ··· 224 246 .ok_or(AttestationError::MetadataMustBeObject)?; 225 247 226 248 let metadata_value = Value::Object(metadata.clone()); 227 - let signing_record = prepare_signing_record(record, &metadata_value)?; 249 + let signing_record = prepare_signing_record(record, &metadata_value, repository_did)?; 228 250 let cid = create_cid(&signing_record)?; 229 251 230 252 let mut remote_attestation = metadata.clone(); 253 + remote_attestation.remove("repository"); // Don't include repository in final proof record 231 254 remote_attestation.insert("cid".to_string(), Value::String(cid.to_string())); 232 255 233 256 Ok(Value::Object(remote_attestation)) ··· 355 378 Ok(Value::Object(result)) 356 379 } 357 380 358 - /// Verify a single attestation entry at the specified index without a record resolver. 359 - /// 360 - /// Inline signatures are reconstructed into `$sig` metadata, a CID is generated, 361 - /// and the signature bytes are validated against the resolved public key. 362 - /// Remote attestations will be reported as unverified. 381 + /// Verify a single attestation entry with repository binding. 363 382 /// 364 - /// This is a convenience function for the common case where no record resolver is needed. 365 - /// For verifying remote attestations, use [`verify_signature_with_resolver`]. 383 + /// Validates that the attestation was created for the specified repository DID 384 + /// to prevent replay attacks. 366 385 pub async fn verify_signature( 367 386 record: &Value, 368 387 index: usize, 388 + repository_did: &str, 369 389 key_resolver: Option<&dyn KeyResolver>, 370 390 ) -> Result<VerificationReport, AttestationError> { 371 391 verify_signature_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>( 372 392 record, 373 393 index, 394 + repository_did, 374 395 key_resolver, 375 396 None, 376 397 ) 377 398 .await 378 399 } 379 400 380 - /// Verify a single attestation entry at the specified index with optional record resolver. 401 + 402 + 403 + /// Verify a single attestation entry with repository binding and optional record resolver. 381 404 /// 382 - /// Inline signatures are reconstructed into `$sig` metadata, a CID is generated, 383 - /// and the signature bytes are validated against the resolved public key. 384 - /// Remote attestations can be verified if a `record_resolver` is provided to fetch 385 - /// the proof record via AT-URI. Without a record resolver, remote attestations are 386 - /// reported as unverified. 405 + /// Validates that the attestation was created for the specified repository DID 406 + /// to prevent replay attacks across different repositories. 387 407 pub async fn verify_signature_with_resolver<R>( 388 408 record: &Value, 389 409 index: usize, 410 + repository_did: &str, 390 411 key_resolver: Option<&dyn KeyResolver>, 391 412 record_resolver: Option<&R>, 392 413 ) -> Result<VerificationReport, AttestationError> ··· 424 445 AttestationKind::Remote => { 425 446 match record_resolver { 426 447 Some(resolver) => { 427 - match verify_remote_attestation(record, signature_map, resolver).await { 448 + match verify_remote_attestation(record, signature_map, repository_did, resolver).await { 428 449 Ok(cid) => VerificationStatus::Valid { cid }, 429 450 Err(error) => VerificationStatus::Invalid { error }, 430 451 } ··· 435 456 } 436 457 } 437 458 AttestationKind::Inline => { 438 - match verify_inline_attestation(record, signature_map, key_resolver).await { 459 + match verify_inline_attestation(record, signature_map, repository_did, key_resolver).await { 439 460 Ok(cid) => VerificationStatus::Valid { cid }, 440 461 Err(error) => VerificationStatus::Invalid { error }, 441 462 } ··· 451 472 }) 452 473 } 453 474 454 - /// Verify all attestation entries attached to the record without a record resolver. 455 - /// 456 - /// Returns a report per signature. Structural issues with the record (for 457 - /// example, a missing `signatures` array) are returned as an error. 475 + /// Verify all attestation entries with repository binding. 458 476 /// 459 - /// Remote attestations will be reported as unverified. For verifying remote 460 - /// attestations, use [`verify_all_signatures_with_resolver`]. 477 + /// Validates that attestations were created for the specified repository DID 478 + /// to prevent replay attacks. 461 479 pub async fn verify_all_signatures( 462 480 record: &Value, 481 + repository_did: &str, 463 482 key_resolver: Option<&dyn KeyResolver>, 464 483 ) -> Result<Vec<VerificationReport>, AttestationError> { 465 484 verify_all_signatures_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>( 466 485 record, 486 + repository_did, 467 487 key_resolver, 468 488 None, 469 489 ) 470 490 .await 471 491 } 472 492 473 - /// Verify all attestation entries attached to the record with optional record resolver. 474 - /// 475 - /// Returns a report per signature. Structural issues with the record (for 476 - /// example, a missing `signatures` array) are returned as an error. 493 + /// Verify all attestation entries with repository binding and optional record resolver. 477 494 /// 478 - /// If a `record_resolver` is provided, remote attestations will be fetched and verified. 479 - /// Otherwise, remote attestations will be reported as unverified. 495 + /// Validates that all attestations were created for the specified repository DID 496 + /// to prevent replay attacks across different repositories. 480 497 pub async fn verify_all_signatures_with_resolver<R>( 481 498 record: &Value, 499 + repository_did: &str, 482 500 key_resolver: Option<&dyn KeyResolver>, 483 501 record_resolver: Option<&R>, 484 502 ) -> Result<Vec<VerificationReport>, AttestationError> ··· 490 508 491 509 for index in 0..signatures_array.len() { 492 510 reports.push( 493 - verify_signature_with_resolver(record, index, key_resolver, record_resolver).await?, 511 + verify_signature_with_resolver( 512 + record, 513 + index, 514 + repository_did, 515 + key_resolver, 516 + record_resolver 517 + ).await?, 494 518 ); 495 519 } 496 520 ··· 500 524 async fn verify_remote_attestation<R>( 501 525 record: &Value, 502 526 signature_object: &Map<String, Value>, 527 + repository_did: &str, 503 528 record_resolver: &R, 504 529 ) -> Result<Cid, AttestationError> 505 530 where ··· 560 585 .ok_or(AttestationError::RecordMustBeObject)?; 561 586 proof_metadata.remove("cid"); 562 587 563 - let signing_record = prepare_signing_record(record, &Value::Object(proof_metadata))?; 588 + let signing_record = prepare_signing_record(record, &Value::Object(proof_metadata), repository_did)?; 564 589 let computed_cid = create_cid(&signing_record)?; 565 590 566 591 // Verify the CID matches ··· 577 602 async fn verify_inline_attestation( 578 603 record: &Value, 579 604 signature_object: &Map<String, Value>, 605 + repository_did: &str, 580 606 key_resolver: Option<&dyn KeyResolver>, 581 607 ) -> Result<Cid, AttestationError> { 582 608 let key_reference = signature_object ··· 604 630 let mut sig_metadata = signature_object.clone(); 605 631 sig_metadata.remove("signature"); 606 632 607 - let signing_record = prepare_signing_record(record, &Value::Object(sig_metadata))?; 633 + let signing_record = prepare_signing_record(record, &Value::Object(sig_metadata), repository_did)?; 608 634 let cid = create_cid(&signing_record)?; 609 635 let cid_bytes = cid.to_bytes(); 610 636 ··· 777 803 778 804 #[test] 779 805 fn prepare_signing_record_removes_signatures() -> Result<(), AttestationError> { 806 + let repository_did = "did:plc:test"; 780 807 let record = json!({ 781 808 "$type": "app.bsky.feed.post", 782 809 "text": "hello", ··· 793 820 "cid": "bafyignored" 794 821 }); 795 822 796 - let prepared = prepare_signing_record(&record, &metadata)?; 823 + let prepared = prepare_signing_record(&record, &metadata, repository_did)?; 797 824 let object = prepared.as_object().unwrap(); 798 825 assert!(object.get("signatures").is_none()); 799 826 assert!(object.get("sigs").is_none()); ··· 873 900 "$type": "com.example.inlineSignature" 874 901 }); 875 902 876 - let proof_record = create_remote_attestation(&record, &metadata)?; 903 + let proof_record = create_remote_attestation(&record, &metadata, "did:plc:test")?; 877 904 878 905 let proof_object = proof_record 879 906 .as_object() ··· 895 922 let private_key = generate_key(KeyType::K256Private)?; 896 923 let public_key = to_public(&private_key)?; 897 924 let key_reference = format!("{}", &public_key); 925 + let repository_did = "did:plc:testrepository123"; 898 926 899 927 let base_record = json!({ 900 928 "$type": "app.example.record", ··· 907 935 "purpose": "unit-test" 908 936 }); 909 937 910 - let signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?; 938 + let signed = create_inline_attestation( 939 + &base_record, 940 + &sig_metadata, 941 + repository_did, 942 + &private_key, 943 + )?; 911 944 912 - let report = verify_signature(&signed, 0, None).await?; 945 + let report = verify_signature(&signed, 0, repository_did, None).await?; 913 946 match report.status { 914 947 VerificationStatus::Valid { .. } => {} 915 948 other => panic!("expected valid signature, got {:?}", other), ··· 924 957 let public_key = to_public(&private_key)?; 925 958 let key_multibase = format!("{}", &public_key); 926 959 let key_reference = "did:plc:resolvertest#atproto".to_string(); 960 + let repository_did = "did:plc:resolvertest"; 927 961 928 962 let document = DocumentBuilder::new() 929 963 .id("did:plc:resolvertest") ··· 953 987 "scope": "resolver" 954 988 }); 955 989 956 - let signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?; 990 + let signed = create_inline_attestation( 991 + &base_record, 992 + &sig_metadata, 993 + repository_did, 994 + &private_key, 995 + )?; 957 996 958 - let report = verify_signature(&signed, 0, Some(&key_resolver)).await?; 997 + let report = 998 + verify_signature(&signed, 0, repository_did, Some(&key_resolver)) 999 + .await?; 959 1000 match report.status { 960 1001 VerificationStatus::Valid { .. } => {} 961 1002 other => panic!("expected valid signature, got {:?}", other), ··· 966 1007 967 1008 #[tokio::test] 968 1009 async fn verify_all_signatures_reports_remote() -> Result<(), Box<dyn std::error::Error>> { 1010 + let repository_did = "did:plc:example"; 969 1011 let record = json!({ 970 1012 "$type": "app.example.record", 971 1013 "signatures": [ ··· 977 1019 ] 978 1020 }); 979 1021 980 - let reports = verify_all_signatures(&record, None).await?; 1022 + let reports = verify_all_signatures(&record, repository_did, None).await?; 981 1023 assert_eq!(reports.len(), 1); 982 1024 match &reports[0].status { 983 1025 VerificationStatus::Unverified { reason } => { ··· 994 1036 let private_key = generate_key(KeyType::K256Private)?; 995 1037 let public_key = to_public(&private_key)?; 996 1038 let key_reference = format!("{}", &public_key); 1039 + let repository_did = "did:plc:tampertest"; 997 1040 998 1041 let base_record = json!({ 999 1042 "$type": "app.example.record", ··· 1005 1048 "key": key_reference 1006 1049 }); 1007 1050 1008 - let mut signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?; 1051 + let mut signed = create_inline_attestation( 1052 + &base_record, 1053 + &sig_metadata, 1054 + repository_did, 1055 + &private_key, 1056 + )?; 1009 1057 if let Some(object) = signed.as_object_mut() { 1010 1058 object.insert("body".to_string(), json!("tampered")); 1011 1059 } 1012 1060 1013 - let report = verify_signature(&signed, 0, None).await?; 1061 + let report = verify_signature(&signed, 0, repository_did, None).await?; 1014 1062 match report.status { 1015 1063 VerificationStatus::Invalid { .. } => {} 1016 1064 other => panic!("expected invalid signature, got {:?}", other), 1017 1065 } 1066 + 1067 + Ok(()) 1068 + } 1069 + 1070 + #[tokio::test] 1071 + async fn verify_repository_field_prevents_replay_attack( 1072 + ) -> Result<(), Box<dyn std::error::Error>> { 1073 + let private_key = generate_key(KeyType::K256Private)?; 1074 + let public_key = to_public(&private_key)?; 1075 + let key_reference = format!("{}", &public_key); 1076 + let original_repository = "did:plc:originalrepo"; 1077 + let attacker_repository = "did:plc:attackerrepo"; 1078 + 1079 + let base_record = json!({ 1080 + "$type": "app.example.record", 1081 + "body": "Important content" 1082 + }); 1083 + 1084 + let sig_metadata = json!({ 1085 + "$type": "com.example.inlineSignature", 1086 + "key": key_reference, 1087 + "purpose": "original-attestation" 1088 + }); 1089 + 1090 + // Create attestation for original repository 1091 + let signed = create_inline_attestation( 1092 + &base_record, 1093 + &sig_metadata, 1094 + original_repository, 1095 + &private_key, 1096 + )?; 1097 + 1098 + // Verify succeeds with correct repository 1099 + let report = 1100 + verify_signature(&signed, 0, original_repository, None).await?; 1101 + match report.status { 1102 + VerificationStatus::Valid { .. } => {} 1103 + other => panic!("expected valid signature for original repo, got {:?}", other), 1104 + } 1105 + 1106 + // Verify FAILS with different repository (simulating replay attack) 1107 + let report = 1108 + verify_signature(&signed, 0, attacker_repository, None).await?; 1109 + match report.status { 1110 + VerificationStatus::Invalid { .. } => {} 1111 + other => panic!( 1112 + "expected invalid signature for attacker repo, got {:?}", 1113 + other 1114 + ), 1115 + } 1116 + 1117 + Ok(()) 1118 + } 1119 + 1120 + #[test] 1121 + fn prepare_signing_record_enforces_repository() -> Result<(), AttestationError> { 1122 + let record = json!({ 1123 + "$type": "app.example.record", 1124 + "text": "Test content" 1125 + }); 1126 + 1127 + let metadata = json!({ 1128 + "$type": "com.example.attestationType", 1129 + "purpose": "test" 1130 + }); 1131 + 1132 + let repository_did = "did:plc:testrepo123"; 1133 + 1134 + // Prepare with repository field 1135 + let prepared = prepare_signing_record(&record, &metadata, repository_did)?; 1136 + let prepared_obj = prepared.as_object().unwrap(); 1137 + let sig_obj = prepared_obj.get("$sig").unwrap().as_object().unwrap(); 1138 + 1139 + // Verify repository field is set correctly 1140 + assert_eq!( 1141 + sig_obj.get("repository").and_then(Value::as_str), 1142 + Some(repository_did) 1143 + ); 1144 + 1145 + // Verify $type is preserved 1146 + assert_eq!( 1147 + sig_obj.get("$type").and_then(Value::as_str), 1148 + Some("com.example.attestationType") 1149 + ); 1150 + 1151 + // Verify original metadata fields are preserved 1152 + assert_eq!( 1153 + sig_obj.get("purpose").and_then(Value::as_str), 1154 + Some("test") 1155 + ); 1018 1156 1019 1157 Ok(()) 1020 1158 }