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