+75
-34
crates/atproto-attestation/src/bin/atproto-attestation-sign.rs
+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
+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
+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
}