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