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