+16
-8
CLAUDE.md
+16
-8
CLAUDE.md
···
27
27
- **Sign data**: `cargo run --features clap --bin atproto-identity-sign -- <did_key> <json_file>`
28
28
- **Validate signatures**: `cargo run --features clap --bin atproto-identity-validate -- <did_key> <json_file> <signature>`
29
29
30
+
#### Attestation Operations
31
+
- **Sign records (inline)**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- inline <source_record> <signing_key> <metadata_record>`
32
+
- **Sign records (remote)**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- remote <source_record> <repository_did> <metadata_record>`
33
+
- **Verify records**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record>` (verifies all signatures)
34
+
- **Verify attestation**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record> <attestation>` (verifies specific attestation)
35
+
30
36
#### Record Operations
31
-
- **Sign records**: `cargo run --features clap --bin atproto-record-sign -- <issuer_did> <signing_key> <record_input> repository=<repo> collection=<collection>`
32
-
- **Verify records**: `cargo run --features clap --bin atproto-record-verify -- <issuer_did> <key> <record_input> repository=<repo> collection=<collection>`
33
37
- **Generate CID**: `cat record.json | cargo run --features clap --bin atproto-record-cid` (reads JSON from stdin, outputs CID)
34
38
35
39
#### Client Tools
···
45
49
## Architecture
46
50
47
51
A comprehensive Rust workspace with multiple crates:
48
-
- **atproto-identity**: Core identity management with 10 modules (resolve, plc, web, model, validation, config, errors, key, storage, storage_lru)
49
-
- **atproto-record**: Record signature operations and validation
52
+
- **atproto-identity**: Core identity management with 11 modules (resolve, plc, web, model, validation, config, errors, key, storage_lru, traits, url)
53
+
- **atproto-attestation**: CID-first attestation utilities for creating and verifying record signatures
54
+
- **atproto-record**: Record utilities including TID generation, AT-URI parsing, and CID generation
50
55
- **atproto-client**: HTTP client with OAuth and identity integration
51
56
- **atproto-jetstream**: WebSocket event streaming with compression
52
57
- **atproto-oauth**: OAuth workflow implementation with DPoP, PKCE, JWT, and storage abstractions
···
137
142
### Core Library Modules (atproto-identity)
138
143
- **`src/lib.rs`**: Main library exports
139
144
- **`src/resolve.rs`**: Core resolution logic for handles and DIDs, DNS/HTTP resolution
140
-
- **`src/plc.rs`**: PLC directory client for did:plc resolution
145
+
- **`src/plc.rs`**: PLC directory client for did:plc resolution
141
146
- **`src/web.rs`**: Web DID client for did:web resolution and URL conversion
142
147
- **`src/model.rs`**: Data structures for DID documents and AT Protocol entities
143
148
- **`src/validation.rs`**: Input validation for handles and DIDs
144
149
- **`src/config.rs`**: Configuration management and environment variable handling
145
150
- **`src/errors.rs`**: Structured error types following project conventions
146
151
- **`src/key.rs`**: Cryptographic key operations including signature validation and key identification for P-256, P-384, and K-256 curves
147
-
- **`src/storage.rs`**: Storage abstraction interface for DID document caching
148
152
- **`src/storage_lru.rs`**: LRU-based storage implementation (requires `lru` feature)
153
+
- **`src/traits.rs`**: Core trait definitions for identity resolution and key resolution
154
+
- **`src/url.rs`**: URL utilities for AT Protocol services
149
155
150
156
### CLI Tools (require --features clap)
151
157
···
155
161
- **`src/bin/atproto-identity-sign.rs`**: Create cryptographic signatures of JSON data
156
162
- **`src/bin/atproto-identity-validate.rs`**: Validate cryptographic signatures
157
163
164
+
#### Attestation Operations (atproto-attestation)
165
+
- **`src/bin/atproto-attestation-sign.rs`**: Sign AT Protocol records with inline or remote attestations using CID-first specification
166
+
- **`src/bin/atproto-attestation-verify.rs`**: Verify cryptographic signatures on AT Protocol records with attestation validation
167
+
158
168
#### Record Operations (atproto-record)
159
-
- **`src/bin/atproto-record-sign.rs`**: Sign AT Protocol records with cryptographic signatures
160
-
- **`src/bin/atproto-record-verify.rs`**: Verify AT Protocol record signatures
161
169
- **`src/bin/atproto-record-cid.rs`**: Generate CID (Content Identifier) for AT Protocol records using DAG-CBOR serialization
162
170
163
171
#### Client Tools (atproto-client)
+29
Cargo.lock
+29
Cargo.lock
···
106
106
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
107
107
108
108
[[package]]
109
+
name = "atproto-attestation"
110
+
version = "0.13.0"
111
+
dependencies = [
112
+
"anyhow",
113
+
"async-trait",
114
+
"atproto-client",
115
+
"atproto-identity",
116
+
"atproto-record",
117
+
"base64",
118
+
"cid",
119
+
"clap",
120
+
"elliptic-curve",
121
+
"k256",
122
+
"multihash",
123
+
"p256",
124
+
"reqwest",
125
+
"serde",
126
+
"serde_ipld_dagcbor",
127
+
"serde_json",
128
+
"sha2",
129
+
"thiserror 2.0.12",
130
+
"tokio",
131
+
]
132
+
133
+
[[package]]
109
134
name = "atproto-client"
110
135
version = "0.13.0"
111
136
dependencies = [
112
137
"anyhow",
138
+
"async-trait",
113
139
"atproto-identity",
114
140
"atproto-oauth",
115
141
"atproto-record",
···
151
177
"thiserror 2.0.12",
152
178
"tokio",
153
179
"tracing",
180
+
"url",
154
181
"urlencoding",
155
182
"zeroize",
156
183
]
···
277
304
version = "0.13.0"
278
305
dependencies = [
279
306
"anyhow",
307
+
"async-trait",
280
308
"atproto-identity",
281
309
"base64",
282
310
"chrono",
283
311
"cid",
284
312
"clap",
285
313
"multihash",
314
+
"rand 0.8.5",
286
315
"serde",
287
316
"serde_ipld_dagcbor",
288
317
"serde_json",
+4
-1
Cargo.toml
+4
-1
Cargo.toml
···
10
10
"crates/atproto-xrpcs-helloworld",
11
11
"crates/atproto-xrpcs",
12
12
"crates/atproto-lexicon",
13
+
"crates/atproto-attestation",
13
14
]
14
15
resolver = "3"
15
16
···
31
32
atproto-record = { version = "0.13.0", path = "crates/atproto-record" }
32
33
atproto-xrpcs = { version = "0.13.0", path = "crates/atproto-xrpcs" }
33
34
atproto-jetstream = { version = "0.13.0", path = "crates/atproto-jetstream" }
35
+
atproto-attestation = { version = "0.13.0", path = "crates/atproto-attestation" }
34
36
35
37
anyhow = "1.0"
36
38
async-trait = "0.1.88"
···
63
65
tokio-util = "0.7"
64
66
tracing = { version = "0.1", features = ["async-await"] }
65
67
ulid = "1.2.1"
68
+
zstd = "0.13"
69
+
url = "2.5"
66
70
urlencoding = "2.1"
67
-
zstd = "0.13"
68
71
69
72
zeroize = { version = "1.8.1", features = ["zeroize_derive"] }
70
73
+12
-8
Dockerfile
+12
-8
Dockerfile
···
1
1
# Multi-stage build for atproto-identity-rs workspace
2
-
# Builds and installs all 13 binaries from the workspace
2
+
# Builds and installs all 15 binaries from the workspace
3
3
4
-
# Build stage - use 1.89 to support resolver = "3" and edition = "2024"
4
+
# Build stage - use 1.90 to support resolver = "3" and edition = "2024"
5
5
FROM rust:1.90-slim-bookworm AS builder
6
6
7
7
# Install system dependencies needed for building
···
19
19
# Build all binaries in release mode
20
20
# This will build all binaries defined in the workspace:
21
21
# - atproto-identity: 4 binaries (resolve, key, sign, validate)
22
-
# - atproto-record: 2 binaries (sign, verify)
22
+
# - atproto-attestation: 2 binaries (attestation-sign, attestation-verify)
23
+
# - atproto-record: 1 binary (record-cid)
23
24
# - atproto-client: 3 binaries (auth, app-password, dpop)
24
25
# - atproto-oauth: 1 binary (service-token)
25
26
# - atproto-oauth-axum: 1 binary (oauth-tool)
···
40
41
COPY --from=builder /usr/src/app/target/release/atproto-identity-key .
41
42
COPY --from=builder /usr/src/app/target/release/atproto-identity-sign .
42
43
COPY --from=builder /usr/src/app/target/release/atproto-identity-validate .
43
-
COPY --from=builder /usr/src/app/target/release/atproto-record-sign .
44
-
COPY --from=builder /usr/src/app/target/release/atproto-record-verify .
44
+
COPY --from=builder /usr/src/app/target/release/atproto-attestation-sign .
45
+
COPY --from=builder /usr/src/app/target/release/atproto-attestation-verify .
46
+
COPY --from=builder /usr/src/app/target/release/atproto-record-cid .
45
47
COPY --from=builder /usr/src/app/target/release/atproto-client-auth .
46
48
COPY --from=builder /usr/src/app/target/release/atproto-client-app-password .
47
49
COPY --from=builder /usr/src/app/target/release/atproto-client-dpop .
···
53
55
54
56
# Default to the main resolution tool
55
57
# Users can override with specific binary: docker run <image> atproto-identity-resolve --help
56
-
# Or run other tools:
58
+
# Or run other tools:
57
59
# docker run <image> atproto-identity-key --help
58
-
# docker run <image> atproto-record-sign --help
60
+
# docker run <image> atproto-attestation-sign --help
61
+
# docker run <image> atproto-attestation-verify --help
62
+
# docker run <image> atproto-record-cid --help
59
63
# docker run <image> atproto-client-auth --help
60
64
# docker run <image> atproto-oauth-service-token --help
61
65
# docker run <image> atproto-oauth-tool --help
···
73
77
LABEL org.opencontainers.image.licenses="MIT"
74
78
75
79
# Document available binaries
76
-
LABEL binaries="atproto-identity-resolve,atproto-identity-key,atproto-identity-sign,atproto-identity-validate,atproto-record-sign,atproto-record-verify,atproto-client-auth,atproto-client-app-password,atproto-client-dpop,atproto-oauth-service-token,atproto-oauth-tool,atproto-jetstream-consumer,atproto-xrpcs-helloworld,atproto-lexicon-resolve"
80
+
LABEL binaries="atproto-identity-resolve,atproto-identity-key,atproto-identity-sign,atproto-identity-validate,atproto-attestation-sign,atproto-attestation-verify,atproto-record-cid,atproto-client-auth,atproto-client-app-password,atproto-client-dpop,atproto-oauth-service-token,atproto-oauth-tool,atproto-jetstream-consumer,atproto-xrpcs-helloworld,atproto-lexicon-resolve"
+22
-15
README.md
+22
-15
README.md
···
11
11
### Identity & Cryptography
12
12
13
13
- **[`atproto-identity`](crates/atproto-identity/)** - Core identity management with multi-method DID resolution (plc, web, key), DNS/HTTP handle resolution, and P-256/P-384/K-256 cryptographic operations. *Includes 4 CLI tools.*
14
-
- **[`atproto-record`](crates/atproto-record/)** - Cryptographic signature operations for AT Protocol records using IPLD DAG-CBOR serialization with AT-URI parsing support. *Includes 2 CLI tools.*
14
+
- **[`atproto-attestation`](crates/atproto-attestation/)** - CID-first attestation utilities for creating and verifying cryptographic signatures on AT Protocol records, supporting both inline and remote attestation workflows. *Includes 2 CLI tools.*
15
+
- **[`atproto-record`](crates/atproto-record/)** - Record utilities including TID generation, AT-URI parsing, datetime formatting, and CID generation using IPLD DAG-CBOR serialization. *Includes 1 CLI tool.*
15
16
- **[`atproto-lexicon`](crates/atproto-lexicon/)** - Lexicon schema resolution and validation for AT Protocol, supporting recursive resolution, NSID validation, and DNS-based lexicon discovery. *Includes 1 CLI tool.*
16
17
17
18
### Authentication & Authorization
···
37
38
```toml
38
39
[dependencies]
39
40
atproto-identity = "0.13.0"
41
+
atproto-attestation = "0.13.0"
40
42
atproto-record = "0.13.0"
41
43
atproto-lexicon = "0.13.0"
42
44
atproto-oauth = "0.13.0"
···
85
87
### Record Signing
86
88
87
89
```rust
88
-
use atproto_identity::key::identify_key;
89
-
use atproto_record::signature;
90
+
use atproto_identity::key::{identify_key, to_public};
91
+
use atproto_attestation::{create_inline_attestation, verify_all_signatures, VerificationStatus};
90
92
use serde_json::json;
91
93
92
94
#[tokio::main]
93
95
async fn main() -> anyhow::Result<()> {
94
-
let signing_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?;
96
+
let private_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?;
97
+
let public_key = to_public(&private_key)?;
98
+
let key_reference = format!("{}", &public_key);
95
99
96
100
let record = json!({
97
101
"$type": "app.bsky.feed.post",
···
99
103
"createdAt": "2024-01-01T00:00:00.000Z"
100
104
});
101
105
102
-
let signature_object = json!({
106
+
let sig_metadata = json!({
107
+
"$type": "com.example.inlineSignature",
108
+
"key": &key_reference,
103
109
"issuer": "did:plc:issuer123",
104
110
"issuedAt": "2024-01-01T00:00:00.000Z"
105
111
});
106
112
107
-
let signed_record = signature::create(
108
-
&signing_key,
109
-
&record,
110
-
"did:plc:user123",
111
-
"app.bsky.feed.post",
112
-
signature_object,
113
-
).await?;
113
+
let signed_record =
114
+
create_inline_attestation(&record, &sig_metadata, &private_key)?;
115
+
116
+
let reports = verify_all_signatures(&signed_record, None).await?;
117
+
assert!(reports.iter().all(|report| matches!(report.status, VerificationStatus::Valid { .. })));
114
118
115
119
Ok(())
116
120
}
···
212
216
cargo run --features clap --bin atproto-identity-sign -- did:key:... data.json
213
217
cargo run --features clap --bin atproto-identity-validate -- did:key:... data.json signature
214
218
219
+
# Attestation operations (atproto-attestation crate)
220
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- inline record.json did:key:... metadata.json
221
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- signed_record.json
222
+
215
223
# Record operations (atproto-record crate)
216
-
cargo run --features clap --bin atproto-record-sign -- did:key:... did:plc:issuer record.json repository=did:plc:user collection=app.bsky.feed.post
217
-
cargo run --features clap --bin atproto-record-verify -- did:plc:issuer did:key:... signed_record.json repository=did:plc:user collection=app.bsky.feed.post
224
+
cat record.json | cargo run --features clap --bin atproto-record-cid
218
225
219
226
# Lexicon operations (atproto-lexicon crate)
220
227
cargo run --features clap,hickory-dns --bin atproto-lexicon-resolve -- app.bsky.feed.post
···
289
296
290
297
## Acknowledgments
291
298
292
-
Parts of this project were extracted from the [smokesignal.events](https://tangled.sh/@smokesignal.events/smokesignal) project, an open-source AT Protocol event and RSVP management application. This extraction enables broader community use and contribution to AT Protocol tooling in Rust.
299
+
Parts of this project were extracted from the [smokesignal.events](https://tangled.sh/@smokesignal.events/smokesignal) project, an open-source AT Protocol event and RSVP management application. This extraction enables broader community use and contribution to AT Protocol tooling in Rust.
+63
crates/atproto-attestation/Cargo.toml
+63
crates/atproto-attestation/Cargo.toml
···
1
+
[package]
2
+
name = "atproto-attestation"
3
+
version = "0.13.0"
4
+
description = "AT Protocol attestation utilities for creating and verifying record signatures"
5
+
readme = "README.md"
6
+
homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs"
7
+
documentation = "https://docs.rs/atproto-attestation"
8
+
edition.workspace = true
9
+
rust-version.workspace = true
10
+
repository.workspace = true
11
+
authors.workspace = true
12
+
license.workspace = true
13
+
keywords.workspace = true
14
+
categories.workspace = true
15
+
16
+
[[bin]]
17
+
name = "atproto-attestation-sign"
18
+
test = false
19
+
bench = false
20
+
doc = true
21
+
required-features = ["clap", "tokio"]
22
+
23
+
[[bin]]
24
+
name = "atproto-attestation-verify"
25
+
test = false
26
+
bench = false
27
+
doc = true
28
+
required-features = ["clap", "tokio"]
29
+
30
+
[dependencies]
31
+
atproto-client.workspace = true
32
+
atproto-identity.workspace = true
33
+
atproto-record.workspace = true
34
+
anyhow.workspace = true
35
+
base64.workspace = true
36
+
serde.workspace = true
37
+
serde_json.workspace = true
38
+
serde_ipld_dagcbor.workspace = true
39
+
sha2.workspace = true
40
+
thiserror.workspace = true
41
+
42
+
cid = "0.11"
43
+
elliptic-curve = { version = "0.13", default-features = false, features = ["std"] }
44
+
k256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
45
+
multihash = "0.19"
46
+
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
47
+
48
+
async-trait = { workspace = true, optional = true }
49
+
clap = { workspace = true, optional = true }
50
+
reqwest = { workspace = true, optional = true }
51
+
tokio = { workspace = true, optional = true }
52
+
53
+
[dev-dependencies]
54
+
async-trait = "0.1"
55
+
tokio = { workspace = true, features = ["macros", "rt"] }
56
+
57
+
[features]
58
+
default = []
59
+
clap = ["dep:clap"]
60
+
tokio = ["dep:tokio", "dep:reqwest", "dep:async-trait"]
61
+
62
+
[lints]
63
+
workspace = true
+421
crates/atproto-attestation/README.md
+421
crates/atproto-attestation/README.md
···
1
+
# atproto-attestation
2
+
3
+
Utilities for preparing, signing, and verifying AT Protocol record attestations using the CID-first workflow.
4
+
5
+
## Overview
6
+
7
+
A Rust library implementing the CID-first attestation specification for AT Protocol records. This crate provides cryptographic signature creation and verification for records, supporting both inline attestations (signatures embedded directly in records) and remote attestations (separate proof records with strongRef references).
8
+
9
+
The attestation workflow ensures deterministic signing payloads by:
10
+
1. Preparing records with `$sig` metadata
11
+
2. Generating content identifiers (CIDs) using DAG-CBOR serialization
12
+
3. Signing CID bytes with elliptic curve cryptography
13
+
4. Embedding or referencing signatures in records
14
+
5. Verifying signatures against resolved public keys
15
+
16
+
## Features
17
+
18
+
- **Inline attestations**: Embed cryptographic signatures directly in record structures
19
+
- **Remote attestations**: Create separate proof records with CID-based strongRef references
20
+
- **CID-first workflow**: Deterministic signing based on content identifiers
21
+
- **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curves
22
+
- **Signature normalization**: Automatic low-S normalization for ECDSA signatures
23
+
- **Key resolution**: Resolve verification keys from DID documents or did:key identifiers
24
+
- **Flexible verification**: Verify individual signatures or all signatures in a record
25
+
- **Structured reporting**: Detailed verification reports with success/failure status
26
+
27
+
## CLI Tools
28
+
29
+
The following command-line tools are available when built with the `clap` and `tokio` features:
30
+
31
+
- **`atproto-attestation-sign`**: Sign AT Protocol records with inline or remote attestations
32
+
- **`atproto-attestation-verify`**: Verify cryptographic signatures on AT Protocol records
33
+
34
+
## Library Usage
35
+
36
+
### Creating Inline Attestations
37
+
38
+
Inline attestations embed the signature bytes directly in the record:
39
+
40
+
```rust
41
+
use atproto_identity::key::{identify_key, to_public};
42
+
use atproto_attestation::create_inline_attestation;
43
+
use serde_json::json;
44
+
45
+
#[tokio::main]
46
+
async fn main() -> anyhow::Result<()> {
47
+
// Parse the signing key from a did:key
48
+
let private_key = identify_key("did:key:zQ3sh...")?;
49
+
let public_key = to_public(&private_key)?;
50
+
let key_reference = format!("{}", &public_key);
51
+
52
+
// The record to sign
53
+
let record = json!({
54
+
"$type": "app.bsky.feed.post",
55
+
"text": "Hello world!",
56
+
"createdAt": "2024-01-01T00:00:00.000Z"
57
+
});
58
+
59
+
// Attestation metadata (required fields: $type, key)
60
+
let sig_metadata = json!({
61
+
"$type": "com.example.inlineSignature",
62
+
"key": &key_reference,
63
+
"issuer": "did:plc:issuer123",
64
+
"issuedAt": "2024-01-01T00:00:00.000Z"
65
+
});
66
+
67
+
// Create inline attestation
68
+
let signed_record = create_inline_attestation(&record, &sig_metadata, &private_key)?;
69
+
70
+
println!("{}", serde_json::to_string_pretty(&signed_record)?);
71
+
72
+
Ok(())
73
+
}
74
+
```
75
+
76
+
The resulting record will have a `signatures` array:
77
+
78
+
```json
79
+
{
80
+
"$type": "app.bsky.feed.post",
81
+
"text": "Hello world!",
82
+
"createdAt": "2024-01-01T00:00:00.000Z",
83
+
"signatures": [
84
+
{
85
+
"$type": "com.example.inlineSignature",
86
+
"key": "did:key:zQ3sh...",
87
+
"issuer": "did:plc:issuer123",
88
+
"issuedAt": "2024-01-01T00:00:00.000Z",
89
+
"signature": {
90
+
"$bytes": "base64-encoded-signature-bytes"
91
+
}
92
+
}
93
+
]
94
+
}
95
+
```
96
+
97
+
### Creating Remote Attestations
98
+
99
+
Remote attestations create a separate proof record that must be stored in a repository:
100
+
101
+
```rust
102
+
use atproto_attestation::{create_remote_attestation, create_remote_attestation_reference};
103
+
use serde_json::json;
104
+
105
+
let record = json!({
106
+
"$type": "app.bsky.feed.post",
107
+
"text": "Hello world!"
108
+
});
109
+
110
+
let metadata = json!({
111
+
"$type": "com.example.attestation",
112
+
"issuer": "did:plc:issuer123",
113
+
"purpose": "verification"
114
+
});
115
+
116
+
// Create the proof record (contains the CID)
117
+
let proof_record = create_remote_attestation(&record, &metadata)?;
118
+
119
+
// Create the source record with strongRef
120
+
let repository_did = "did:plc:repo123";
121
+
let attested_record = create_remote_attestation_reference(
122
+
&record,
123
+
&proof_record,
124
+
repository_did
125
+
)?;
126
+
127
+
// The proof_record should be stored in the repository
128
+
// The attested_record contains the strongRef reference
129
+
```
130
+
131
+
### Verifying Signatures
132
+
133
+
Verify signatures embedded in records:
134
+
135
+
```rust
136
+
use atproto_attestation::{verify_all_signatures, VerificationStatus};
137
+
138
+
#[tokio::main]
139
+
async fn main() -> anyhow::Result<()> {
140
+
// Signed record with signatures array
141
+
let signed_record = /* ... */;
142
+
143
+
// Verify all signatures (remote attestations will be unverified)
144
+
let reports = verify_all_signatures(&signed_record, None).await?;
145
+
146
+
for report in reports {
147
+
match report.status {
148
+
VerificationStatus::Valid { cid } => {
149
+
println!("✓ Signature {} is valid (CID: {})", report.index, cid);
150
+
}
151
+
VerificationStatus::Invalid { error } => {
152
+
println!("✗ Signature {} is invalid: {}", report.index, error);
153
+
}
154
+
VerificationStatus::Unverified { reason } => {
155
+
println!("? Signature {} unverified: {}", report.index, reason);
156
+
}
157
+
}
158
+
}
159
+
160
+
Ok(())
161
+
}
162
+
```
163
+
164
+
### Verifying with Custom Key Resolver
165
+
166
+
For signatures that reference DID document keys (not did:key), provide a key resolver:
167
+
168
+
```rust
169
+
use atproto_attestation::verify_all_signatures;
170
+
use atproto_identity::key::IdentityDocumentKeyResolver;
171
+
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
172
+
use std::sync::Arc;
173
+
174
+
#[tokio::main]
175
+
async fn main() -> anyhow::Result<()> {
176
+
let http_client = reqwest::Client::new();
177
+
let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
178
+
179
+
// Create identity and key resolvers
180
+
let identity_resolver = Arc::new(InnerIdentityResolver {
181
+
http_client: http_client.clone(),
182
+
dns_resolver: Arc::new(dns_resolver),
183
+
plc_hostname: "plc.directory".to_string(),
184
+
});
185
+
let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver);
186
+
187
+
let signed_record = /* ... */;
188
+
189
+
// Verify with key resolver for DID document keys
190
+
let reports = verify_all_signatures(&signed_record, Some(&key_resolver)).await?;
191
+
192
+
Ok(())
193
+
}
194
+
```
195
+
196
+
### Verifying Remote Attestations
197
+
198
+
To verify remote attestations (strongRef), use `verify_all_signatures_with_resolver` and provide a `RecordResolver` that can fetch proof records:
199
+
200
+
```rust
201
+
use atproto_attestation::verify_all_signatures_with_resolver;
202
+
use atproto_client::record_resolver::RecordResolver;
203
+
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
204
+
use atproto_identity::traits::IdentityResolver;
205
+
use std::sync::Arc;
206
+
207
+
// Custom record resolver that resolves DIDs to find PDS endpoints
208
+
struct MyRecordResolver {
209
+
http_client: reqwest::Client,
210
+
identity_resolver: InnerIdentityResolver,
211
+
}
212
+
213
+
#[async_trait::async_trait]
214
+
impl RecordResolver for MyRecordResolver {
215
+
async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T>
216
+
where
217
+
T: serde::de::DeserializeOwned + Send,
218
+
{
219
+
// Parse AT-URI, resolve DID to PDS, fetch record
220
+
// See atproto-attestation-verify.rs for full implementation
221
+
todo!()
222
+
}
223
+
}
224
+
225
+
#[tokio::main]
226
+
async fn main() -> anyhow::Result<()> {
227
+
let http_client = reqwest::Client::new();
228
+
let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
229
+
230
+
let identity_resolver = InnerIdentityResolver {
231
+
http_client: http_client.clone(),
232
+
dns_resolver: Arc::new(dns_resolver),
233
+
plc_hostname: "plc.directory".to_string(),
234
+
};
235
+
236
+
let record_resolver = MyRecordResolver {
237
+
http_client,
238
+
identity_resolver,
239
+
};
240
+
241
+
let signed_record = /* ... */;
242
+
243
+
// Verify all signatures including remote attestations
244
+
let reports = verify_all_signatures_with_resolver(&signed_record, None, Some(&record_resolver)).await?;
245
+
246
+
Ok(())
247
+
}
248
+
```
249
+
250
+
### Manual CID Generation
251
+
252
+
For advanced use cases, manually generate CIDs:
253
+
254
+
```rust
255
+
use atproto_attestation::{prepare_signing_record, create_cid};
256
+
use serde_json::json;
257
+
258
+
let record = json!({
259
+
"$type": "app.bsky.feed.post",
260
+
"text": "Manual CID generation"
261
+
});
262
+
263
+
let metadata = json!({
264
+
"$type": "com.example.signature",
265
+
"key": "did:key:z..."
266
+
});
267
+
268
+
// Prepare the signing record (adds $sig, removes signatures)
269
+
let signing_record = prepare_signing_record(&record, &metadata)?;
270
+
271
+
// Generate the CID
272
+
let cid = create_cid(&signing_record)?;
273
+
println!("CID: {}", cid);
274
+
```
275
+
276
+
## Command Line Usage
277
+
278
+
### Signing Records
279
+
280
+
#### Inline Attestation
281
+
282
+
```bash
283
+
# Sign with inline attestation (signature embedded in record)
284
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \
285
+
inline \
286
+
record.json \
287
+
did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA \
288
+
metadata.json
289
+
290
+
# Using JSON strings instead of files
291
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \
292
+
inline \
293
+
'{"$type":"app.bsky.feed.post","text":"Hello!"}' \
294
+
did:key:zQ3sh... \
295
+
'{"$type":"com.example.sig","key":"did:key:zQ3sh..."}'
296
+
297
+
# Read record from stdin
298
+
cat record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \
299
+
inline \
300
+
- \
301
+
did:key:zQ3sh... \
302
+
metadata.json
303
+
```
304
+
305
+
#### Remote Attestation
306
+
307
+
```bash
308
+
# Create remote attestation (generates proof record + strongRef)
309
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \
310
+
remote \
311
+
record.json \
312
+
did:plc:repo123... \
313
+
metadata.json
314
+
315
+
# This outputs TWO JSON objects:
316
+
# 1. Proof record (store this in the repository)
317
+
# 2. Source record with strongRef attestation
318
+
```
319
+
320
+
### Verifying Signatures
321
+
322
+
#### Verify All Signatures in a Record
323
+
324
+
```bash
325
+
# Verify all signatures in a record from file
326
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
327
+
./signed_record.json
328
+
329
+
# Verify all signatures from AT-URI (fetches from PDS)
330
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
331
+
at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g
332
+
333
+
# Verify from stdin
334
+
cat signed_record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- -
335
+
336
+
# Verify from inline JSON
337
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
338
+
'{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}'
339
+
340
+
# Output shows each signature status:
341
+
# ✓ Signature 0 valid (key: did:key:zQ3sh...pb3) [CID: bafyrei...]
342
+
# ? Signature 1 unverified: Remote attestations require fetching the proof record via strongRef.
343
+
#
344
+
# Summary: 2 total, 1 valid
345
+
```
346
+
347
+
#### Verify Specific Attestation Against Record
348
+
349
+
```bash
350
+
# Verify a specific attestation record (both from files)
351
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
352
+
./record.json \
353
+
./attestation.json
354
+
355
+
# Verify attestation from AT-URI against local record
356
+
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
357
+
./record.json \
358
+
at://did:plc:xyz/com.example.attestation/abc123
359
+
360
+
# On success, outputs:
361
+
# OK
362
+
# CID: bafyrei...
363
+
```
364
+
365
+
## Attestation Specification
366
+
367
+
This crate implements the CID-first attestation specification, which ensures:
368
+
369
+
1. **Deterministic signing**: Records are serialized to DAG-CBOR with `$sig` metadata, producing consistent CIDs
370
+
2. **Content addressing**: Signatures are over CID bytes, not the full record
371
+
3. **Flexible metadata**: Custom fields in `$sig` are preserved and included in the CID calculation
372
+
4. **Signature normalization**: ECDSA signatures are normalized to low-S form
373
+
5. **Multiple attestations**: Records can have multiple signatures in the `signatures` array
374
+
375
+
### Signature Structure
376
+
377
+
Inline attestation entry:
378
+
```json
379
+
{
380
+
"$type": "com.example.signature",
381
+
"key": "did:key:z...",
382
+
"issuer": "did:plc:...",
383
+
"signature": {
384
+
"$bytes": "base64-signature"
385
+
}
386
+
}
387
+
```
388
+
389
+
Remote attestation entry (strongRef):
390
+
```json
391
+
{
392
+
"$type": "com.atproto.repo.strongRef",
393
+
"uri": "at://did:plc:repo/com.example.attestation/tid",
394
+
"cid": "bafyrei..."
395
+
}
396
+
```
397
+
398
+
## Error Handling
399
+
400
+
The crate provides structured error types via `AttestationError`:
401
+
402
+
- `RecordMustBeObject`: Input must be a JSON object
403
+
- `MetadataMustBeObject`: Attestation metadata must be a JSON object
404
+
- `SigMetadataMissing`: No `$sig` field found in prepared record
405
+
- `SignatureCreationFailed`: Key signing operation failed
406
+
- `SignatureValidationFailed`: Signature verification failed
407
+
- `SignatureNotNormalized`: ECDSA signature not in low-S form
408
+
- `KeyResolutionFailed`: Could not resolve verification key
409
+
- `UnsupportedKeyType`: Key type not supported for signing/verification
410
+
411
+
## Security Considerations
412
+
413
+
- **Key management**: Private keys should be protected and never logged or transmitted
414
+
- **Signature normalization**: All signatures are normalized to low-S form to prevent malleability
415
+
- **CID verification**: Always verify signatures against the reconstructed CID, not the record content
416
+
- **Key resolution**: Use trusted key resolvers to prevent key substitution attacks
417
+
- **Timestamp validation**: Check `issuedAt` and `expiry` fields if present in metadata
418
+
419
+
## License
420
+
421
+
MIT License
+298
crates/atproto-attestation/src/bin/atproto-attestation-sign.rs
+298
crates/atproto-attestation/src/bin/atproto-attestation-sign.rs
···
1
+
//! Command-line tool for signing AT Protocol records with inline or remote attestations.
2
+
//!
3
+
//! This tool creates cryptographic signatures for AT Protocol records using the CID-first
4
+
//! attestation specification. It supports both inline attestations (embedding signatures
5
+
//! directly in records) and remote attestations (creating separate proof records).
6
+
//!
7
+
//! ## Usage Patterns
8
+
//!
9
+
//! ### Remote Attestation
10
+
//! ```bash
11
+
//! atproto-attestation-sign remote <source_record> <repository_did> <metadata_record>
12
+
//! ```
13
+
//!
14
+
//! ### Inline Attestation
15
+
//! ```bash
16
+
//! atproto-attestation-sign inline <source_record> <signing_key> <metadata_record>
17
+
//! ```
18
+
//!
19
+
//! ## Arguments
20
+
//!
21
+
//! - `source_record`: JSON string or path to JSON file containing the record being attested
22
+
//! - `repository_did`: (Remote mode) DID of the repository that will contain the remote attestation record
23
+
//! - `signing_key`: (Inline mode) Private key string (did:key format) used to sign the attestation
24
+
//! - `metadata_record`: JSON string or path to JSON file with attestation metadata used during CID creation
25
+
//!
26
+
//! ## Examples
27
+
//!
28
+
//! ```bash
29
+
//! # Remote attestation - creates proof record and strongRef
30
+
//! atproto-attestation-sign remote \
31
+
//! record.json \
32
+
//! did:plc:xyz123... \
33
+
//! metadata.json
34
+
//!
35
+
//! # Inline attestation - embeds signature in record
36
+
//! atproto-attestation-sign inline \
37
+
//! record.json \
38
+
//! did:key:z42tv1pb3... \
39
+
//! '{"$type":"com.example.attestation","purpose":"demo"}'
40
+
//!
41
+
//! # Read from stdin
42
+
//! cat record.json | atproto-attestation-sign inline \
43
+
//! - \
44
+
//! did:key:z42tv1pb3... \
45
+
//! metadata.json
46
+
//! ```
47
+
48
+
use anyhow::{Context, Result, anyhow};
49
+
use atproto_attestation::{
50
+
create_inline_attestation, create_remote_attestation, create_remote_attestation_reference,
51
+
};
52
+
use atproto_identity::key::identify_key;
53
+
use clap::{Parser, Subcommand};
54
+
use serde_json::Value;
55
+
use std::{
56
+
fs,
57
+
io::{self, Read},
58
+
path::Path,
59
+
};
60
+
61
+
/// Command-line tool for signing AT Protocol records with cryptographic attestations.
62
+
///
63
+
/// Creates inline or remote attestations following the CID-first specification.
64
+
/// Inline attestations embed signatures directly in records, while remote attestations
65
+
/// generate separate proof records with strongRef references.
66
+
#[derive(Parser)]
67
+
#[command(
68
+
name = "atproto-attestation-sign",
69
+
version,
70
+
about = "Sign AT Protocol records with cryptographic attestations",
71
+
long_about = "
72
+
A command-line tool for signing AT Protocol records using the CID-first attestation
73
+
specification. Supports both inline attestations (signatures embedded in the record)
74
+
and remote attestations (separate proof records with CID references).
75
+
76
+
MODES:
77
+
remote Creates a separate proof record with strongRef reference
78
+
Syntax: remote <source_record> <repository_did> <metadata_record>
79
+
80
+
inline Embeds signature bytes directly in the record
81
+
Syntax: inline <source_record> <signing_key> <metadata_record>
82
+
83
+
ARGUMENTS:
84
+
source_record JSON string or file path to the record being attested
85
+
repository_did (Remote) DID of repository containing the attestation record
86
+
signing_key (Inline) Private key in did:key format for signing
87
+
metadata_record JSON string or file path with attestation metadata
88
+
89
+
EXAMPLES:
90
+
# Remote attestation (creates proof record + strongRef):
91
+
atproto-attestation-sign remote \\
92
+
record.json \\
93
+
did:plc:xyz123abc... \\
94
+
metadata.json
95
+
96
+
# Inline attestation (embeds signature):
97
+
atproto-attestation-sign inline \\
98
+
record.json \\
99
+
did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\
100
+
'{\"$type\":\"com.example.attestation\",\"purpose\":\"demo\"}'
101
+
102
+
# Read source record from stdin:
103
+
cat record.json | atproto-attestation-sign inline \\
104
+
- \\
105
+
did:key:z42tv1pb3... \\
106
+
metadata.json
107
+
108
+
OUTPUT:
109
+
Remote mode outputs TWO JSON objects:
110
+
1. The proof record (to be stored in the repository)
111
+
2. The source record with strongRef attestation appended
112
+
113
+
Inline mode outputs ONE JSON object:
114
+
- The source record with inline attestation embedded
115
+
"
116
+
)]
117
+
struct Args {
118
+
#[command(subcommand)]
119
+
command: Commands,
120
+
}
121
+
122
+
#[derive(Subcommand)]
123
+
enum Commands {
124
+
/// Create a remote attestation with separate proof record
125
+
///
126
+
/// Generates a proof record containing the CID and returns both the proof
127
+
/// record (to be stored in the repository) and the source record with a
128
+
/// strongRef attestation reference.
129
+
#[command(visible_alias = "r")]
130
+
Remote {
131
+
/// Source record JSON string or file path (use '-' for stdin)
132
+
source_record: String,
133
+
134
+
/// Repository DID that will contain the remote attestation record
135
+
repository_did: String,
136
+
137
+
/// Attestation metadata JSON string or file path
138
+
metadata_record: String,
139
+
},
140
+
141
+
/// Create an inline attestation with embedded signature
142
+
///
143
+
/// Signs the record with the provided private key and embeds the signature
144
+
/// directly in the record's attestation structure.
145
+
#[command(visible_alias = "i")]
146
+
Inline {
147
+
/// Source record JSON string or file path (use '-' for stdin)
148
+
source_record: String,
149
+
150
+
/// Private signing key in did:key format (e.g., did:key:z...)
151
+
signing_key: String,
152
+
153
+
/// Attestation metadata JSON string or file path
154
+
metadata_record: String,
155
+
},
156
+
}
157
+
158
+
#[tokio::main]
159
+
async fn main() -> Result<()> {
160
+
let args = Args::parse();
161
+
162
+
match args.command {
163
+
Commands::Remote {
164
+
source_record,
165
+
repository_did,
166
+
metadata_record,
167
+
} => handle_remote_attestation(&source_record, &repository_did, &metadata_record)?,
168
+
169
+
Commands::Inline {
170
+
source_record,
171
+
signing_key,
172
+
metadata_record,
173
+
} => handle_inline_attestation(&source_record, &signing_key, &metadata_record)?,
174
+
}
175
+
176
+
Ok(())
177
+
}
178
+
179
+
/// Handle remote attestation mode.
180
+
///
181
+
/// Creates a proof record and appends a strongRef to the source record.
182
+
/// Outputs both the proof record and the updated source record.
183
+
fn handle_remote_attestation(
184
+
source_record: &str,
185
+
repository_did: &str,
186
+
metadata_record: &str,
187
+
) -> Result<()> {
188
+
// Load source record and metadata
189
+
let record_json = load_json_input(source_record)?;
190
+
let metadata_json = load_json_input(metadata_record)?;
191
+
192
+
// Validate inputs
193
+
if !record_json.is_object() {
194
+
return Err(anyhow!("Source record must be a JSON object"));
195
+
}
196
+
197
+
if !metadata_json.is_object() {
198
+
return Err(anyhow!("Metadata record must be a JSON object"));
199
+
}
200
+
201
+
// Validate repository DID
202
+
if !repository_did.starts_with("did:") {
203
+
return Err(anyhow!(
204
+
"Repository DID must start with 'did:' prefix, got: {}",
205
+
repository_did
206
+
));
207
+
}
208
+
209
+
// Create the remote attestation proof record
210
+
let proof_record = create_remote_attestation(&record_json, &metadata_json)
211
+
.context("Failed to create remote attestation proof record")?;
212
+
213
+
// Create the source record with strongRef reference
214
+
let attested_record =
215
+
create_remote_attestation_reference(&record_json, &proof_record, repository_did)
216
+
.context("Failed to create remote attestation reference")?;
217
+
218
+
// Output both records
219
+
println!("=== Proof Record (store in repository) ===");
220
+
println!("{}", serde_json::to_string_pretty(&proof_record)?);
221
+
println!();
222
+
println!("=== Attested Record (with strongRef) ===");
223
+
println!("{}", serde_json::to_string_pretty(&attested_record)?);
224
+
225
+
Ok(())
226
+
}
227
+
228
+
/// Handle inline attestation mode.
229
+
///
230
+
/// Signs the record with the provided key and embeds the signature.
231
+
/// Outputs the record with inline attestation.
232
+
fn handle_inline_attestation(
233
+
source_record: &str,
234
+
signing_key: &str,
235
+
metadata_record: &str,
236
+
) -> Result<()> {
237
+
// Load source record and metadata
238
+
let record_json = load_json_input(source_record)?;
239
+
let metadata_json = load_json_input(metadata_record)?;
240
+
241
+
// Validate inputs
242
+
if !record_json.is_object() {
243
+
return Err(anyhow!("Source record must be a JSON object"));
244
+
}
245
+
246
+
if !metadata_json.is_object() {
247
+
return Err(anyhow!("Metadata record must be a JSON object"));
248
+
}
249
+
250
+
// Parse the signing key
251
+
let key_data = identify_key(signing_key)
252
+
.with_context(|| format!("Failed to parse signing key: {}", signing_key))?;
253
+
254
+
// Create inline attestation
255
+
let signed_record = create_inline_attestation(&record_json, &metadata_json, &key_data)
256
+
.context("Failed to create inline attestation")?;
257
+
258
+
// Output the signed record
259
+
println!("{}", serde_json::to_string_pretty(&signed_record)?);
260
+
261
+
Ok(())
262
+
}
263
+
264
+
/// Load JSON input from various sources.
265
+
///
266
+
/// Accepts:
267
+
/// - "-" for stdin
268
+
/// - File paths (if the file exists)
269
+
/// - Direct JSON strings
270
+
///
271
+
/// Returns the parsed JSON value or an error.
272
+
fn load_json_input(argument: &str) -> Result<Value> {
273
+
// Handle stdin input
274
+
if argument == "-" {
275
+
let mut input = String::new();
276
+
io::stdin()
277
+
.read_to_string(&mut input)
278
+
.context("Failed to read from stdin")?;
279
+
return serde_json::from_str(&input).context("Failed to parse JSON from stdin");
280
+
}
281
+
282
+
// Try as file path first
283
+
let path = Path::new(argument);
284
+
if path.is_file() {
285
+
let file_content = fs::read_to_string(path)
286
+
.with_context(|| format!("Failed to read file: {}", argument))?;
287
+
return serde_json::from_str(&file_content)
288
+
.with_context(|| format!("Failed to parse JSON from file: {}", argument));
289
+
}
290
+
291
+
// Try as direct JSON string
292
+
serde_json::from_str(argument).with_context(|| {
293
+
format!(
294
+
"Argument is neither valid JSON nor a readable file: {}",
295
+
argument
296
+
)
297
+
})
298
+
}
+440
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
+440
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
···
1
+
//! Command-line tool for verifying cryptographic signatures on AT Protocol records.
2
+
//!
3
+
//! This tool validates attestation signatures on AT Protocol records by reconstructing
4
+
//! the signed content and verifying ECDSA signatures against public keys embedded in the
5
+
//! attestation metadata.
6
+
//!
7
+
//! ## Usage Patterns
8
+
//!
9
+
//! ### Verify all signatures in a record
10
+
//! ```bash
11
+
//! atproto-attestation-verify <record>
12
+
//! ```
13
+
//!
14
+
//! ### Verify a specific attestation against a record
15
+
//! ```bash
16
+
//! atproto-attestation-verify <record> <attestation>
17
+
//! ```
18
+
//!
19
+
//! ## Parameter Formats
20
+
//!
21
+
//! Both `record` and `attestation` parameters accept:
22
+
//! - **JSON string**: Direct JSON payload (e.g., `'{"$type":"...","text":"..."}'`)
23
+
//! - **File path**: Path to a JSON file (e.g., `./record.json`)
24
+
//! - **AT-URI**: AT Protocol URI to fetch the record (e.g., `at://did:plc:abc/app.bsky.feed.post/123`)
25
+
//!
26
+
//! ## Examples
27
+
//!
28
+
//! ```bash
29
+
//! # Verify all signatures in a record from file
30
+
//! atproto-attestation-verify ./signed_post.json
31
+
//!
32
+
//! # Verify all signatures in a record from AT-URI
33
+
//! atproto-attestation-verify at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g
34
+
//!
35
+
//! # Verify specific attestation against a record (both from files)
36
+
//! atproto-attestation-verify ./record.json ./attestation.json
37
+
//!
38
+
//! # Verify specific attestation (from AT-URI) against record (from file)
39
+
//! atproto-attestation-verify ./record.json at://did:plc:xyz/com.example.attestation/abc123
40
+
//!
41
+
//! # Read record from stdin, verify all signatures
42
+
//! cat signed.json | atproto-attestation-verify -
43
+
//!
44
+
//! # Verify inline JSON
45
+
//! atproto-attestation-verify '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}'
46
+
//! ```
47
+
48
+
use anyhow::{Context, Result, anyhow};
49
+
use atproto_attestation::{VerificationStatus, verify_all_signatures_with_resolver};
50
+
use clap::Parser;
51
+
use serde_json::Value;
52
+
use std::{
53
+
fs,
54
+
io::{self, Read},
55
+
path::Path,
56
+
};
57
+
58
+
/// Command-line tool for verifying cryptographic signatures on AT Protocol records.
59
+
///
60
+
/// Validates attestation signatures by reconstructing signed content and checking
61
+
/// ECDSA signatures against embedded public keys. Supports verifying all signatures
62
+
/// in a record or validating a specific attestation record.
63
+
#[derive(Parser)]
64
+
#[command(
65
+
name = "atproto-attestation-verify",
66
+
version,
67
+
about = "Verify cryptographic signatures of AT Protocol records",
68
+
long_about = "
69
+
A command-line tool for verifying cryptographic signatures of AT Protocol records.
70
+
71
+
USAGE:
72
+
atproto-attestation-verify <record> Verify all signatures in record
73
+
atproto-attestation-verify <record> <attestation> Verify specific attestation
74
+
75
+
PARAMETER FORMATS:
76
+
Each parameter accepts JSON strings, file paths, or AT-URIs:
77
+
- JSON string: '{\"$type\":\"...\",\"text\":\"...\"}'
78
+
- File path: ./record.json
79
+
- AT-URI: at://did:plc:abc/app.bsky.feed.post/123
80
+
- Stdin: - (for record parameter only)
81
+
82
+
EXAMPLES:
83
+
# Verify all signatures in a record:
84
+
atproto-attestation-verify ./signed_post.json
85
+
atproto-attestation-verify at://did:plc:abc/app.bsky.feed.post/123
86
+
87
+
# Verify specific attestation:
88
+
atproto-attestation-verify ./record.json ./attestation.json
89
+
atproto-attestation-verify ./record.json at://did:plc:xyz/com.example.attestation/abc
90
+
91
+
# Read from stdin:
92
+
cat signed.json | atproto-attestation-verify -
93
+
94
+
OUTPUT:
95
+
Single record mode: Reports each signature with ✓ (valid), ✗ (invalid), or ? (unverified)
96
+
Attestation mode: Outputs 'OK' on success, error message on failure
97
+
98
+
VERIFICATION:
99
+
- Inline signatures are verified by reconstructing $sig and validating against embedded keys
100
+
- Remote attestations (strongRef) are reported as unverified (require fetching proof record)
101
+
- Keys are resolved from did:key identifiers or require a key resolver for DID document keys
102
+
"
103
+
)]
104
+
struct Args {
105
+
/// Record to verify - JSON string, file path, AT-URI, or '-' for stdin
106
+
record: String,
107
+
108
+
/// Optional attestation record to verify against the record - JSON string, file path, or AT-URI
109
+
attestation: Option<String>,
110
+
}
111
+
112
+
#[tokio::main]
113
+
async fn main() -> Result<()> {
114
+
let args = Args::parse();
115
+
116
+
// Load the record
117
+
let record = load_input(&args.record, true)
118
+
.await
119
+
.context("Failed to load record")?;
120
+
121
+
if !record.is_object() {
122
+
return Err(anyhow!("Record must be a JSON object"));
123
+
}
124
+
125
+
// Determine verification mode
126
+
match args.attestation {
127
+
None => {
128
+
// Mode 1: Verify all signatures in the record
129
+
verify_all_mode(&record).await
130
+
}
131
+
Some(attestation_input) => {
132
+
// Mode 2: Verify specific attestation against record
133
+
let attestation = load_input(&attestation_input, false)
134
+
.await
135
+
.context("Failed to load attestation")?;
136
+
137
+
if !attestation.is_object() {
138
+
return Err(anyhow!("Attestation must be a JSON object"));
139
+
}
140
+
141
+
verify_attestation_mode(&record, &attestation).await
142
+
}
143
+
}
144
+
}
145
+
146
+
/// Mode 1: Verify all signatures contained in the record.
147
+
///
148
+
/// Reports each signature with status indicators:
149
+
/// - ✓ Valid signature
150
+
/// - ✗ Invalid signature
151
+
/// - ? Unverified (e.g., remote attestations requiring proof record fetch)
152
+
async fn verify_all_mode(record: &Value) -> Result<()> {
153
+
// Create an identity resolver for fetching remote attestations
154
+
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
155
+
use std::sync::Arc;
156
+
157
+
let http_client = reqwest::Client::new();
158
+
let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
159
+
160
+
let identity_resolver = InnerIdentityResolver {
161
+
http_client: http_client.clone(),
162
+
dns_resolver: Arc::new(dns_resolver),
163
+
plc_hostname: "plc.directory".to_string(),
164
+
};
165
+
166
+
// Create record resolver that can fetch remote attestation proof records
167
+
let record_resolver = RemoteAttestationResolver {
168
+
http_client,
169
+
identity_resolver,
170
+
};
171
+
172
+
let reports = verify_all_signatures_with_resolver(record, None, Some(&record_resolver))
173
+
.await
174
+
.context("Failed to verify signatures")?;
175
+
176
+
if reports.is_empty() {
177
+
return Err(anyhow!("No signatures found in record"));
178
+
}
179
+
180
+
let mut all_valid = true;
181
+
let mut has_errors = false;
182
+
183
+
for report in &reports {
184
+
match &report.status {
185
+
VerificationStatus::Valid { cid } => {
186
+
let key_info = report
187
+
.key
188
+
.as_deref()
189
+
.map(|k| format!(" (key: {})", truncate_did(k)))
190
+
.unwrap_or_default();
191
+
println!(
192
+
"✓ Signature {} valid{} [CID: {}]",
193
+
report.index, key_info, cid
194
+
);
195
+
}
196
+
VerificationStatus::Invalid { error } => {
197
+
println!("✗ Signature {} invalid: {}", report.index, error);
198
+
all_valid = false;
199
+
has_errors = true;
200
+
}
201
+
VerificationStatus::Unverified { reason } => {
202
+
println!("? Signature {} unverified: {}", report.index, reason);
203
+
all_valid = false;
204
+
}
205
+
}
206
+
}
207
+
208
+
println!();
209
+
println!(
210
+
"Summary: {} total, {} valid",
211
+
reports.len(),
212
+
reports
213
+
.iter()
214
+
.filter(|r| matches!(r.status, VerificationStatus::Valid { .. }))
215
+
.count()
216
+
);
217
+
218
+
if has_errors {
219
+
Err(anyhow!("One or more signatures are invalid"))
220
+
} else if !all_valid {
221
+
Err(anyhow!("One or more signatures could not be verified"))
222
+
} else {
223
+
Ok(())
224
+
}
225
+
}
226
+
227
+
/// Mode 2: Verify a specific attestation record against the provided record.
228
+
///
229
+
/// The attestation should be a standalone attestation object (e.g., from a remote proof record)
230
+
/// that will be verified against the record's content.
231
+
async fn verify_attestation_mode(record: &Value, attestation: &Value) -> Result<()> {
232
+
// The attestation should have a CID field that we can use to verify
233
+
let attestation_obj = attestation
234
+
.as_object()
235
+
.ok_or_else(|| anyhow!("Attestation must be a JSON object"))?;
236
+
237
+
// Get the CID from the attestation
238
+
let cid_str = attestation_obj
239
+
.get("cid")
240
+
.and_then(Value::as_str)
241
+
.ok_or_else(|| anyhow!("Attestation must contain a 'cid' field"))?;
242
+
243
+
// Prepare the signing record with the attestation metadata
244
+
let mut signing_metadata = attestation_obj.clone();
245
+
signing_metadata.remove("cid");
246
+
signing_metadata.remove("signature");
247
+
248
+
let signing_record =
249
+
atproto_attestation::prepare_signing_record(record, &Value::Object(signing_metadata))
250
+
.context("Failed to prepare signing record")?;
251
+
252
+
// Generate the CID from the signing record
253
+
let computed_cid =
254
+
atproto_attestation::create_cid(&signing_record).context("Failed to generate CID")?;
255
+
256
+
// Compare CIDs
257
+
if computed_cid.to_string() != cid_str {
258
+
return Err(anyhow!(
259
+
"CID mismatch: attestation claims {}, but computed {}",
260
+
cid_str,
261
+
computed_cid
262
+
));
263
+
}
264
+
265
+
println!("OK");
266
+
println!("CID: {}", computed_cid);
267
+
268
+
Ok(())
269
+
}
270
+
271
+
/// Load input from various sources: JSON string, file path, AT-URI, or stdin.
272
+
///
273
+
/// The `allow_stdin` parameter controls whether "-" is interpreted as stdin.
274
+
async fn load_input(input: &str, allow_stdin: bool) -> Result<Value> {
275
+
// Handle stdin
276
+
if input == "-" {
277
+
if !allow_stdin {
278
+
return Err(anyhow!(
279
+
"Stdin ('-') is only supported for the record parameter"
280
+
));
281
+
}
282
+
283
+
let mut buffer = String::new();
284
+
io::stdin()
285
+
.read_to_string(&mut buffer)
286
+
.context("Failed to read from stdin")?;
287
+
288
+
return serde_json::from_str(&buffer).context("Failed to parse JSON from stdin");
289
+
}
290
+
291
+
// Check if it's an AT-URI
292
+
if input.starts_with("at://") {
293
+
return load_from_aturi(input)
294
+
.await
295
+
.with_context(|| format!("Failed to fetch record from AT-URI: {}", input));
296
+
}
297
+
298
+
// Try as file path
299
+
let path = Path::new(input);
300
+
if path.exists() && path.is_file() {
301
+
let content =
302
+
fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", input))?;
303
+
304
+
return serde_json::from_str(&content)
305
+
.with_context(|| format!("Failed to parse JSON from file: {}", input));
306
+
}
307
+
308
+
// Try as direct JSON string
309
+
serde_json::from_str(input).with_context(|| {
310
+
format!(
311
+
"Input is not valid JSON, an existing file, or an AT-URI: {}",
312
+
input
313
+
)
314
+
})
315
+
}
316
+
317
+
/// Load a record from an AT-URI by fetching it from a PDS.
318
+
///
319
+
/// This requires resolving the DID to find the PDS endpoint, then fetching the record.
320
+
async fn load_from_aturi(aturi: &str) -> Result<Value> {
321
+
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
322
+
use atproto_record::aturi::ATURI;
323
+
use std::str::FromStr;
324
+
use std::sync::Arc;
325
+
326
+
// Parse the AT-URI
327
+
let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?;
328
+
329
+
// Create resolver components
330
+
let http_client = reqwest::Client::new();
331
+
let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
332
+
333
+
// Create identity resolver
334
+
let identity_resolver = InnerIdentityResolver {
335
+
http_client: http_client.clone(),
336
+
dns_resolver: Arc::new(dns_resolver),
337
+
plc_hostname: "plc.directory".to_string(),
338
+
};
339
+
340
+
// Resolve the DID to get the PDS endpoint
341
+
let document = identity_resolver
342
+
.resolve(&parsed.authority)
343
+
.await
344
+
.with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?;
345
+
346
+
// Find the PDS endpoint
347
+
let pds_endpoint = document
348
+
.service
349
+
.iter()
350
+
.find(|s| s.r#type == "AtprotoPersonalDataServer")
351
+
.map(|s| s.service_endpoint.as_str())
352
+
.ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?;
353
+
354
+
// Fetch the record using the XRPC client
355
+
let response = atproto_client::com::atproto::repo::get_record(
356
+
&http_client,
357
+
&atproto_client::client::Auth::None,
358
+
pds_endpoint,
359
+
&parsed.authority,
360
+
&parsed.collection,
361
+
&parsed.record_key,
362
+
None,
363
+
)
364
+
.await
365
+
.with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?;
366
+
367
+
match response {
368
+
atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => Ok(value),
369
+
atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => {
370
+
Err(anyhow!("Failed to fetch record: {}", error.error_message()))
371
+
}
372
+
}
373
+
}
374
+
375
+
/// Truncate a DID or did:key for display purposes.
376
+
fn truncate_did(did: &str) -> String {
377
+
if did.len() > 40 {
378
+
format!("{}...{}", &did[..20], &did[did.len() - 12..])
379
+
} else {
380
+
did.to_string()
381
+
}
382
+
}
383
+
384
+
/// Record resolver for remote attestations that resolves DIDs to find PDS endpoints.
385
+
struct RemoteAttestationResolver {
386
+
http_client: reqwest::Client,
387
+
identity_resolver: atproto_identity::resolve::InnerIdentityResolver,
388
+
}
389
+
390
+
#[async_trait::async_trait]
391
+
impl atproto_client::record_resolver::RecordResolver for RemoteAttestationResolver {
392
+
async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T>
393
+
where
394
+
T: serde::de::DeserializeOwned + Send,
395
+
{
396
+
use atproto_record::aturi::ATURI;
397
+
use std::str::FromStr;
398
+
399
+
// Parse the AT-URI
400
+
let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?;
401
+
402
+
// Resolve the DID to get the PDS endpoint
403
+
let document = self
404
+
.identity_resolver
405
+
.resolve(&parsed.authority)
406
+
.await
407
+
.with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?;
408
+
409
+
// Find the PDS endpoint
410
+
let pds_endpoint = document
411
+
.service
412
+
.iter()
413
+
.find(|s| s.r#type == "AtprotoPersonalDataServer")
414
+
.map(|s| s.service_endpoint.as_str())
415
+
.ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?;
416
+
417
+
// Fetch the record using the XRPC client
418
+
let response = atproto_client::com::atproto::repo::get_record(
419
+
&self.http_client,
420
+
&atproto_client::client::Auth::None,
421
+
pds_endpoint,
422
+
&parsed.authority,
423
+
&parsed.collection,
424
+
&parsed.record_key,
425
+
None,
426
+
)
427
+
.await
428
+
.with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?;
429
+
430
+
match response {
431
+
atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => {
432
+
serde_json::from_value(value)
433
+
.map_err(|e| anyhow!("Failed to deserialize record: {}", e))
434
+
}
435
+
atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => {
436
+
Err(anyhow!("Failed to fetch record: {}", error.error_message()))
437
+
}
438
+
}
439
+
}
440
+
}
+194
crates/atproto-attestation/src/errors.rs
+194
crates/atproto-attestation/src/errors.rs
···
1
+
//! Errors that can occur during attestation preparation and verification.
2
+
//!
3
+
//! Covers CID construction, `$sig` metadata validation, inline attestation
4
+
//! structure checks, and identity/key resolution failures.
5
+
6
+
use thiserror::Error;
7
+
8
+
/// Errors that can occur during attestation preparation and verification.
9
+
#[derive(Debug, Error)]
10
+
pub enum AttestationError {
11
+
/// Error when the record value is not a JSON object.
12
+
#[error("error-atproto-attestation-1 Record must be a JSON object")]
13
+
RecordMustBeObject,
14
+
15
+
/// Error when attestation metadata is not a JSON object.
16
+
#[error("error-atproto-attestation-2 Attestation metadata must be a JSON object")]
17
+
MetadataMustBeObject,
18
+
19
+
/// Error when attestation metadata is missing a required field.
20
+
#[error("error-atproto-attestation-3 Attestation metadata missing required field: {field}")]
21
+
MetadataMissingField {
22
+
/// Name of the missing field.
23
+
field: String,
24
+
},
25
+
26
+
/// Error when attestation metadata omits the `$type` discriminator.
27
+
#[error("error-atproto-attestation-4 Attestation metadata must include a string `$type` field")]
28
+
MetadataMissingSigType,
29
+
30
+
/// Error when the record does not contain a signatures array.
31
+
#[error("error-atproto-attestation-5 Signatures array not found on record")]
32
+
SignaturesArrayMissing,
33
+
34
+
/// Error when the signatures field exists but is not an array.
35
+
#[error("error-atproto-attestation-6 Signatures field must be an array")]
36
+
SignaturesFieldInvalid,
37
+
38
+
/// Error when attempting to verify a signature at an invalid index.
39
+
#[error("error-atproto-attestation-7 Signature index {index} out of bounds")]
40
+
SignatureIndexOutOfBounds {
41
+
/// Index that was requested.
42
+
index: usize,
43
+
},
44
+
45
+
/// Error when a signature object is missing a required field.
46
+
#[error("error-atproto-attestation-8 Signature object missing required field: {field}")]
47
+
SignatureMissingField {
48
+
/// Field name that was expected.
49
+
field: String,
50
+
},
51
+
52
+
/// Error when a signature object uses an invalid `$type` for inline attestations.
53
+
#[error(
54
+
"error-atproto-attestation-9 Inline attestation `$type` cannot be `com.atproto.repo.strongRef`"
55
+
)]
56
+
InlineAttestationTypeInvalid,
57
+
58
+
/// Error when a remote attestation entry does not use the strongRef type.
59
+
#[error(
60
+
"error-atproto-attestation-10 Remote attestation entries must use `com.atproto.repo.strongRef`"
61
+
)]
62
+
RemoteAttestationTypeInvalid,
63
+
64
+
/// Error when a remote attestation entry is missing a CID.
65
+
#[error(
66
+
"error-atproto-attestation-11 Remote attestation entries must include a string `cid` field"
67
+
)]
68
+
RemoteAttestationMissingCid,
69
+
70
+
/// Error when signature bytes are not provided using the `$bytes` wrapper.
71
+
#[error(
72
+
"error-atproto-attestation-12 Signature bytes must be encoded as `{{\"$bytes\": \"...\"}}`"
73
+
)]
74
+
SignatureBytesFormatInvalid,
75
+
76
+
/// Error when record serialization to DAG-CBOR fails.
77
+
#[error("error-atproto-attestation-13 Record serialization failed: {error}")]
78
+
RecordSerializationFailed {
79
+
/// Underlying serialization error.
80
+
#[from]
81
+
error: serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>,
82
+
},
83
+
84
+
/// Error when `$sig` metadata is missing from the record before CID creation.
85
+
#[error("error-atproto-attestation-14 `$sig` metadata must be present before generating a CID")]
86
+
SigMetadataMissing,
87
+
88
+
/// Error when `$sig` metadata is not an object.
89
+
#[error("error-atproto-attestation-15 `$sig` metadata must be a JSON object")]
90
+
SigMetadataNotObject,
91
+
92
+
/// Error when `$sig` metadata omits the `$type` discriminator.
93
+
#[error("error-atproto-attestation-16 `$sig` metadata must include a string `$type` field")]
94
+
SigMetadataMissingType,
95
+
96
+
/// Error when a key resolver is required but not provided.
97
+
#[error("error-atproto-attestation-17 Key resolver required to resolve key reference: {key}")]
98
+
KeyResolverRequired {
99
+
/// Key reference that required resolution.
100
+
key: String,
101
+
},
102
+
103
+
/// Error when key resolution using the provided resolver fails.
104
+
#[error("error-atproto-attestation-18 Failed to resolve key reference {key}: {error}")]
105
+
KeyResolutionFailed {
106
+
/// Key reference that was being resolved.
107
+
key: String,
108
+
/// Underlying resolution error.
109
+
#[source]
110
+
error: anyhow::Error,
111
+
},
112
+
113
+
/// Error when the key type is unsupported for inline attestations.
114
+
#[error("error-atproto-attestation-21 Unsupported key type for attestation: {key_type}")]
115
+
UnsupportedKeyType {
116
+
/// Unsupported key type.
117
+
key_type: atproto_identity::key::KeyType,
118
+
},
119
+
120
+
/// Error when signature decoding fails.
121
+
#[error("error-atproto-attestation-22 Signature decoding failed: {error}")]
122
+
SignatureDecodingFailed {
123
+
/// Underlying base64 decoding error.
124
+
#[from]
125
+
error: base64::DecodeError,
126
+
},
127
+
128
+
/// Error when signature length does not match the expected size.
129
+
#[error(
130
+
"error-atproto-attestation-23 Signature length invalid: expected {expected} bytes, found {actual}"
131
+
)]
132
+
SignatureLengthInvalid {
133
+
/// Expected signature length.
134
+
expected: usize,
135
+
/// Actual signature length.
136
+
actual: usize,
137
+
},
138
+
139
+
/// Error when signature is not normalized to low-S form.
140
+
#[error("error-atproto-attestation-24 Signature must be normalized to low-S form")]
141
+
SignatureNotNormalized,
142
+
143
+
/// Error when cryptographic verification fails.
144
+
#[error("error-atproto-attestation-25 Signature verification failed: {error}")]
145
+
SignatureValidationFailed {
146
+
/// Underlying key validation error.
147
+
#[source]
148
+
error: atproto_identity::errors::KeyError,
149
+
},
150
+
151
+
/// Error when multihash construction for CID generation fails.
152
+
#[error("error-atproto-attestation-26 Failed to construct CID multihash: {error}")]
153
+
MultihashWrapFailed {
154
+
/// Underlying multihash error.
155
+
#[source]
156
+
error: multihash::Error,
157
+
},
158
+
159
+
/// Error when signature creation fails during inline attestation.
160
+
#[error("error-atproto-attestation-27 Signature creation failed: {error}")]
161
+
SignatureCreationFailed {
162
+
/// Underlying signing error.
163
+
#[source]
164
+
error: atproto_identity::errors::KeyError,
165
+
},
166
+
167
+
/// Error when fetching a remote attestation proof record fails.
168
+
#[error("error-atproto-attestation-28 Failed to fetch remote attestation from {uri}: {error}")]
169
+
RemoteAttestationFetchFailed {
170
+
/// AT-URI that failed to resolve.
171
+
uri: String,
172
+
/// Underlying fetch error.
173
+
#[source]
174
+
error: anyhow::Error,
175
+
},
176
+
177
+
/// Error when the CID of a remote attestation proof record doesn't match expected.
178
+
#[error(
179
+
"error-atproto-attestation-29 Remote attestation CID mismatch: expected {expected}, got {actual}"
180
+
)]
181
+
RemoteAttestationCidMismatch {
182
+
/// Expected CID.
183
+
expected: String,
184
+
/// Actual CID.
185
+
actual: String,
186
+
},
187
+
188
+
/// Error when parsing a CID string fails.
189
+
#[error("error-atproto-attestation-30 Invalid CID format: {cid}")]
190
+
InvalidCid {
191
+
/// Invalid CID string.
192
+
cid: String,
193
+
},
194
+
}
+1021
crates/atproto-attestation/src/lib.rs
+1021
crates/atproto-attestation/src/lib.rs
···
1
+
//! AT Protocol record attestation utilities based on the CID-first specification.
2
+
//!
3
+
//! This crate implements helpers for constructing deterministic signing payloads,
4
+
//! creating inline and remote attestation references, and verifying signatures
5
+
//! against DID verification methods. It follows the requirements documented in
6
+
//! `bluesky-attestation-tee/documentation/spec/attestation.md`.
7
+
//!
8
+
//! The workflow for inline attestations is:
9
+
//! 1. Prepare a signing record with [`prepare_signing_record`].
10
+
//! 2. Generate the content identifier using [`create_cid`].
11
+
//! 3. Sign the CID bytes externally and embed the attestation with
12
+
//! [`create_inline_attestation_reference`].
13
+
//! 4. Verify signatures with [`verify_signature`] or [`verify_all_signatures`].
14
+
//!
15
+
//! Remote attestations follow the same `$sig` preparation process but store the
16
+
//! generated CID in a proof record and reference it with
17
+
//! [`create_remote_attestation_reference`].
18
+
19
+
#![forbid(unsafe_code)]
20
+
#![warn(missing_docs)]
21
+
22
+
pub mod errors;
23
+
24
+
use atproto_record::tid::Tid;
25
+
pub use errors::AttestationError;
26
+
27
+
use atproto_identity::key::{KeyData, KeyResolver, KeyType, identify_key, sign, validate};
28
+
use base64::{
29
+
Engine,
30
+
alphabet::STANDARD as STANDARD_ALPHABET,
31
+
engine::{
32
+
DecodePaddingMode,
33
+
general_purpose::{GeneralPurpose, GeneralPurposeConfig},
34
+
},
35
+
};
36
+
use cid::Cid;
37
+
use elliptic_curve::scalar::IsHigh;
38
+
use k256::ecdsa::Signature as K256Signature;
39
+
use multihash::Multihash;
40
+
use p256::ecdsa::Signature as P256Signature;
41
+
use serde_json::{Map, Value, json};
42
+
use sha2::{Digest, Sha256};
43
+
44
+
// Base64 engine that accepts both padded and unpadded input for maximum compatibility
45
+
// with various AT Protocol implementations. Uses standard encoding with padding for output,
46
+
// but accepts any padding format for decoding.
47
+
const BASE64: GeneralPurpose = GeneralPurpose::new(
48
+
&STANDARD_ALPHABET,
49
+
GeneralPurposeConfig::new()
50
+
.with_encode_padding(true)
51
+
.with_decode_padding_mode(DecodePaddingMode::Indifferent),
52
+
);
53
+
54
+
const STRONG_REF_TYPE: &str = "com.atproto.repo.strongRef";
55
+
56
+
/// Resolver trait for retrieving remote attestation records by AT URI.
57
+
///
58
+
/// Kind of attestation represented within the `signatures` array.
59
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60
+
pub enum AttestationKind {
61
+
/// Inline attestation containing signature bytes.
62
+
Inline,
63
+
/// Remote attestation referencing a proof record via strongRef.
64
+
Remote,
65
+
}
66
+
67
+
/// Result of verifying a single attestation entry.
68
+
#[derive(Debug)]
69
+
pub enum VerificationStatus {
70
+
/// Signature is valid for the reconstructed signing payload.
71
+
Valid {
72
+
/// CID produced for the reconstructed record.
73
+
cid: Cid,
74
+
},
75
+
/// Signature verification or metadata validation failed.
76
+
Invalid {
77
+
/// Failure reason.
78
+
error: AttestationError,
79
+
},
80
+
/// Attestation cannot be verified locally (e.g., remote references).
81
+
Unverified {
82
+
/// Explanation for why verification was skipped.
83
+
reason: String,
84
+
},
85
+
}
86
+
87
+
/// Structured verification report for a single attestation entry.
88
+
#[derive(Debug)]
89
+
pub struct VerificationReport {
90
+
/// Zero-based index of the signature in the record's `signatures` array.
91
+
pub index: usize,
92
+
/// Detected attestation kind.
93
+
pub kind: AttestationKind,
94
+
/// `$type` discriminator from the attestation entry, if present.
95
+
pub signature_type: Option<String>,
96
+
/// Key reference for inline signatures (if available).
97
+
pub key: Option<String>,
98
+
/// Verification outcome.
99
+
pub status: VerificationStatus,
100
+
}
101
+
102
+
/// Create a deterministic CID for a record prepared with [`prepare_signing_record`].
103
+
///
104
+
/// The record **must** contain a `$sig` object with at least a `$type` string
105
+
/// to scope the signature. The returned CID uses the blessed parameters:
106
+
/// CIDv1, dag-cbor codec (0x71), and sha2-256 multihash.
107
+
pub fn create_cid(record: &Value) -> Result<Cid, AttestationError> {
108
+
let record_object = record
109
+
.as_object()
110
+
.ok_or(AttestationError::RecordMustBeObject)?;
111
+
112
+
let sig_value = record_object
113
+
.get("$sig")
114
+
.ok_or(AttestationError::SigMetadataMissing)?;
115
+
116
+
let sig_object = sig_value
117
+
.as_object()
118
+
.ok_or(AttestationError::SigMetadataNotObject)?;
119
+
120
+
if !sig_object
121
+
.get("$type")
122
+
.and_then(Value::as_str)
123
+
.filter(|value| !value.is_empty())
124
+
.is_some()
125
+
{
126
+
return Err(AttestationError::SigMetadataMissingType);
127
+
}
128
+
129
+
let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?;
130
+
let digest = Sha256::digest(&dag_cbor_bytes);
131
+
let multihash = Multihash::wrap(0x12, &digest)
132
+
.map_err(|error| AttestationError::MultihashWrapFailed { error })?;
133
+
134
+
Ok(Cid::new_v1(0x71, multihash))
135
+
}
136
+
137
+
fn create_plain_cid(record: &Value) -> Result<Cid, AttestationError> {
138
+
let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?;
139
+
let digest = Sha256::digest(&dag_cbor_bytes);
140
+
let multihash = Multihash::wrap(0x12, &digest)
141
+
.map_err(|error| AttestationError::MultihashWrapFailed { error })?;
142
+
143
+
Ok(Cid::new_v1(0x71, multihash))
144
+
}
145
+
146
+
/// Prepare a record for signing by removing attestation artifacts and adding `$sig`.
147
+
///
148
+
/// - Removes any existing `signatures`, `sigs`, and `$sig` fields.
149
+
/// - Inserts the provided `attestation` metadata as the new `$sig` object.
150
+
/// - Ensures the metadata contains a string `$type` discriminator.
151
+
pub fn prepare_signing_record(
152
+
record: &Value,
153
+
attestation: &Value,
154
+
) -> Result<Value, AttestationError> {
155
+
let mut prepared = record
156
+
.as_object()
157
+
.cloned()
158
+
.ok_or(AttestationError::RecordMustBeObject)?;
159
+
160
+
let mut sig_metadata = attestation
161
+
.as_object()
162
+
.cloned()
163
+
.ok_or(AttestationError::MetadataMustBeObject)?;
164
+
165
+
if !sig_metadata
166
+
.get("$type")
167
+
.and_then(Value::as_str)
168
+
.filter(|value| !value.is_empty())
169
+
.is_some()
170
+
{
171
+
return Err(AttestationError::MetadataMissingSigType);
172
+
}
173
+
174
+
sig_metadata.remove("signature");
175
+
sig_metadata.remove("cid");
176
+
177
+
prepared.remove("signatures");
178
+
prepared.remove("sigs");
179
+
prepared.remove("$sig");
180
+
prepared.insert("$sig".to_string(), Value::Object(sig_metadata));
181
+
182
+
Ok(Value::Object(prepared))
183
+
}
184
+
185
+
/// Creates an inline attestation by signing the prepared record with the provided key.
186
+
pub fn create_inline_attestation(
187
+
record: &Value,
188
+
attestation_metadata: &Value,
189
+
signing_key: &KeyData,
190
+
) -> Result<Value, AttestationError> {
191
+
let signing_record = prepare_signing_record(record, attestation_metadata)?;
192
+
let cid = create_cid(&signing_record)?;
193
+
194
+
let raw_signature = sign(signing_key, &cid.to_bytes())
195
+
.map_err(|error| AttestationError::SignatureCreationFailed { error })?;
196
+
let signature_bytes = normalize_signature(raw_signature, signing_key.key_type())?;
197
+
198
+
let mut inline_object = attestation_metadata
199
+
.as_object()
200
+
.cloned()
201
+
.ok_or(AttestationError::MetadataMustBeObject)?;
202
+
203
+
inline_object.remove("signature");
204
+
inline_object.remove("cid");
205
+
inline_object.insert(
206
+
"signature".to_string(),
207
+
json!({"$bytes": BASE64.encode(signature_bytes)}),
208
+
);
209
+
210
+
create_inline_attestation_reference(record, &Value::Object(inline_object))
211
+
}
212
+
213
+
/// Creates a remote attestation by generating a proof record and strongRef entry.
214
+
///
215
+
/// Returns a tuple containing:
216
+
/// - Remote proof record containing the CID for storage in a repository.
217
+
pub fn create_remote_attestation(
218
+
record: &Value,
219
+
attestation_metadata: &Value,
220
+
) -> Result<Value, AttestationError> {
221
+
let metadata = attestation_metadata
222
+
.as_object()
223
+
.cloned()
224
+
.ok_or(AttestationError::MetadataMustBeObject)?;
225
+
226
+
let metadata_value = Value::Object(metadata.clone());
227
+
let signing_record = prepare_signing_record(record, &metadata_value)?;
228
+
let cid = create_cid(&signing_record)?;
229
+
230
+
let mut remote_attestation = metadata.clone();
231
+
remote_attestation.insert("cid".to_string(), Value::String(cid.to_string()));
232
+
233
+
Ok(Value::Object(remote_attestation))
234
+
}
235
+
236
+
/// Normalize raw signature bytes to the required low-S form.
237
+
///
238
+
/// This helper ensures signatures produced by signing APIs comply with the
239
+
/// specification requirements before embedding them in attestation objects.
240
+
pub fn normalize_signature(
241
+
signature: Vec<u8>,
242
+
key_type: &KeyType,
243
+
) -> Result<Vec<u8>, AttestationError> {
244
+
match key_type {
245
+
KeyType::P256Private | KeyType::P256Public => normalize_p256(signature),
246
+
KeyType::K256Private | KeyType::K256Public => normalize_k256(signature),
247
+
other => Err(AttestationError::UnsupportedKeyType {
248
+
key_type: other.clone(),
249
+
}),
250
+
}
251
+
}
252
+
253
+
/// Attach a remote attestation entry (strongRef) to the record.
254
+
///
255
+
/// The `attestation` value must be an object containing:
256
+
/// - `$type`: `"com.atproto.repo.strongRef"`
257
+
/// - `cid`: base32 CID string referencing the remote proof record
258
+
/// - Optional `uri`: AT URI for the remote record
259
+
pub fn create_remote_attestation_reference(
260
+
record: &Value,
261
+
attestation: &Value,
262
+
did: &str,
263
+
) -> Result<Value, AttestationError> {
264
+
let mut result = record
265
+
.as_object()
266
+
.cloned()
267
+
.ok_or(AttestationError::RecordMustBeObject)?;
268
+
269
+
let attestation = attestation
270
+
.as_object()
271
+
.cloned()
272
+
.ok_or(AttestationError::MetadataMustBeObject)?;
273
+
274
+
let remote_object_type = attestation
275
+
.get("$type")
276
+
.and_then(Value::as_str)
277
+
.filter(|value| !value.is_empty())
278
+
.ok_or(AttestationError::RemoteAttestationMissingCid)?;
279
+
280
+
let tid = Tid::new();
281
+
282
+
let attestion_cid = create_plain_cid(&serde_json::Value::Object(attestation.clone()))?;
283
+
284
+
let remote_object = json!({
285
+
"$type": STRONG_REF_TYPE,
286
+
"uri": format!("at://{did}/{remote_object_type}/{tid}"),
287
+
"cid": attestion_cid.to_string()
288
+
});
289
+
290
+
let mut signatures = extract_signatures_vec(&mut result)?;
291
+
signatures.push(remote_object);
292
+
result.insert("signatures".to_string(), Value::Array(signatures));
293
+
294
+
Ok(Value::Object(result))
295
+
}
296
+
297
+
/// Attach an inline attestation entry containing signature bytes.
298
+
///
299
+
/// The `attestation` value must be an object containing:
300
+
/// - `$type`: union discriminator (must NOT be `com.atproto.repo.strongRef`)
301
+
/// - `key`: verification method reference used to sign
302
+
/// - `signature`: object with `$bytes` base64 signature
303
+
/// Additional custom fields are preserved for `$sig` metadata.
304
+
pub fn create_inline_attestation_reference(
305
+
record: &Value,
306
+
attestation: &Value,
307
+
) -> Result<Value, AttestationError> {
308
+
let mut result = record
309
+
.as_object()
310
+
.cloned()
311
+
.ok_or(AttestationError::RecordMustBeObject)?;
312
+
313
+
let inline_object = attestation
314
+
.as_object()
315
+
.cloned()
316
+
.ok_or(AttestationError::MetadataMustBeObject)?;
317
+
318
+
let signature_type = inline_object
319
+
.get("$type")
320
+
.and_then(Value::as_str)
321
+
.ok_or_else(|| AttestationError::MetadataMissingField {
322
+
field: "$type".to_string(),
323
+
})?;
324
+
325
+
if signature_type == STRONG_REF_TYPE {
326
+
return Err(AttestationError::InlineAttestationTypeInvalid);
327
+
}
328
+
329
+
inline_object
330
+
.get("key")
331
+
.and_then(Value::as_str)
332
+
.filter(|value| !value.is_empty())
333
+
.ok_or_else(|| AttestationError::SignatureMissingField {
334
+
field: "key".to_string(),
335
+
})?;
336
+
337
+
let signature_bytes = inline_object
338
+
.get("signature")
339
+
.and_then(Value::as_object)
340
+
.and_then(|object| object.get("$bytes"))
341
+
.and_then(Value::as_str)
342
+
.filter(|value| !value.is_empty())
343
+
.ok_or(AttestationError::SignatureBytesFormatInvalid)?;
344
+
345
+
// Ensure the signature bytes decode cleanly to catch malformed input early.
346
+
let _ = BASE64
347
+
.decode(signature_bytes)
348
+
.map_err(|error| AttestationError::SignatureDecodingFailed { error })?;
349
+
350
+
let mut signatures = extract_signatures_vec(&mut result)?;
351
+
signatures.push(Value::Object(inline_object));
352
+
result.insert("signatures".to_string(), Value::Array(signatures));
353
+
result.remove("$sig");
354
+
355
+
Ok(Value::Object(result))
356
+
}
357
+
358
+
/// Verify a single attestation entry at the specified index without a record resolver.
359
+
///
360
+
/// Inline signatures are reconstructed into `$sig` metadata, a CID is generated,
361
+
/// and the signature bytes are validated against the resolved public key.
362
+
/// Remote attestations will be reported as unverified.
363
+
///
364
+
/// This is a convenience function for the common case where no record resolver is needed.
365
+
/// For verifying remote attestations, use [`verify_signature_with_resolver`].
366
+
pub async fn verify_signature(
367
+
record: &Value,
368
+
index: usize,
369
+
key_resolver: Option<&dyn KeyResolver>,
370
+
) -> Result<VerificationReport, AttestationError> {
371
+
verify_signature_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>(
372
+
record,
373
+
index,
374
+
key_resolver,
375
+
None,
376
+
)
377
+
.await
378
+
}
379
+
380
+
/// Verify a single attestation entry at the specified index with optional record resolver.
381
+
///
382
+
/// Inline signatures are reconstructed into `$sig` metadata, a CID is generated,
383
+
/// and the signature bytes are validated against the resolved public key.
384
+
/// Remote attestations can be verified if a `record_resolver` is provided to fetch
385
+
/// the proof record via AT-URI. Without a record resolver, remote attestations are
386
+
/// reported as unverified.
387
+
pub async fn verify_signature_with_resolver<R>(
388
+
record: &Value,
389
+
index: usize,
390
+
key_resolver: Option<&dyn KeyResolver>,
391
+
record_resolver: Option<&R>,
392
+
) -> Result<VerificationReport, AttestationError>
393
+
where
394
+
R: atproto_client::record_resolver::RecordResolver,
395
+
{
396
+
let signatures_array = extract_signatures_array(record)?;
397
+
let signature_entry = signatures_array
398
+
.get(index)
399
+
.ok_or(AttestationError::SignatureIndexOutOfBounds { index })?;
400
+
401
+
let signature_map =
402
+
signature_entry
403
+
.as_object()
404
+
.ok_or_else(|| AttestationError::SignatureMissingField {
405
+
field: "object".to_string(),
406
+
})?;
407
+
408
+
let signature_type = signature_map
409
+
.get("$type")
410
+
.and_then(Value::as_str)
411
+
.map(ToOwned::to_owned);
412
+
413
+
let report_kind = match signature_type.as_deref() {
414
+
Some(STRONG_REF_TYPE) => AttestationKind::Remote,
415
+
_ => AttestationKind::Inline,
416
+
};
417
+
418
+
let key_reference = signature_map
419
+
.get("key")
420
+
.and_then(Value::as_str)
421
+
.map(ToOwned::to_owned);
422
+
423
+
let status = match report_kind {
424
+
AttestationKind::Remote => {
425
+
match record_resolver {
426
+
Some(resolver) => {
427
+
match verify_remote_attestation(record, signature_map, resolver).await {
428
+
Ok(cid) => VerificationStatus::Valid { cid },
429
+
Err(error) => VerificationStatus::Invalid { error },
430
+
}
431
+
}
432
+
None => VerificationStatus::Unverified {
433
+
reason: "Remote attestations require a record resolver to fetch the proof record via strongRef.".to_string(),
434
+
},
435
+
}
436
+
}
437
+
AttestationKind::Inline => {
438
+
match verify_inline_attestation(record, signature_map, key_resolver).await {
439
+
Ok(cid) => VerificationStatus::Valid { cid },
440
+
Err(error) => VerificationStatus::Invalid { error },
441
+
}
442
+
}
443
+
};
444
+
445
+
Ok(VerificationReport {
446
+
index,
447
+
kind: report_kind,
448
+
signature_type,
449
+
key: key_reference,
450
+
status,
451
+
})
452
+
}
453
+
454
+
/// Verify all attestation entries attached to the record without a record resolver.
455
+
///
456
+
/// Returns a report per signature. Structural issues with the record (for
457
+
/// example, a missing `signatures` array) are returned as an error.
458
+
///
459
+
/// Remote attestations will be reported as unverified. For verifying remote
460
+
/// attestations, use [`verify_all_signatures_with_resolver`].
461
+
pub async fn verify_all_signatures(
462
+
record: &Value,
463
+
key_resolver: Option<&dyn KeyResolver>,
464
+
) -> Result<Vec<VerificationReport>, AttestationError> {
465
+
verify_all_signatures_with_resolver::<atproto_client::record_resolver::HttpRecordResolver>(
466
+
record,
467
+
key_resolver,
468
+
None,
469
+
)
470
+
.await
471
+
}
472
+
473
+
/// Verify all attestation entries attached to the record with optional record resolver.
474
+
///
475
+
/// Returns a report per signature. Structural issues with the record (for
476
+
/// example, a missing `signatures` array) are returned as an error.
477
+
///
478
+
/// If a `record_resolver` is provided, remote attestations will be fetched and verified.
479
+
/// Otherwise, remote attestations will be reported as unverified.
480
+
pub async fn verify_all_signatures_with_resolver<R>(
481
+
record: &Value,
482
+
key_resolver: Option<&dyn KeyResolver>,
483
+
record_resolver: Option<&R>,
484
+
) -> Result<Vec<VerificationReport>, AttestationError>
485
+
where
486
+
R: atproto_client::record_resolver::RecordResolver,
487
+
{
488
+
let signatures_array = extract_signatures_array(record)?;
489
+
let mut reports = Vec::with_capacity(signatures_array.len());
490
+
491
+
for index in 0..signatures_array.len() {
492
+
reports.push(
493
+
verify_signature_with_resolver(record, index, key_resolver, record_resolver).await?,
494
+
);
495
+
}
496
+
497
+
Ok(reports)
498
+
}
499
+
500
+
async fn verify_remote_attestation<R>(
501
+
record: &Value,
502
+
signature_object: &Map<String, Value>,
503
+
record_resolver: &R,
504
+
) -> Result<Cid, AttestationError>
505
+
where
506
+
R: atproto_client::record_resolver::RecordResolver,
507
+
{
508
+
// Extract the strongRef URI and CID
509
+
let uri = signature_object
510
+
.get("uri")
511
+
.and_then(Value::as_str)
512
+
.ok_or_else(|| AttestationError::SignatureMissingField {
513
+
field: "uri".to_string(),
514
+
})?;
515
+
516
+
let expected_cid_str = signature_object
517
+
.get("cid")
518
+
.and_then(Value::as_str)
519
+
.ok_or_else(|| AttestationError::SignatureMissingField {
520
+
field: "cid".to_string(),
521
+
})?;
522
+
523
+
// Fetch the proof record from the URI
524
+
let proof_record: Value = record_resolver.resolve(uri).await.map_err(|error| {
525
+
AttestationError::RemoteAttestationFetchFailed {
526
+
uri: uri.to_string(),
527
+
error,
528
+
}
529
+
})?;
530
+
531
+
// Verify the proof record CID matches
532
+
let proof_cid = create_plain_cid(&proof_record)?;
533
+
if proof_cid.to_string() != expected_cid_str {
534
+
return Err(AttestationError::RemoteAttestationCidMismatch {
535
+
expected: expected_cid_str.to_string(),
536
+
actual: proof_cid.to_string(),
537
+
});
538
+
}
539
+
540
+
// Extract the CID from the proof record
541
+
let attestation_cid_str = proof_record
542
+
.get("cid")
543
+
.and_then(Value::as_str)
544
+
.ok_or_else(|| AttestationError::SignatureMissingField {
545
+
field: "cid".to_string(),
546
+
})?;
547
+
548
+
// Parse the attestation CID
549
+
let attestation_cid =
550
+
attestation_cid_str
551
+
.parse::<Cid>()
552
+
.map_err(|_| AttestationError::InvalidCid {
553
+
cid: attestation_cid_str.to_string(),
554
+
})?;
555
+
556
+
// Prepare the signing record using the proof record as metadata (without the CID field)
557
+
let mut proof_metadata = proof_record
558
+
.as_object()
559
+
.cloned()
560
+
.ok_or(AttestationError::RecordMustBeObject)?;
561
+
proof_metadata.remove("cid");
562
+
563
+
let signing_record = prepare_signing_record(record, &Value::Object(proof_metadata))?;
564
+
let computed_cid = create_cid(&signing_record)?;
565
+
566
+
// Verify the CID matches
567
+
if computed_cid != attestation_cid {
568
+
return Err(AttestationError::RemoteAttestationCidMismatch {
569
+
expected: attestation_cid.to_string(),
570
+
actual: computed_cid.to_string(),
571
+
});
572
+
}
573
+
574
+
Ok(computed_cid)
575
+
}
576
+
577
+
async fn verify_inline_attestation(
578
+
record: &Value,
579
+
signature_object: &Map<String, Value>,
580
+
key_resolver: Option<&dyn KeyResolver>,
581
+
) -> Result<Cid, AttestationError> {
582
+
let key_reference = signature_object
583
+
.get("key")
584
+
.and_then(Value::as_str)
585
+
.ok_or_else(|| AttestationError::SignatureMissingField {
586
+
field: "key".to_string(),
587
+
})?;
588
+
589
+
let key_data = resolve_key_reference(key_reference, key_resolver).await?;
590
+
591
+
let signature_bytes = signature_object
592
+
.get("signature")
593
+
.and_then(Value::as_object)
594
+
.and_then(|object| object.get("$bytes"))
595
+
.and_then(Value::as_str)
596
+
.ok_or(AttestationError::SignatureBytesFormatInvalid)?;
597
+
598
+
let signature_bytes = BASE64
599
+
.decode(signature_bytes)
600
+
.map_err(|error| AttestationError::SignatureDecodingFailed { error })?;
601
+
602
+
ensure_normalized_signature(&key_data, &signature_bytes)?;
603
+
604
+
let mut sig_metadata = signature_object.clone();
605
+
sig_metadata.remove("signature");
606
+
607
+
let signing_record = prepare_signing_record(record, &Value::Object(sig_metadata))?;
608
+
let cid = create_cid(&signing_record)?;
609
+
let cid_bytes = cid.to_bytes();
610
+
611
+
validate(&key_data, &signature_bytes, &cid_bytes)
612
+
.map_err(|error| AttestationError::SignatureValidationFailed { error })?;
613
+
614
+
Ok(cid)
615
+
}
616
+
617
+
async fn resolve_key_reference(
618
+
key_reference: &str,
619
+
key_resolver: Option<&dyn KeyResolver>,
620
+
) -> Result<KeyData, AttestationError> {
621
+
if let Some(base) = key_reference.split('#').next() {
622
+
if let Ok(key_data) = identify_key(base) {
623
+
return Ok(key_data);
624
+
}
625
+
}
626
+
627
+
if let Ok(key_data) = identify_key(key_reference) {
628
+
return Ok(key_data);
629
+
}
630
+
631
+
let resolver = key_resolver.ok_or_else(|| AttestationError::KeyResolverRequired {
632
+
key: key_reference.to_string(),
633
+
})?;
634
+
635
+
resolver
636
+
.resolve(key_reference)
637
+
.await
638
+
.map_err(|error| AttestationError::KeyResolutionFailed {
639
+
key: key_reference.to_string(),
640
+
error,
641
+
})
642
+
}
643
+
644
+
fn normalize_p256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> {
645
+
if signature.len() != 64 {
646
+
return Err(AttestationError::SignatureLengthInvalid {
647
+
expected: 64,
648
+
actual: signature.len(),
649
+
});
650
+
}
651
+
652
+
let parsed = P256Signature::from_slice(&signature).map_err(|_| {
653
+
AttestationError::SignatureLengthInvalid {
654
+
expected: 64,
655
+
actual: signature.len(),
656
+
}
657
+
})?;
658
+
659
+
let normalized = parsed.normalize_s().unwrap_or(parsed);
660
+
661
+
Ok(normalized.to_vec())
662
+
}
663
+
664
+
fn normalize_k256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> {
665
+
if signature.len() != 64 {
666
+
return Err(AttestationError::SignatureLengthInvalid {
667
+
expected: 64,
668
+
actual: signature.len(),
669
+
});
670
+
}
671
+
672
+
let parsed = K256Signature::from_slice(&signature).map_err(|_| {
673
+
AttestationError::SignatureLengthInvalid {
674
+
expected: 64,
675
+
actual: signature.len(),
676
+
}
677
+
})?;
678
+
679
+
let normalized = parsed.normalize_s().unwrap_or(parsed);
680
+
681
+
Ok(normalized.to_vec())
682
+
}
683
+
684
+
fn ensure_normalized_signature(
685
+
key_data: &KeyData,
686
+
signature: &[u8],
687
+
) -> Result<(), AttestationError> {
688
+
match key_data.key_type() {
689
+
KeyType::P256Private | KeyType::P256Public => {
690
+
if signature.len() != 64 {
691
+
return Err(AttestationError::SignatureLengthInvalid {
692
+
expected: 64,
693
+
actual: signature.len(),
694
+
});
695
+
}
696
+
697
+
let parsed = P256Signature::from_slice(signature).map_err(|_| {
698
+
AttestationError::SignatureLengthInvalid {
699
+
expected: 64,
700
+
actual: signature.len(),
701
+
}
702
+
})?;
703
+
704
+
if bool::from(parsed.s().is_high()) {
705
+
return Err(AttestationError::SignatureNotNormalized);
706
+
}
707
+
}
708
+
KeyType::K256Private | KeyType::K256Public => {
709
+
if signature.len() != 64 {
710
+
return Err(AttestationError::SignatureLengthInvalid {
711
+
expected: 64,
712
+
actual: signature.len(),
713
+
});
714
+
}
715
+
716
+
let parsed = K256Signature::from_slice(signature).map_err(|_| {
717
+
AttestationError::SignatureLengthInvalid {
718
+
expected: 64,
719
+
actual: signature.len(),
720
+
}
721
+
})?;
722
+
723
+
if bool::from(parsed.s().is_high()) {
724
+
return Err(AttestationError::SignatureNotNormalized);
725
+
}
726
+
}
727
+
other => {
728
+
return Err(AttestationError::UnsupportedKeyType {
729
+
key_type: other.clone(),
730
+
});
731
+
}
732
+
}
733
+
734
+
Ok(())
735
+
}
736
+
737
+
fn extract_signatures_array(record: &Value) -> Result<&Vec<Value>, AttestationError> {
738
+
let signatures = record.get("signatures");
739
+
740
+
match signatures {
741
+
Some(value) => value
742
+
.as_array()
743
+
.ok_or(AttestationError::SignaturesFieldInvalid),
744
+
None => Err(AttestationError::SignaturesArrayMissing),
745
+
}
746
+
}
747
+
748
+
fn extract_signatures_vec(record: &mut Map<String, Value>) -> Result<Vec<Value>, AttestationError> {
749
+
let existing = record.remove("signatures");
750
+
751
+
match existing {
752
+
Some(Value::Array(array)) => Ok(array),
753
+
Some(_) => Err(AttestationError::SignaturesFieldInvalid),
754
+
None => Ok(Vec::new()),
755
+
}
756
+
}
757
+
758
+
#[cfg(test)]
759
+
mod tests {
760
+
use super::*;
761
+
use atproto_identity::key::{IdentityDocumentKeyResolver, KeyType, generate_key, to_public};
762
+
use atproto_identity::model::{Document, DocumentBuilder, VerificationMethod};
763
+
use atproto_identity::resolve::IdentityResolver;
764
+
use serde_json::json;
765
+
use std::sync::Arc;
766
+
767
+
struct StaticResolver {
768
+
document: Document,
769
+
}
770
+
771
+
#[async_trait::async_trait]
772
+
impl IdentityResolver for StaticResolver {
773
+
async fn resolve(&self, _subject: &str) -> anyhow::Result<Document> {
774
+
Ok(self.document.clone())
775
+
}
776
+
}
777
+
778
+
#[test]
779
+
fn prepare_signing_record_removes_signatures() -> Result<(), AttestationError> {
780
+
let record = json!({
781
+
"$type": "app.bsky.feed.post",
782
+
"text": "hello",
783
+
"signatures": [
784
+
{"$type": "example.sig", "signature": {"$bytes": "dGVzdA=="}, "key": "did:key:zabc"}
785
+
]
786
+
});
787
+
788
+
let metadata = json!({
789
+
"$type": "com.example.inlineSignature",
790
+
"key": "did:key:zabc",
791
+
"purpose": "demo",
792
+
"signature": {"$bytes": "trim"},
793
+
"cid": "bafyignored"
794
+
});
795
+
796
+
let prepared = prepare_signing_record(&record, &metadata)?;
797
+
let object = prepared.as_object().unwrap();
798
+
assert!(object.get("signatures").is_none());
799
+
assert!(object.get("sigs").is_none());
800
+
assert!(object.get("$sig").is_some());
801
+
802
+
let sig_object = object.get("$sig").unwrap().as_object().unwrap();
803
+
assert_eq!(
804
+
sig_object.get("$type").and_then(Value::as_str),
805
+
Some("com.example.inlineSignature")
806
+
);
807
+
assert_eq!(
808
+
sig_object.get("purpose").and_then(Value::as_str),
809
+
Some("demo")
810
+
);
811
+
assert!(sig_object.get("signature").is_none());
812
+
assert!(sig_object.get("cid").is_none());
813
+
814
+
Ok(())
815
+
}
816
+
817
+
#[test]
818
+
fn create_cid_produces_expected_codec_and_length() -> Result<(), AttestationError> {
819
+
let prepared = json!({
820
+
"$type": "app.example.record",
821
+
"text": "cid demo",
822
+
"$sig": {
823
+
"$type": "com.example.inlineSignature",
824
+
"key": "did:key:zabc"
825
+
}
826
+
});
827
+
828
+
let cid = create_cid(&prepared)?;
829
+
assert_eq!(cid.codec(), 0x71);
830
+
assert_eq!(cid.hash().code(), 0x12);
831
+
assert_eq!(cid.hash().digest().len(), 32);
832
+
assert_eq!(cid.to_bytes().len(), 36);
833
+
834
+
Ok(())
835
+
}
836
+
837
+
#[test]
838
+
fn create_inline_attestation_appends_signature() -> Result<(), AttestationError> {
839
+
let record = json!({
840
+
"$type": "app.example.record",
841
+
"body": "Important content"
842
+
});
843
+
844
+
let inline = json!({
845
+
"$type": "com.example.inlineSignature",
846
+
"key": "did:key:zabc",
847
+
"signature": {"$bytes": "ZHVtbXk="}
848
+
});
849
+
850
+
let updated = create_inline_attestation_reference(&record, &inline)?;
851
+
let signatures = updated
852
+
.get("signatures")
853
+
.and_then(Value::as_array)
854
+
.expect("signatures array should exist");
855
+
assert_eq!(signatures.len(), 1);
856
+
assert_eq!(
857
+
signatures[0].get("$type").and_then(Value::as_str),
858
+
Some("com.example.inlineSignature")
859
+
);
860
+
861
+
Ok(())
862
+
}
863
+
864
+
#[test]
865
+
fn create_remote_attestation_produces_reference_and_proof()
866
+
-> Result<(), Box<dyn std::error::Error>> {
867
+
let record = json!({
868
+
"$type": "app.example.record",
869
+
"body": "remote attestation"
870
+
});
871
+
872
+
let metadata = json!({
873
+
"$type": "com.example.inlineSignature"
874
+
});
875
+
876
+
let proof_record = create_remote_attestation(&record, &metadata)?;
877
+
878
+
let proof_object = proof_record
879
+
.as_object()
880
+
.expect("reference should be an object");
881
+
assert_eq!(
882
+
proof_object.get("$type").and_then(Value::as_str),
883
+
Some("com.example.inlineSignature")
884
+
);
885
+
assert!(
886
+
proof_object.get("cid").and_then(Value::as_str).is_some(),
887
+
"proof must contain a cid"
888
+
);
889
+
890
+
Ok(())
891
+
}
892
+
893
+
#[tokio::test]
894
+
async fn verify_inline_signature_with_did_key() -> Result<(), Box<dyn std::error::Error>> {
895
+
let private_key = generate_key(KeyType::K256Private)?;
896
+
let public_key = to_public(&private_key)?;
897
+
let key_reference = format!("{}", &public_key);
898
+
899
+
let base_record = json!({
900
+
"$type": "app.example.record",
901
+
"body": "Sign me"
902
+
});
903
+
904
+
let sig_metadata = json!({
905
+
"$type": "com.example.inlineSignature",
906
+
"key": key_reference,
907
+
"purpose": "unit-test"
908
+
});
909
+
910
+
let signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?;
911
+
912
+
let report = verify_signature(&signed, 0, None).await?;
913
+
match report.status {
914
+
VerificationStatus::Valid { .. } => {}
915
+
other => panic!("expected valid signature, got {:?}", other),
916
+
}
917
+
918
+
Ok(())
919
+
}
920
+
921
+
#[tokio::test]
922
+
async fn verify_inline_signature_with_resolver() -> Result<(), Box<dyn std::error::Error>> {
923
+
let private_key = generate_key(KeyType::P256Private)?;
924
+
let public_key = to_public(&private_key)?;
925
+
let key_multibase = format!("{}", &public_key);
926
+
let key_reference = "did:plc:resolvertest#atproto".to_string();
927
+
928
+
let document = DocumentBuilder::new()
929
+
.id("did:plc:resolvertest")
930
+
.add_verification_method(VerificationMethod::Multikey {
931
+
id: key_reference.clone(),
932
+
controller: "did:plc:resolvertest".to_string(),
933
+
public_key_multibase: key_multibase
934
+
.strip_prefix("did:key:")
935
+
.unwrap_or(&key_multibase)
936
+
.to_string(),
937
+
extra: std::collections::HashMap::new(),
938
+
})
939
+
.build()
940
+
.unwrap();
941
+
942
+
let identity_resolver = Arc::new(StaticResolver { document });
943
+
let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver.clone());
944
+
945
+
let base_record = json!({
946
+
"$type": "app.example.record",
947
+
"body": "resolver test"
948
+
});
949
+
950
+
let sig_metadata = json!({
951
+
"$type": "com.example.inlineSignature",
952
+
"key": key_reference,
953
+
"scope": "resolver"
954
+
});
955
+
956
+
let signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?;
957
+
958
+
let report = verify_signature(&signed, 0, Some(&key_resolver)).await?;
959
+
match report.status {
960
+
VerificationStatus::Valid { .. } => {}
961
+
other => panic!("expected valid signature, got {:?}", other),
962
+
}
963
+
964
+
Ok(())
965
+
}
966
+
967
+
#[tokio::test]
968
+
async fn verify_all_signatures_reports_remote() -> Result<(), Box<dyn std::error::Error>> {
969
+
let record = json!({
970
+
"$type": "app.example.record",
971
+
"signatures": [
972
+
{
973
+
"$type": STRONG_REF_TYPE,
974
+
"cid": "bafyreid473y2gjzvzgjwdj3vpbk2bdzodf5hvbgxncjc62xmy3zsmb3pxq",
975
+
"uri": "at://did:plc:example/com.example.attestation/abc123"
976
+
}
977
+
]
978
+
});
979
+
980
+
let reports = verify_all_signatures(&record, None).await?;
981
+
assert_eq!(reports.len(), 1);
982
+
match &reports[0].status {
983
+
VerificationStatus::Unverified { reason } => {
984
+
assert!(reason.contains("Remote attestations"));
985
+
}
986
+
other => panic!("expected unverified status, got {:?}", other),
987
+
}
988
+
989
+
Ok(())
990
+
}
991
+
992
+
#[tokio::test]
993
+
async fn verify_detects_tampering() -> Result<(), Box<dyn std::error::Error>> {
994
+
let private_key = generate_key(KeyType::K256Private)?;
995
+
let public_key = to_public(&private_key)?;
996
+
let key_reference = format!("{}", &public_key);
997
+
998
+
let base_record = json!({
999
+
"$type": "app.example.record",
1000
+
"body": "original"
1001
+
});
1002
+
1003
+
let sig_metadata = json!({
1004
+
"$type": "com.example.inlineSignature",
1005
+
"key": key_reference
1006
+
});
1007
+
1008
+
let mut signed = create_inline_attestation(&base_record, &sig_metadata, &private_key)?;
1009
+
if let Some(object) = signed.as_object_mut() {
1010
+
object.insert("body".to_string(), json!("tampered"));
1011
+
}
1012
+
1013
+
let report = verify_signature(&signed, 0, None).await?;
1014
+
match report.status {
1015
+
VerificationStatus::Invalid { .. } => {}
1016
+
other => panic!("expected invalid signature, got {:?}", other),
1017
+
}
1018
+
1019
+
Ok(())
1020
+
}
1021
+
}
+2
-1
crates/atproto-client/Cargo.toml
+2
-1
crates/atproto-client/Cargo.toml
···
37
37
38
38
[dependencies]
39
39
atproto-identity.workspace = true
40
-
atproto-record.workspace = true
41
40
atproto-oauth.workspace = true
41
+
atproto-record.workspace = true
42
42
43
43
anyhow.workspace = true
44
44
reqwest-chain.workspace = true
···
50
50
tokio.workspace = true
51
51
tracing.workspace = true
52
52
urlencoding = "2.1.3"
53
+
async-trait.workspace = true
53
54
bytes = "1.10.1"
54
55
clap = { workspace = true, optional = true }
55
56
rpassword = { workspace = true, optional = true }
+7
-7
crates/atproto-client/src/com_atproto_identity.rs
+7
-7
crates/atproto-client/src/com_atproto_identity.rs
···
6
6
use std::collections::HashMap;
7
7
8
8
use anyhow::Result;
9
-
use atproto_identity::url::URLBuilder;
9
+
use atproto_identity::url::build_url;
10
10
use serde::{Deserialize, de::DeserializeOwned};
11
11
12
12
use crate::{
···
58
58
base_url: &str,
59
59
handle: String,
60
60
) -> Result<ResolveHandleResponse> {
61
-
let mut url_builder = URLBuilder::new(base_url);
62
-
url_builder.path("/xrpc/com.atproto.identity.resolveHandle");
63
-
64
-
url_builder.param("handle", &handle);
65
-
66
-
let url = url_builder.build();
61
+
let url = build_url(
62
+
base_url,
63
+
"/xrpc/com.atproto.identity.resolveHandle",
64
+
[("handle", handle.as_str())],
65
+
)?
66
+
.to_string();
67
67
68
68
match auth {
69
69
Auth::None => get_json(http_client, &url)
+48
-41
crates/atproto-client/src/com_atproto_repo.rs
+48
-41
crates/atproto-client/src/com_atproto_repo.rs
···
23
23
//! OAuth access tokens and private keys for proof generation.
24
24
25
25
use std::collections::HashMap;
26
+
use std::iter;
26
27
27
28
use anyhow::Result;
28
-
use atproto_identity::url::URLBuilder;
29
+
use atproto_identity::url::build_url;
29
30
use bytes::Bytes;
30
31
use serde::{Deserialize, Serialize, de::DeserializeOwned};
31
32
···
77
78
did: &str,
78
79
cid: &str,
79
80
) -> Result<Bytes> {
80
-
let mut url_builder = URLBuilder::new(base_url);
81
-
url_builder.path("/xrpc/com.atproto.sync.getBlob");
82
-
83
-
url_builder.param("did", did);
84
-
url_builder.param("cid", cid);
85
-
86
-
let url = url_builder.build();
81
+
let url = build_url(
82
+
base_url,
83
+
"/xrpc/com.atproto.sync.getBlob",
84
+
[("did", did), ("cid", cid)],
85
+
)?
86
+
.to_string();
87
87
88
88
get_bytes(http_client, &url).await
89
89
}
···
112
112
rkey: &str,
113
113
cid: Option<&str>,
114
114
) -> Result<GetRecordResponse> {
115
-
let mut url_builder = URLBuilder::new(base_url);
116
-
url_builder.path("/xrpc/com.atproto.repo.getRecord");
117
-
118
-
url_builder.param("repo", repo);
119
-
url_builder.param("collection", collection);
120
-
url_builder.param("rkey", rkey);
121
-
115
+
let mut params = vec![("repo", repo), ("collection", collection), ("rkey", rkey)];
122
116
if let Some(cid) = cid {
123
-
url_builder.param("cid", cid);
117
+
params.push(("cid", cid));
124
118
}
125
119
126
-
let url = url_builder.build();
120
+
let url = build_url(base_url, "/xrpc/com.atproto.repo.getRecord", params)?.to_string();
127
121
128
122
match auth {
129
123
Auth::None => get_json(http_client, &url)
···
218
212
collection: String,
219
213
params: ListRecordsParams,
220
214
) -> Result<ListRecordsResponse<T>> {
221
-
let mut url_builder = URLBuilder::new(base_url);
222
-
url_builder.path("/xrpc/com.atproto.repo.listRecords");
215
+
let mut url = build_url(
216
+
base_url,
217
+
"/xrpc/com.atproto.repo.listRecords",
218
+
iter::empty::<(&str, &str)>(),
219
+
)?;
220
+
{
221
+
let mut pairs = url.query_pairs_mut();
222
+
pairs.append_pair("repo", &repo);
223
+
pairs.append_pair("collection", &collection);
223
224
224
-
// Add query parameters
225
-
url_builder.param("repo", &repo);
226
-
url_builder.param("collection", &collection);
225
+
if let Some(limit) = params.limit {
226
+
pairs.append_pair("limit", &limit.to_string());
227
+
}
227
228
228
-
if let Some(limit) = params.limit {
229
-
url_builder.param("limit", &limit.to_string());
230
-
}
229
+
if let Some(cursor) = params.cursor {
230
+
pairs.append_pair("cursor", &cursor);
231
+
}
231
232
232
-
if let Some(cursor) = params.cursor {
233
-
url_builder.param("cursor", &cursor);
233
+
if let Some(reverse) = params.reverse {
234
+
pairs.append_pair("reverse", &reverse.to_string());
235
+
}
234
236
}
235
237
236
-
if let Some(reverse) = params.reverse {
237
-
url_builder.param("reverse", &reverse.to_string());
238
-
}
239
-
240
-
let url = url_builder.build();
238
+
let url = url.to_string();
241
239
242
240
match auth {
243
241
Auth::None => get_json(http_client, &url)
···
319
317
base_url: &str,
320
318
record: CreateRecordRequest<T>,
321
319
) -> Result<CreateRecordResponse> {
322
-
let mut url_builder = URLBuilder::new(base_url);
323
-
url_builder.path("/xrpc/com.atproto.repo.createRecord");
324
-
let url = url_builder.build();
320
+
let url = build_url(
321
+
base_url,
322
+
"/xrpc/com.atproto.repo.createRecord",
323
+
iter::empty::<(&str, &str)>(),
324
+
)?
325
+
.to_string();
325
326
326
327
let value = serde_json::to_value(record)?;
327
328
···
413
414
base_url: &str,
414
415
record: PutRecordRequest<T>,
415
416
) -> Result<PutRecordResponse> {
416
-
let mut url_builder = URLBuilder::new(base_url);
417
-
url_builder.path("/xrpc/com.atproto.repo.putRecord");
418
-
let url = url_builder.build();
417
+
let url = build_url(
418
+
base_url,
419
+
"/xrpc/com.atproto.repo.putRecord",
420
+
iter::empty::<(&str, &str)>(),
421
+
)?
422
+
.to_string();
419
423
420
424
let value = serde_json::to_value(record)?;
421
425
···
496
500
base_url: &str,
497
501
record: DeleteRecordRequest,
498
502
) -> Result<DeleteRecordResponse> {
499
-
let mut url_builder = URLBuilder::new(base_url);
500
-
url_builder.path("/xrpc/com.atproto.repo.deleteRecord");
501
-
let url = url_builder.build();
503
+
let url = build_url(
504
+
base_url,
505
+
"/xrpc/com.atproto.repo.deleteRecord",
506
+
iter::empty::<(&str, &str)>(),
507
+
)?
508
+
.to_string();
502
509
503
510
let value = serde_json::to_value(record)?;
504
511
+26
-13
crates/atproto-client/src/com_atproto_server.rs
+26
-13
crates/atproto-client/src/com_atproto_server.rs
···
19
19
//! an access JWT token from an authenticated session.
20
20
21
21
use anyhow::Result;
22
-
use atproto_identity::url::URLBuilder;
22
+
use atproto_identity::url::build_url;
23
23
use serde::{Deserialize, Serialize};
24
+
use std::iter;
24
25
25
26
use crate::{
26
27
client::{Auth, post_json},
···
118
119
password: &str,
119
120
auth_factor_token: Option<&str>,
120
121
) -> Result<AppPasswordSession> {
121
-
let mut url_builder = URLBuilder::new(base_url);
122
-
url_builder.path("/xrpc/com.atproto.server.createSession");
123
-
let url = url_builder.build();
122
+
let url = build_url(
123
+
base_url,
124
+
"/xrpc/com.atproto.server.createSession",
125
+
iter::empty::<(&str, &str)>(),
126
+
)?
127
+
.to_string();
124
128
125
129
let request = CreateSessionRequest {
126
130
identifier: identifier.to_string(),
···
156
160
base_url: &str,
157
161
refresh_token: &str,
158
162
) -> Result<RefreshSessionResponse> {
159
-
let mut url_builder = URLBuilder::new(base_url);
160
-
url_builder.path("/xrpc/com.atproto.server.refreshSession");
161
-
let url = url_builder.build();
163
+
let url = build_url(
164
+
base_url,
165
+
"/xrpc/com.atproto.server.refreshSession",
166
+
iter::empty::<(&str, &str)>(),
167
+
)?
168
+
.to_string();
162
169
163
170
// Create a new client with the refresh token in Authorization header
164
171
let mut headers = reqwest::header::HeaderMap::new();
···
197
204
access_token: &str,
198
205
name: &str,
199
206
) -> Result<AppPasswordResponse> {
200
-
let mut url_builder = URLBuilder::new(base_url);
201
-
url_builder.path("/xrpc/com.atproto.server.createAppPassword");
202
-
let url = url_builder.build();
207
+
let url = build_url(
208
+
base_url,
209
+
"/xrpc/com.atproto.server.createAppPassword",
210
+
iter::empty::<(&str, &str)>(),
211
+
)?
212
+
.to_string();
203
213
204
214
let request_body = serde_json::json!({
205
215
"name": name
···
260
270
}
261
271
};
262
272
263
-
let mut url_builder = URLBuilder::new(base_url);
264
-
url_builder.path("/xrpc/com.atproto.server.deleteSession");
265
-
let url = url_builder.build();
273
+
let url = build_url(
274
+
base_url,
275
+
"/xrpc/com.atproto.server.deleteSession",
276
+
iter::empty::<(&str, &str)>(),
277
+
)?
278
+
.to_string();
266
279
267
280
// Create headers with the Bearer token
268
281
let mut headers = reqwest::header::HeaderMap::new();
+3
crates/atproto-client/src/lib.rs
+3
crates/atproto-client/src/lib.rs
+77
crates/atproto-client/src/record_resolver.rs
+77
crates/atproto-client/src/record_resolver.rs
···
1
+
//! Helpers for resolving AT Protocol records referenced by URI.
2
+
3
+
use std::str::FromStr;
4
+
5
+
use anyhow::{Result, anyhow, bail};
6
+
use async_trait::async_trait;
7
+
use atproto_record::aturi::ATURI;
8
+
9
+
use crate::{
10
+
client::Auth,
11
+
com::atproto::repo::{GetRecordResponse, get_record},
12
+
};
13
+
14
+
/// Trait for resolving AT Protocol records by `at://` URI.
15
+
///
16
+
/// Implementations perform the network lookup and deserialize the response into
17
+
/// the requested type.
18
+
#[async_trait]
19
+
pub trait RecordResolver: Send + Sync {
20
+
/// Resolve an AT URI to a typed record.
21
+
async fn resolve<T>(&self, aturi: &str) -> Result<T>
22
+
where
23
+
T: serde::de::DeserializeOwned + Send;
24
+
}
25
+
26
+
/// Resolver that fetches records using public XRPC endpoints.
27
+
#[derive(Clone)]
28
+
pub struct HttpRecordResolver {
29
+
http_client: reqwest::Client,
30
+
base_url: String,
31
+
}
32
+
33
+
impl HttpRecordResolver {
34
+
/// Create a new resolver using the provided HTTP client and PDS base URL.
35
+
pub fn new(http_client: reqwest::Client, base_url: impl Into<String>) -> Self {
36
+
Self {
37
+
http_client,
38
+
base_url: base_url.into(),
39
+
}
40
+
}
41
+
}
42
+
43
+
#[async_trait]
44
+
impl RecordResolver for HttpRecordResolver {
45
+
async fn resolve<T>(&self, aturi: &str) -> Result<T>
46
+
where
47
+
T: serde::de::DeserializeOwned + Send,
48
+
{
49
+
let parsed = ATURI::from_str(aturi).map_err(|error| anyhow!(error))?;
50
+
let auth = Auth::None;
51
+
52
+
let response = get_record(
53
+
&self.http_client,
54
+
&auth,
55
+
&self.base_url,
56
+
&parsed.authority,
57
+
&parsed.collection,
58
+
&parsed.record_key,
59
+
None,
60
+
)
61
+
.await?;
62
+
63
+
match response {
64
+
GetRecordResponse::Record { value, .. } => {
65
+
serde_json::from_value(value).map_err(|error| anyhow!(error))
66
+
}
67
+
GetRecordResponse::Error(error) => {
68
+
let message = error.error_message();
69
+
if message.is_empty() {
70
+
bail!("Record resolution failed without additional error details");
71
+
}
72
+
73
+
bail!(message);
74
+
}
75
+
}
76
+
}
77
+
}
+1
crates/atproto-identity/Cargo.toml
+1
crates/atproto-identity/Cargo.toml
+70
-21
crates/atproto-identity/src/key.rs
+70
-21
crates/atproto-identity/src/key.rs
···
47
47
//! }
48
48
//! ```
49
49
50
-
use anyhow::Result;
50
+
use anyhow::{Context, Result, anyhow};
51
51
use ecdsa::signature::Signer;
52
52
use elliptic_curve::JwkEcKey;
53
53
use elliptic_curve::sec1::ToEncodedPoint;
54
54
55
+
use crate::model::VerificationMethod;
56
+
use crate::traits::IdentityResolver;
57
+
58
+
pub use crate::traits::KeyResolver;
59
+
use std::sync::Arc;
60
+
55
61
use crate::errors::KeyError;
56
62
57
63
#[cfg(feature = "zeroize")]
58
64
use zeroize::{Zeroize, ZeroizeOnDrop};
59
65
60
66
/// Cryptographic key types supported for AT Protocol identity.
61
-
#[derive(Clone, PartialEq)]
62
-
#[cfg_attr(debug_assertions, derive(Debug))]
67
+
#[derive(Clone, PartialEq, Debug)]
63
68
#[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))]
64
69
pub enum KeyType {
65
70
/// A p256 (P-256 / secp256r1 / ES256) public key.
···
160
165
// Add DID key prefix
161
166
write!(f, "did:key:{}", multibase_encoded)
162
167
}
163
-
}
164
-
165
-
/// Trait for providing cryptographic keys by identifier.
166
-
///
167
-
/// This trait defines the interface for key providers that can retrieve private keys
168
-
/// by their identifier. Implementations must be thread-safe to support concurrent access.
169
-
#[async_trait::async_trait]
170
-
pub trait KeyProvider: Send + Sync {
171
-
/// Retrieves a private key by its identifier.
172
-
///
173
-
/// # Arguments
174
-
/// * `key_id` - The identifier of the key to retrieve
175
-
///
176
-
/// # Returns
177
-
/// * `Ok(Some(KeyData))` - If the key was found and successfully retrieved
178
-
/// * `Ok(None)` - If no key exists for the given identifier
179
-
/// * `Err(anyhow::Error)` - If an error occurred during key retrieval
180
-
async fn get_private_key_by_id(&self, key_id: &str) -> Result<Option<KeyData>>;
181
168
}
182
169
183
170
/// DID key method prefix.
···
362
349
.map_err(|error| KeyError::ECDSAError { error })?;
363
350
Ok(signature.to_vec())
364
351
}
352
+
}
353
+
}
354
+
355
+
/// Key resolver implementation that fetches DID documents using an [`IdentityResolver`].
356
+
#[derive(Clone)]
357
+
pub struct IdentityDocumentKeyResolver {
358
+
identity_resolver: Arc<dyn IdentityResolver>,
359
+
}
360
+
361
+
impl IdentityDocumentKeyResolver {
362
+
/// Creates a new key resolver backed by an [`IdentityResolver`].
363
+
pub fn new(identity_resolver: Arc<dyn IdentityResolver>) -> Self {
364
+
Self { identity_resolver }
365
+
}
366
+
}
367
+
368
+
#[async_trait::async_trait]
369
+
impl KeyResolver for IdentityDocumentKeyResolver {
370
+
async fn resolve(&self, key: &str) -> Result<KeyData> {
371
+
if let Some(did_key) = key.split('#').next() {
372
+
if let Ok(key_data) = identify_key(did_key) {
373
+
return Ok(key_data);
374
+
}
375
+
} else if let Ok(key_data) = identify_key(key) {
376
+
return Ok(key_data);
377
+
}
378
+
379
+
let (did, fragment) = key
380
+
.split_once('#')
381
+
.context("Key reference must contain a DID fragment (e.g., did:example#key)")?;
382
+
383
+
if did.is_empty() || fragment.is_empty() {
384
+
return Err(anyhow!(
385
+
"Key reference must include both DID and fragment (received `{key}`)"
386
+
));
387
+
}
388
+
389
+
let document = self.identity_resolver.resolve(did).await?;
390
+
let fragment_with_hash = format!("#{fragment}");
391
+
392
+
let public_key_multibase = document
393
+
.verification_method
394
+
.iter()
395
+
.find_map(|method| match method {
396
+
VerificationMethod::Multikey {
397
+
id,
398
+
public_key_multibase,
399
+
..
400
+
} if id == key || *id == fragment_with_hash => Some(public_key_multibase.clone()),
401
+
_ => None,
402
+
})
403
+
.context(format!(
404
+
"Verification method `{key}` not found in DID document `{did}`"
405
+
))?;
406
+
407
+
let full_key = if public_key_multibase.starts_with("did:key:") {
408
+
public_key_multibase
409
+
} else {
410
+
format!("did:key:{}", public_key_multibase)
411
+
};
412
+
413
+
identify_key(&full_key).context("Failed to parse key data from verification method")
365
414
}
366
415
}
367
416
+1
-1
crates/atproto-identity/src/lib.rs
+1
-1
crates/atproto-identity/src/lib.rs
+95
-29
crates/atproto-identity/src/resolve.rs
+95
-29
crates/atproto-identity/src/resolve.rs
···
32
32
use crate::validation::{is_valid_did_method_plc, is_valid_handle};
33
33
use crate::web::query as web_query;
34
34
35
-
/// Trait for AT Protocol identity resolution.
36
-
///
37
-
/// Implementations must be thread-safe (Send + Sync) and usable in async environments.
38
-
/// This trait provides the core functionality for resolving AT Protocol subjects
39
-
/// (handles or DIDs) to their corresponding DID documents.
40
-
#[async_trait::async_trait]
41
-
pub trait IdentityResolver: Send + Sync {
42
-
/// Resolves an AT Protocol subject to its DID document.
43
-
///
44
-
/// Takes a handle or DID, resolves it to a canonical DID, then retrieves
45
-
/// the corresponding DID document from the appropriate source (PLC directory or web).
46
-
///
47
-
/// # Arguments
48
-
/// * `subject` - The AT Protocol handle or DID to resolve
49
-
///
50
-
/// # Returns
51
-
/// * `Ok(Document)` - The resolved DID document
52
-
/// * `Err(anyhow::Error)` - Resolution error with detailed context
53
-
async fn resolve(&self, subject: &str) -> Result<Document>;
54
-
}
55
-
56
-
/// Trait for DNS resolution operations.
57
-
/// Provides async DNS TXT record lookups for handle resolution.
58
-
#[async_trait::async_trait]
59
-
pub trait DnsResolver: Send + Sync {
60
-
/// Resolves TXT records for a given domain name.
61
-
/// Returns a vector of strings representing the TXT record values.
62
-
async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>;
63
-
}
35
+
pub use crate::traits::{DnsResolver, IdentityResolver};
64
36
65
37
/// Hickory DNS implementation of the DnsResolver trait.
66
38
/// Wraps hickory_resolver::TokioResolver for TXT record resolution.
···
196
168
is_valid_handle(trimmed)
197
169
.map(InputType::Handle)
198
170
.ok_or(ResolveError::InvalidInput)
171
+
}
172
+
}
173
+
174
+
#[cfg(test)]
175
+
mod tests {
176
+
use super::*;
177
+
use crate::key::{
178
+
IdentityDocumentKeyResolver, KeyResolver, KeyType, generate_key, identify_key, to_public,
179
+
};
180
+
use crate::model::{DocumentBuilder, VerificationMethod};
181
+
use std::collections::HashMap;
182
+
183
+
struct StubIdentityResolver {
184
+
expected: String,
185
+
document: Document,
186
+
}
187
+
188
+
#[async_trait::async_trait]
189
+
impl IdentityResolver for StubIdentityResolver {
190
+
async fn resolve(&self, subject: &str) -> Result<Document> {
191
+
if !self.expected.is_empty() {
192
+
assert_eq!(self.expected, subject);
193
+
}
194
+
Ok(self.document.clone())
195
+
}
196
+
}
197
+
198
+
#[tokio::test]
199
+
async fn resolves_direct_did_key() -> Result<()> {
200
+
let private_key = generate_key(KeyType::K256Private)?;
201
+
let public_key = to_public(&private_key)?;
202
+
let key_reference = format!("{}", &public_key);
203
+
204
+
let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver {
205
+
expected: String::new(),
206
+
document: Document::builder()
207
+
.id("did:plc:placeholder")
208
+
.build()
209
+
.unwrap(),
210
+
}));
211
+
212
+
let key_data = resolver.resolve(&key_reference).await?;
213
+
assert_eq!(key_data.bytes(), public_key.bytes());
214
+
Ok(())
215
+
}
216
+
217
+
#[tokio::test]
218
+
async fn resolves_literal_did_key_reference() -> Result<()> {
219
+
let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver {
220
+
expected: String::new(),
221
+
document: Document::builder()
222
+
.id("did:example:unused".to_string())
223
+
.build()
224
+
.unwrap(),
225
+
}));
226
+
227
+
let sample = "did:key:zDnaezRmyM3NKx9NCphGiDFNBEMyR2sTZhhMGTseXCU2iXn53";
228
+
let expected = identify_key(sample)?;
229
+
let resolved = resolver.resolve(sample).await?;
230
+
assert_eq!(resolved.bytes(), expected.bytes());
231
+
Ok(())
232
+
}
233
+
234
+
#[tokio::test]
235
+
async fn resolves_via_identity_document() -> Result<()> {
236
+
let private_key = generate_key(KeyType::P256Private)?;
237
+
let public_key = to_public(&private_key)?;
238
+
let public_key_multibase = format!("{}", &public_key)
239
+
.strip_prefix("did:key:")
240
+
.unwrap()
241
+
.to_string();
242
+
243
+
let did = "did:web:example.com";
244
+
let method_id = format!("{did}#atproto");
245
+
246
+
let document = DocumentBuilder::new()
247
+
.id(did.to_string())
248
+
.add_verification_method(VerificationMethod::Multikey {
249
+
id: method_id.clone(),
250
+
controller: did.to_string(),
251
+
public_key_multibase,
252
+
extra: HashMap::new(),
253
+
})
254
+
.build()
255
+
.unwrap();
256
+
257
+
let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver {
258
+
expected: did.to_string(),
259
+
document,
260
+
}));
261
+
262
+
let key_data = resolver.resolve(&method_id).await?;
263
+
assert_eq!(key_data.bytes(), public_key.bytes());
264
+
Ok(())
199
265
}
200
266
}
201
267
-212
crates/atproto-identity/src/storage.rs
-212
crates/atproto-identity/src/storage.rs
···
1
-
//! DID document storage abstraction.
2
-
//!
3
-
//! Storage trait for DID document CRUD operations supporting multiple
4
-
//! backends (database, file system, memory) with consistent interface.
5
-
6
-
use anyhow::Result;
7
-
8
-
use crate::model::Document;
9
-
10
-
/// Trait for implementing DID document CRUD operations across different storage backends.
11
-
///
12
-
/// This trait provides an abstraction layer for storing and retrieving DID documents,
13
-
/// allowing different implementations for various storage systems such as databases, file systems,
14
-
/// in-memory stores, or cloud storage services.
15
-
///
16
-
/// All methods return `anyhow::Result` to allow implementations to use their own error types
17
-
/// while providing a consistent interface for callers. Implementations should handle their
18
-
/// specific error conditions and convert them to appropriate error messages.
19
-
///
20
-
/// ## Thread Safety
21
-
///
22
-
/// This trait requires implementations to be thread-safe (`Send + Sync`), meaning:
23
-
/// - `Send`: The storage implementation can be moved between threads
24
-
/// - `Sync`: The storage implementation can be safely accessed from multiple threads simultaneously
25
-
///
26
-
/// This is essential for async applications where the storage might be accessed from different
27
-
/// async tasks running on different threads. Implementations should use appropriate
28
-
/// synchronization primitives (like `Arc<Mutex<>>`, `RwLock`, or database connection pools)
29
-
/// to ensure thread safety.
30
-
///
31
-
/// ## Usage
32
-
///
33
-
/// Implementors of this trait can provide storage for AT Protocol DID documents in any backend:
34
-
///
35
-
/// ```rust,ignore
36
-
/// use atproto_identity::storage::DidDocumentStorage;
37
-
/// use atproto_identity::model::Document;
38
-
/// use anyhow::Result;
39
-
/// use std::sync::Arc;
40
-
/// use tokio::sync::RwLock;
41
-
/// use std::collections::HashMap;
42
-
///
43
-
/// // Thread-safe in-memory storage using Arc<RwLock<>>
44
-
/// #[derive(Clone)]
45
-
/// struct InMemoryStorage {
46
-
/// data: Arc<RwLock<HashMap<String, Document>>>, // DID -> Document mapping
47
-
/// }
48
-
///
49
-
/// #[async_trait::async_trait]
50
-
/// impl DidDocumentStorage for InMemoryStorage {
51
-
/// async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> {
52
-
/// let data = self.data.read().await;
53
-
/// Ok(data.get(did).cloned())
54
-
/// }
55
-
///
56
-
/// async fn store_document(&self, document: Document) -> Result<()> {
57
-
/// let mut data = self.data.write().await;
58
-
/// data.insert(document.id.clone(), document);
59
-
/// Ok(())
60
-
/// }
61
-
///
62
-
/// async fn delete_document_by_did(&self, did: &str) -> Result<()> {
63
-
/// let mut data = self.data.write().await;
64
-
/// data.remove(did);
65
-
/// Ok(())
66
-
/// }
67
-
/// }
68
-
///
69
-
/// // Database storage with thread-safe connection pool
70
-
/// struct DatabaseStorage {
71
-
/// pool: sqlx::Pool<sqlx::Postgres>, // Thread-safe connection pool
72
-
/// }
73
-
///
74
-
/// #[async_trait::async_trait]
75
-
/// impl DidDocumentStorage for DatabaseStorage {
76
-
/// async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> {
77
-
/// // Database connection pools are thread-safe
78
-
/// let row: Option<(serde_json::Value,)> = sqlx::query_as(
79
-
/// "SELECT document FROM did_documents WHERE did = $1"
80
-
/// )
81
-
/// .bind(did)
82
-
/// .fetch_optional(&self.pool)
83
-
/// .await?;
84
-
///
85
-
/// if let Some((doc_json,)) = row {
86
-
/// let document: Document = serde_json::from_value(doc_json)?;
87
-
/// Ok(Some(document))
88
-
/// } else {
89
-
/// Ok(None)
90
-
/// }
91
-
/// }
92
-
///
93
-
/// async fn store_document(&self, document: Document) -> Result<()> {
94
-
/// let doc_json = serde_json::to_value(&document)?;
95
-
/// sqlx::query("INSERT INTO did_documents (did, document) VALUES ($1, $2) ON CONFLICT (did) DO UPDATE SET document = $2")
96
-
/// .bind(&document.id)
97
-
/// .bind(doc_json)
98
-
/// .execute(&self.pool)
99
-
/// .await?;
100
-
/// Ok(())
101
-
/// }
102
-
///
103
-
/// async fn delete_document_by_did(&self, did: &str) -> Result<()> {
104
-
/// sqlx::query("DELETE FROM did_documents WHERE did = $1")
105
-
/// .bind(did)
106
-
/// .execute(&self.pool)
107
-
/// .await?;
108
-
/// Ok(())
109
-
/// }
110
-
/// }
111
-
/// ```
112
-
#[async_trait::async_trait]
113
-
pub trait DidDocumentStorage: Send + Sync {
114
-
/// Retrieves a DID document associated with the given DID.
115
-
///
116
-
/// This method looks up the complete DID document that is currently stored for the provided
117
-
/// DID (Decentralized Identifier). The document contains services, verification methods,
118
-
/// and other identity information for the DID.
119
-
///
120
-
/// # Arguments
121
-
/// * `did` - The DID (Decentralized Identifier) to look up. Should be in the format
122
-
/// `did:method:identifier` (e.g., "did:plc:bv6ggog3tya2z3vxsub7hnal")
123
-
///
124
-
/// # Returns
125
-
/// * `Ok(Some(document))` - If a document is found for the given DID
126
-
/// * `Ok(None)` - If no document is currently stored for the DID
127
-
/// * `Err(error)` - If an error occurs during retrieval (storage failure, invalid DID format, etc.)
128
-
///
129
-
/// # Examples
130
-
///
131
-
/// ```rust,ignore
132
-
/// let storage = MyStorage::new();
133
-
/// let document = storage.get_document_by_did("did:plc:bv6ggog3tya2z3vxsub7hnal").await?;
134
-
/// match document {
135
-
/// Some(doc) => {
136
-
/// println!("Found document for DID: {}", doc.id);
137
-
/// if let Some(handle) = doc.handles() {
138
-
/// println!("Primary handle: {}", handle);
139
-
/// }
140
-
/// },
141
-
/// None => println!("No document found for this DID"),
142
-
/// }
143
-
/// ```
144
-
async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>>;
145
-
146
-
/// Stores or updates a DID document.
147
-
///
148
-
/// This method creates a new DID document entry or updates an existing one.
149
-
/// In the AT Protocol ecosystem, this operation typically occurs when a DID document
150
-
/// is resolved from the network, updated by the identity owner, or cached for performance.
151
-
///
152
-
/// Implementations should ensure that:
153
-
/// - The document's DID (`document.id`) is used as the key for storage
154
-
/// - The operation is atomic (either fully succeeds or fully fails)
155
-
/// - Any existing document for the same DID is properly replaced
156
-
/// - The complete document structure is preserved
157
-
///
158
-
/// # Arguments
159
-
/// * `document` - The complete DID document to store. The document's `id` field
160
-
/// will be used as the storage key.
161
-
///
162
-
/// # Returns
163
-
/// * `Ok(())` - If the document was successfully stored or updated
164
-
/// * `Err(error)` - If an error occurs during the operation (storage failure,
165
-
/// serialization failure, constraint violation, etc.)
166
-
///
167
-
/// # Examples
168
-
///
169
-
/// ```rust,ignore
170
-
/// let storage = MyStorage::new();
171
-
/// let document = Document {
172
-
/// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(),
173
-
/// also_known_as: vec!["at://alice.bsky.social".to_string()],
174
-
/// service: vec![/* services */],
175
-
/// verification_method: vec![/* verification methods */],
176
-
/// extra: HashMap::new(),
177
-
/// };
178
-
/// storage.store_document(document).await?;
179
-
/// println!("Document successfully stored");
180
-
/// ```
181
-
async fn store_document(&self, document: Document) -> Result<()>;
182
-
183
-
/// Deletes a DID document by its DID.
184
-
///
185
-
/// This method removes a DID document from storage using the DID as the identifier.
186
-
/// This operation is typically used when cleaning up expired cache entries, removing
187
-
/// invalid documents, or when an identity is deactivated.
188
-
///
189
-
/// Implementations should:
190
-
/// - Handle the case where the DID doesn't exist gracefully (return Ok(()))
191
-
/// - Ensure the deletion is atomic
192
-
/// - Clean up any related data or indexes
193
-
/// - Preserve referential integrity if applicable
194
-
///
195
-
/// # Arguments
196
-
/// * `did` - The DID identifying the document to delete.
197
-
/// Should be in the format `did:method:identifier`
198
-
/// (e.g., "did:plc:bv6ggog3tya2z3vxsub7hnal")
199
-
///
200
-
/// # Returns
201
-
/// * `Ok(())` - If the document was successfully deleted or didn't exist
202
-
/// * `Err(error)` - If an error occurs during deletion (storage failure, etc.)
203
-
///
204
-
/// # Examples
205
-
///
206
-
/// ```rust,ignore
207
-
/// let storage = MyStorage::new();
208
-
/// storage.delete_document_by_did("did:plc:bv6ggog3tya2z3vxsub7hnal").await?;
209
-
/// println!("Document deleted");
210
-
/// ```
211
-
async fn delete_document_by_did(&self, did: &str) -> Result<()>;
212
-
}
+8
-7
crates/atproto-identity/src/storage_lru.rs
+8
-7
crates/atproto-identity/src/storage_lru.rs
···
11
11
12
12
use crate::errors::StorageError;
13
13
use crate::model::Document;
14
-
use crate::storage::DidDocumentStorage;
14
+
use crate::traits::DidDocumentStorage;
15
15
16
16
/// An LRU-based implementation of `DidDocumentStorage` that maintains a fixed-size cache of DID documents.
17
17
///
···
54
54
///
55
55
/// ```rust
56
56
/// use atproto_identity::storage_lru::LruDidDocumentStorage;
57
-
/// use atproto_identity::storage::DidDocumentStorage;
57
+
/// use atproto_identity::traits::DidDocumentStorage;
58
58
/// use atproto_identity::model::Document;
59
59
/// use std::num::NonZeroUsize;
60
60
/// use std::collections::HashMap;
···
164
164
///
165
165
/// ```rust
166
166
/// use atproto_identity::storage_lru::LruDidDocumentStorage;
167
-
/// use atproto_identity::storage::DidDocumentStorage;
167
+
/// use atproto_identity::traits::DidDocumentStorage;
168
168
/// use atproto_identity::model::Document;
169
169
/// use std::num::NonZeroUsize;
170
170
/// use std::collections::HashMap;
···
251
251
///
252
252
/// ```rust
253
253
/// use atproto_identity::storage_lru::LruDidDocumentStorage;
254
-
/// use atproto_identity::storage::DidDocumentStorage;
254
+
/// use atproto_identity::traits::DidDocumentStorage;
255
255
/// use atproto_identity::model::Document;
256
256
/// use std::num::NonZeroUsize;
257
257
/// use std::collections::HashMap;
···
305
305
///
306
306
/// ```rust
307
307
/// use atproto_identity::storage_lru::LruDidDocumentStorage;
308
-
/// use atproto_identity::storage::DidDocumentStorage;
308
+
/// use atproto_identity::traits::DidDocumentStorage;
309
309
/// use atproto_identity::model::Document;
310
310
/// use std::num::NonZeroUsize;
311
311
/// use std::collections::HashMap;
···
370
370
///
371
371
/// ```rust
372
372
/// use atproto_identity::storage_lru::LruDidDocumentStorage;
373
-
/// use atproto_identity::storage::DidDocumentStorage;
373
+
/// use atproto_identity::traits::DidDocumentStorage;
374
374
/// use atproto_identity::model::Document;
375
375
/// use std::num::NonZeroUsize;
376
376
/// use std::collections::HashMap;
···
460
460
///
461
461
/// ```rust
462
462
/// use atproto_identity::storage_lru::LruDidDocumentStorage;
463
-
/// use atproto_identity::storage::DidDocumentStorage;
463
+
/// use atproto_identity::traits::DidDocumentStorage;
464
464
/// use atproto_identity::model::Document;
465
465
/// use std::num::NonZeroUsize;
466
466
/// use std::collections::HashMap;
···
507
507
#[cfg(test)]
508
508
mod tests {
509
509
use super::*;
510
+
use crate::traits::DidDocumentStorage;
510
511
use std::collections::HashMap;
511
512
use std::num::NonZeroUsize;
512
513
+49
crates/atproto-identity/src/traits.rs
+49
crates/atproto-identity/src/traits.rs
···
1
+
//! Shared trait definitions for AT Protocol identity operations.
2
+
//!
3
+
//! This module centralizes async traits used across the identity crate so they can
4
+
//! be implemented without introducing circular module dependencies.
5
+
6
+
use anyhow::Result;
7
+
use async_trait::async_trait;
8
+
9
+
use crate::errors::ResolveError;
10
+
use crate::key::KeyData;
11
+
use crate::model::Document;
12
+
13
+
/// Trait for AT Protocol identity resolution.
14
+
///
15
+
/// Implementations must resolve handles or DIDs to canonical DID documents.
16
+
#[async_trait]
17
+
pub trait IdentityResolver: Send + Sync {
18
+
/// Resolves an AT Protocol subject to its DID document.
19
+
async fn resolve(&self, subject: &str) -> Result<Document>;
20
+
}
21
+
22
+
/// Trait for DNS resolution operations used during handle lookups.
23
+
#[async_trait]
24
+
pub trait DnsResolver: Send + Sync {
25
+
/// Resolves TXT records for a given domain name.
26
+
async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>;
27
+
}
28
+
29
+
/// Trait for retrieving private keys by identifier.
30
+
#[async_trait]
31
+
/// Trait for resolving key references (e.g., DID verification methods) to [`KeyData`].
32
+
#[async_trait]
33
+
pub trait KeyResolver: Send + Sync {
34
+
/// Resolves a key reference string into key material.
35
+
async fn resolve(&self, key: &str) -> Result<KeyData>;
36
+
}
37
+
38
+
/// Trait for DID document storage backends.
39
+
#[async_trait]
40
+
pub trait DidDocumentStorage: Send + Sync {
41
+
/// Retrieves a DID document if present.
42
+
async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>>;
43
+
44
+
/// Stores or updates a DID document.
45
+
async fn store_document(&self, document: Document) -> Result<()>;
46
+
47
+
/// Deletes a DID document by DID.
48
+
async fn delete_document_by_did(&self, did: &str) -> Result<()>;
49
+
}
+48
-119
crates/atproto-identity/src/url.rs
+48
-119
crates/atproto-identity/src/url.rs
···
1
-
//! URL construction utilities for HTTP endpoints.
1
+
//! URL construction utilities leveraging the `url` crate.
2
2
//!
3
-
//! Build well-formed HTTP request URLs with parameter encoding
4
-
//! and query string generation.
5
-
6
-
/// A single query parameter as a key-value pair.
7
-
pub type QueryParam<'a> = (&'a str, &'a str);
8
-
/// A collection of query parameters.
9
-
pub type QueryParams<'a> = Vec<QueryParam<'a>>;
10
-
11
-
/// Builds a query string from a collection of query parameters.
12
-
///
13
-
/// # Arguments
14
-
///
15
-
/// * `query` - Collection of key-value pairs to build into a query string
16
-
///
17
-
/// # Returns
18
-
///
19
-
/// A formatted query string with URL-encoded parameters
20
-
pub fn build_querystring(query: QueryParams) -> String {
21
-
query.iter().fold(String::new(), |acc, &tuple| {
22
-
acc + tuple.0 + "=" + tuple.1 + "&"
23
-
})
24
-
}
3
+
//! Provides helpers for building URLs and appending query parameters
4
+
//! without manual string concatenation.
25
5
26
-
/// Builder for constructing URLs with host, path, and query parameters.
27
-
pub struct URLBuilder {
28
-
host: String,
29
-
path: String,
30
-
params: Vec<(String, String)>,
31
-
}
6
+
use url::{ParseError, Url};
32
7
33
-
/// Convenience function to build a URL with optional parameters.
34
-
///
35
-
/// # Arguments
36
-
///
37
-
/// * `host` - The hostname (will be prefixed with https:// if needed)
38
-
/// * `path` - The URL path
39
-
/// * `params` - Vector of optional key-value pairs for query parameters
40
-
///
41
-
/// # Returns
42
-
///
43
-
/// A fully constructed URL string
44
-
pub fn build_url(host: &str, path: &str, params: Vec<Option<(&str, &str)>>) -> String {
45
-
let mut url_builder = URLBuilder::new(host);
46
-
url_builder.path(path);
8
+
/// Builds a URL from the provided components.
9
+
/// Returns `Result<Url, ParseError>` to surface parsing errors.
10
+
pub fn build_url<K, V, I>(host: &str, path: &str, params: I) -> Result<Url, ParseError>
11
+
where
12
+
I: IntoIterator<Item = (K, V)>,
13
+
K: AsRef<str>,
14
+
V: AsRef<str>,
15
+
{
16
+
let mut base = if host.starts_with("http://") || host.starts_with("https://") {
17
+
Url::parse(host)?
18
+
} else {
19
+
Url::parse(&format!("https://{}", host))?
20
+
};
47
21
48
-
for (key, value) in params.iter().filter_map(|x| *x) {
49
-
url_builder.param(key, value);
22
+
if !base.path().ends_with('/') {
23
+
let mut new_path = base.path().to_string();
24
+
if !new_path.ends_with('/') {
25
+
new_path.push('/');
26
+
}
27
+
if new_path.is_empty() {
28
+
new_path.push('/');
29
+
}
30
+
base.set_path(&new_path);
50
31
}
51
32
52
-
url_builder.build()
53
-
}
54
-
55
-
impl URLBuilder {
56
-
/// Creates a new URLBuilder with the specified host.
57
-
///
58
-
/// # Arguments
59
-
///
60
-
/// * `host` - The hostname (will be prefixed with https:// if needed and trailing slash removed)
61
-
///
62
-
/// # Returns
63
-
///
64
-
/// A new URLBuilder instance
65
-
pub fn new(host: &str) -> URLBuilder {
66
-
let host = if host.starts_with("https://") {
67
-
host.to_string()
68
-
} else {
69
-
format!("https://{}", host)
70
-
};
71
-
72
-
let host = if let Some(trimmed) = host.strip_suffix('/') {
73
-
trimmed.to_string()
74
-
} else {
75
-
host
76
-
};
77
-
78
-
URLBuilder {
79
-
host: host.to_string(),
80
-
params: vec![],
81
-
path: "/".to_string(),
33
+
let mut url = base.join(path.trim_start_matches('/'))?;
34
+
{
35
+
let mut pairs = url.query_pairs_mut();
36
+
for (key, value) in params {
37
+
pairs.append_pair(key.as_ref(), value.as_ref());
82
38
}
83
39
}
40
+
Ok(url)
41
+
}
84
42
85
-
/// Adds a query parameter to the URL.
86
-
///
87
-
/// # Arguments
88
-
///
89
-
/// * `key` - The parameter key
90
-
/// * `value` - The parameter value (will be URL-encoded)
91
-
///
92
-
/// # Returns
93
-
///
94
-
/// A mutable reference to self for method chaining
95
-
pub fn param(&mut self, key: &str, value: &str) -> &mut Self {
96
-
self.params
97
-
.push((key.to_owned(), urlencoding::encode(value).to_string()));
98
-
self
99
-
}
43
+
#[cfg(test)]
44
+
mod tests {
45
+
use super::*;
100
46
101
-
/// Sets the URL path.
102
-
///
103
-
/// # Arguments
104
-
///
105
-
/// * `path` - The URL path
106
-
///
107
-
/// # Returns
108
-
///
109
-
/// A mutable reference to self for method chaining
110
-
pub fn path(&mut self, path: &str) -> &mut Self {
111
-
path.clone_into(&mut self.path);
112
-
self
113
-
}
47
+
#[test]
48
+
fn builds_url_with_params() {
49
+
let url = build_url(
50
+
"example.com/api",
51
+
"resource",
52
+
[("id", "123"), ("status", "active")],
53
+
)
54
+
.expect("url build failed");
114
55
115
-
/// Constructs the final URL string.
116
-
///
117
-
/// # Returns
118
-
///
119
-
/// The complete URL with host, path, and query parameters
120
-
pub fn build(self) -> String {
121
-
let mut url_params = String::new();
122
-
123
-
if !self.params.is_empty() {
124
-
url_params.push('?');
125
-
126
-
let qs_args = self.params.iter().map(|(k, v)| (&**k, &**v)).collect();
127
-
url_params.push_str(build_querystring(qs_args).as_str());
128
-
}
129
-
130
-
format!("{}{}{}", self.host, self.path, url_params)
56
+
assert_eq!(
57
+
url.as_str(),
58
+
"https://example.com/api/resource?id=123&status=active"
59
+
);
131
60
}
132
61
}
+17
-11
crates/atproto-oauth-aip/src/workflow.rs
+17
-11
crates/atproto-oauth-aip/src/workflow.rs
···
112
112
//! and protocol violations.
113
113
114
114
use anyhow::Result;
115
-
use atproto_identity::url::URLBuilder;
115
+
use atproto_identity::url::build_url;
116
116
use atproto_oauth::{
117
117
jwk::WrappedJsonWebKey,
118
118
workflow::{OAuthRequest, OAuthRequestState, ParResponse, TokenResponse},
119
119
};
120
120
use serde::Deserialize;
121
+
use std::iter;
121
122
122
123
use crate::errors::OAuthWorkflowError;
123
124
···
522
523
access_token_type: &Option<&str>,
523
524
subject: &Option<&str>,
524
525
) -> Result<ATProtocolSession> {
525
-
let mut url_builder = URLBuilder::new(protected_resource_base);
526
-
url_builder.path("/api/atprotocol/session");
526
+
let mut url = build_url(
527
+
protected_resource_base,
528
+
"/api/atprotocol/session",
529
+
iter::empty::<(&str, &str)>(),
530
+
)?;
531
+
{
532
+
let mut pairs = url.query_pairs_mut();
533
+
if let Some(value) = access_token_type {
534
+
pairs.append_pair("access_token_type", value);
535
+
}
527
536
528
-
if let Some(value) = access_token_type {
529
-
url_builder.param("access_token_type", value);
530
-
}
531
-
532
-
if let Some(value) = subject {
533
-
url_builder.param("sub", value);
537
+
if let Some(value) = subject {
538
+
pairs.append_pair("sub", value);
539
+
}
534
540
}
535
541
536
-
let url = url_builder.build();
542
+
let url: String = url.into();
537
543
538
544
let response = http_client
539
-
.get(url)
545
+
.get(&url)
540
546
.bearer_auth(access_token)
541
547
.send()
542
548
.await
+15
-12
crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs
+15
-12
crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs
···
30
30
use async_trait::async_trait;
31
31
use atproto_identity::{
32
32
config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version},
33
-
key::{KeyData, KeyProvider, KeyType, generate_key, identify_key, to_public},
34
-
storage::DidDocumentStorage,
33
+
key::{KeyData, KeyResolver, KeyType, generate_key, identify_key, to_public},
35
34
storage_lru::LruDidDocumentStorage,
35
+
traits::DidDocumentStorage,
36
36
};
37
37
38
38
#[cfg(feature = "hickory-dns")]
···
66
66
};
67
67
68
68
#[derive(Clone)]
69
-
pub struct SimpleKeyProvider {
69
+
pub struct SimpleKeyResolver {
70
70
keys: HashMap<String, KeyData>,
71
71
}
72
72
73
-
impl Default for SimpleKeyProvider {
73
+
impl Default for SimpleKeyResolver {
74
74
fn default() -> Self {
75
75
Self::new()
76
76
}
77
77
}
78
78
79
-
impl SimpleKeyProvider {
79
+
impl SimpleKeyResolver {
80
80
pub fn new() -> Self {
81
81
Self {
82
82
keys: HashMap::new(),
···
85
85
}
86
86
87
87
#[async_trait]
88
-
impl KeyProvider for SimpleKeyProvider {
89
-
async fn get_private_key_by_id(&self, key_id: &str) -> anyhow::Result<Option<KeyData>> {
90
-
Ok(self.keys.get(key_id).cloned())
88
+
impl KeyResolver for SimpleKeyResolver {
89
+
async fn resolve(&self, key_id: &str) -> anyhow::Result<KeyData> {
90
+
self.keys
91
+
.get(key_id)
92
+
.cloned()
93
+
.ok_or_else(|| anyhow::anyhow!("Key not found: {}", key_id))
91
94
}
92
95
}
93
96
···
97
100
pub oauth_client_config: OAuthClientConfig,
98
101
pub oauth_storage: Arc<dyn OAuthRequestStorage + Send + Sync>,
99
102
pub document_storage: Arc<dyn DidDocumentStorage + Send + Sync>,
100
-
pub key_provider: Arc<dyn KeyProvider + Send + Sync>,
103
+
pub key_resolver: Arc<dyn KeyResolver + Send + Sync>,
101
104
}
102
105
103
106
#[derive(Clone, FromRef)]
···
135
138
}
136
139
}
137
140
138
-
impl FromRef<WebContext> for Arc<dyn KeyProvider> {
141
+
impl FromRef<WebContext> for Arc<dyn KeyResolver> {
139
142
fn from_ref(context: &WebContext) -> Self {
140
-
context.0.key_provider.clone()
143
+
context.0.key_resolver.clone()
141
144
}
142
145
}
143
146
···
305
308
oauth_client_config: oauth_client_config.clone(),
306
309
oauth_storage: Arc::new(LruOAuthRequestStorage::new(NonZeroUsize::new(256).unwrap())),
307
310
document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())),
308
-
key_provider: Arc::new(SimpleKeyProvider {
311
+
key_resolver: Arc::new(SimpleKeyResolver {
309
312
keys: signing_key_storage,
310
313
}),
311
314
}));
+7
-9
crates/atproto-oauth-axum/src/handle_complete.rs
+7
-9
crates/atproto-oauth-axum/src/handle_complete.rs
···
7
7
8
8
use anyhow::Result;
9
9
use atproto_identity::{
10
-
key::{KeyProvider, identify_key},
11
-
storage::DidDocumentStorage,
10
+
key::{KeyResolver, identify_key},
11
+
traits::DidDocumentStorage,
12
12
};
13
13
use atproto_oauth::{
14
14
resources::pds_resources,
···
61
61
client: HttpClient,
62
62
oauth_request_storage: State<Arc<dyn OAuthRequestStorage>>,
63
63
did_document_storage: State<Arc<dyn DidDocumentStorage>>,
64
-
key_provider: State<Arc<dyn KeyProvider>>,
64
+
key_resolver: State<Arc<dyn KeyResolver>>,
65
65
Form(callback_form): Form<OAuthCallbackForm>,
66
66
) -> Result<impl IntoResponse, OAuthCallbackError> {
67
67
let oauth_request = oauth_request_storage
···
77
77
});
78
78
}
79
79
80
-
let private_signing_key_data = key_provider
81
-
.get_private_key_by_id(&oauth_request.signing_public_key)
82
-
.await?;
83
-
84
-
let private_signing_key_data =
85
-
private_signing_key_data.ok_or(OAuthCallbackError::NoSigningKeyFound)?;
80
+
let private_signing_key_data = key_resolver
81
+
.resolve(&oauth_request.signing_public_key)
82
+
.await
83
+
.map_err(|_| OAuthCallbackError::NoSigningKeyFound)?;
86
84
87
85
let private_dpop_key_data = identify_key(&oauth_request.dpop_private_key)?;
88
86
+2
-2
crates/atproto-oauth/src/dpop.rs
+2
-2
crates/atproto-oauth/src/dpop.rs
···
183
183
/// * `false` if no DPoP error is found or the header format is invalid
184
184
///
185
185
/// # Examples
186
-
/// ```
186
+
/// ```no_run
187
187
/// use atproto_oauth::dpop::is_dpop_error;
188
188
///
189
189
/// // Valid DPoP error: invalid_dpop_proof
···
516
516
/// - HTTP method or URI don't match expected values
517
517
///
518
518
/// # Examples
519
-
/// ```
519
+
/// ```no_run
520
520
/// use atproto_oauth::dpop::{validate_dpop_jwt, DpopValidationConfig};
521
521
///
522
522
/// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
+5
-14
crates/atproto-record/Cargo.toml
+5
-14
crates/atproto-record/Cargo.toml
···
14
14
keywords.workspace = true
15
15
categories.workspace = true
16
16
17
-
[[bin]]
18
-
name = "atproto-record-sign"
19
-
test = false
20
-
bench = false
21
-
doc = true
22
-
required-features = ["clap", "tokio"]
23
-
24
-
[[bin]]
25
-
name = "atproto-record-verify"
26
-
test = false
27
-
bench = false
28
-
doc = true
29
-
required-features = ["clap", "tokio"]
30
-
31
17
[[bin]]
32
18
name = "atproto-record-cid"
33
19
test = false
···
40
26
41
27
anyhow.workspace = true
42
28
base64.workspace = true
29
+
rand.workspace = true
43
30
serde_ipld_dagcbor.workspace = true
44
31
serde_json.workspace = true
45
32
serde.workspace = true
···
51
38
cid = "0.11"
52
39
multihash = "0.19"
53
40
sha2 = { workspace = true }
41
+
42
+
[dev-dependencies]
43
+
async-trait = "0.1"
44
+
tokio = { workspace = true, features = ["macros", "rt"] }
54
45
55
46
[features]
56
47
default = ["hickory-dns"]
+51
-68
crates/atproto-record/README.md
+51
-68
crates/atproto-record/README.md
···
1
1
# atproto-record
2
2
3
-
Cryptographic signature operations and utilities for AT Protocol records.
3
+
Utilities for working with AT Protocol records.
4
4
5
5
## Overview
6
6
7
-
A comprehensive Rust library for working with AT Protocol records, providing cryptographic signature creation and verification, AT-URI parsing, and datetime utilities. Built on IPLD DAG-CBOR serialization with support for P-256, P-384, and K-256 elliptic curve cryptography.
7
+
A Rust library for working with AT Protocol records, providing AT-URI parsing, TID generation, datetime formatting, and CID generation. Built on IPLD DAG-CBOR serialization for deterministic content addressing.
8
8
9
9
## Features
10
10
11
-
- **Record signing**: Create cryptographic signatures on AT Protocol records following community.lexicon.attestation.signature specification
12
-
- **Signature verification**: Verify record signatures against public keys with issuer validation
13
11
- **AT-URI parsing**: Parse and validate AT Protocol URIs (at://authority/collection/record_key) with robust error handling
14
-
- **IPLD serialization**: DAG-CBOR serialization ensuring deterministic and verifiable record encoding
15
-
- **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curve signatures
12
+
- **TID generation**: Timestamp-based identifiers for AT Protocol records with microsecond precision
13
+
- **CID generation**: Content Identifier generation using DAG-CBOR serialization and SHA-256 hashing
16
14
- **DateTime utilities**: RFC 3339 datetime serialization with millisecond precision for consistent timestamp handling
15
+
- **Typed records**: Type-safe record handling with lexicon type validation
16
+
- **Bytes handling**: Base64 encoding/decoding for binary data in AT Protocol records
17
17
- **Structured errors**: Type-safe error handling following project conventions with detailed error messages
18
18
19
19
## CLI Tools
20
20
21
-
The following command-line tools are available when built with the `clap` feature:
21
+
The following command-line tool is available when built with the `clap` feature:
22
22
23
-
- **`atproto-record-sign`**: Sign AT Protocol records with private keys, supporting flexible argument ordering
24
-
- **`atproto-record-verify`**: Verify AT Protocol record signatures by validating cryptographic signatures against issuer DIDs and public keys
23
+
- **`atproto-record-cid`**: Generate CID (Content Identifier) for AT Protocol records from JSON input
25
24
26
25
## Library Usage
27
26
28
-
### Creating Signatures
27
+
### Generating CIDs
29
28
30
29
```rust
31
-
use atproto_record::signature;
32
-
use atproto_identity::key::identify_key;
33
30
use serde_json::json;
34
-
35
-
// Parse the signing key from a did:key
36
-
let key_data = identify_key("did:key:zQ3sh...")?;
37
-
38
-
// The record to sign
39
-
let record = json!({"$type": "app.bsky.feed.post", "text": "Hello world!"});
31
+
use cid::Cid;
32
+
use sha2::{Digest, Sha256};
33
+
use multihash::Multihash;
40
34
41
-
// Signature metadata (issuer is required, other fields are optional)
42
-
let signature_object = json!({
43
-
"issuer": "did:plc:issuer"
44
-
// Optional: "issuedAt", "purpose", "expiry", etc.
35
+
// Serialize a record to DAG-CBOR and generate its CID
36
+
let record = json!({
37
+
"$type": "app.bsky.feed.post",
38
+
"text": "Hello world!",
39
+
"createdAt": "2024-01-01T00:00:00.000Z"
45
40
});
46
41
47
-
// Create the signed record with embedded signatures array
48
-
let signed_record = signature::create(
49
-
&key_data,
50
-
&record,
51
-
"did:plc:repository",
52
-
"app.bsky.feed.post",
53
-
signature_object
54
-
).await?;
42
+
let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(&record)?;
43
+
let hash = Sha256::digest(&dag_cbor_bytes);
44
+
let multihash = Multihash::wrap(0x12, &hash)?;
45
+
let cid = Cid::new_v1(0x71, multihash);
46
+
47
+
println!("Record CID: {}", cid);
55
48
```
56
49
57
-
### Verifying Signatures
50
+
### Generating TIDs
58
51
59
52
```rust
60
-
use atproto_record::signature;
61
-
use atproto_identity::key::identify_key;
53
+
use atproto_record::tid::Tid;
62
54
63
-
// Parse the public key for verification
64
-
let issuer_key = identify_key("did:key:zQ3sh...")?;
55
+
// Generate a new timestamp-based identifier
56
+
let tid = Tid::new();
57
+
println!("TID: {}", tid); // e.g., "3l2k4j5h6g7f8d9s"
65
58
66
-
// Verify the signature (throws error if invalid)
67
-
signature::verify(
68
-
"did:plc:issuer", // Expected issuer DID
69
-
&issuer_key, // Public key for verification
70
-
signed_record, // The signed record
71
-
"did:plc:repository", // Repository context
72
-
"app.bsky.feed.post" // Collection context
73
-
).await?;
59
+
// TIDs are sortable by creation time
60
+
let tid1 = Tid::new();
61
+
std::thread::sleep(std::time::Duration::from_millis(1));
62
+
let tid2 = Tid::new();
63
+
assert!(tid1 < tid2);
74
64
```
75
65
76
66
### AT-URI Parsing
···
110
100
111
101
## Command Line Usage
112
102
113
-
All CLI tools require the `clap` feature:
103
+
The CLI tool requires the `clap` feature:
114
104
115
105
```bash
116
106
# Build with CLI support
117
107
cargo build --features clap --bins
118
108
119
-
# Sign a record
120
-
cargo run --features clap --bin atproto-record-sign -- \
121
-
did:key:zQ3sh... # Signing key (did:key format)
122
-
did:plc:issuer # Issuer DID
123
-
record.json # Record file (or use -- for stdin)
124
-
repository=did:plc:repo # Repository context
125
-
collection=app.bsky.feed.post # Collection type
109
+
# Generate CID from JSON file
110
+
cat record.json | cargo run --features clap --bin atproto-record-cid
126
111
127
-
# Sign with custom fields (e.g., issuedAt, purpose, expiry)
128
-
cargo run --features clap --bin atproto-record-sign -- \
129
-
did:key:zQ3sh... did:plc:issuer record.json \
130
-
repository=did:plc:repo collection=app.bsky.feed.post \
131
-
issuedAt="2024-01-01T00:00:00.000Z" purpose="attestation"
112
+
# Generate CID from inline JSON
113
+
echo '{"$type":"app.bsky.feed.post","text":"Hello!"}' | cargo run --features clap --bin atproto-record-cid
132
114
133
-
# Verify a signature
134
-
cargo run --features clap --bin atproto-record-verify -- \
135
-
did:plc:issuer # Expected issuer DID
136
-
did:key:zQ3sh... # Verification key
137
-
signed.json # Signed record file
138
-
repository=did:plc:repo # Repository context (must match signing)
139
-
collection=app.bsky.feed.post # Collection type (must match signing)
115
+
# Example with a complete AT Protocol record
116
+
cat <<EOF | cargo run --features clap --bin atproto-record-cid
117
+
{
118
+
"$type": "app.bsky.feed.post",
119
+
"text": "Hello AT Protocol!",
120
+
"createdAt": "2024-01-01T00:00:00.000Z"
121
+
}
122
+
EOF
123
+
```
140
124
141
-
# Read from stdin
142
-
echo '{"text":"Hello"}' | cargo run --features clap --bin atproto-record-sign -- \
143
-
did:key:zQ3sh... did:plc:issuer -- \
144
-
repository=did:plc:repo collection=app.bsky.feed.post
125
+
The tool outputs the CID in base32 format:
126
+
```
127
+
bafyreibjzlvhtyxnhbvvzl3gj4qmg2ufl2jbhh5qr3gvvxlm7ksf3qwxqq
145
128
```
146
129
147
130
## License
148
131
149
-
MIT License
132
+
MIT License
-192
crates/atproto-record/src/bin/atproto-record-sign.rs
-192
crates/atproto-record/src/bin/atproto-record-sign.rs
···
1
-
//! Command-line tool for signing AT Protocol records with cryptographic signatures.
2
-
//!
3
-
//! This tool creates cryptographic signatures on AT Protocol records using ECDSA
4
-
//! signatures with IPLD DAG-CBOR serialization. It supports flexible argument
5
-
//! ordering and customizable signature metadata.
6
-
7
-
use anyhow::Result;
8
-
use atproto_identity::{
9
-
key::{KeyData, identify_key},
10
-
resolve::{InputType, parse_input},
11
-
};
12
-
use atproto_record::errors::CliError;
13
-
use atproto_record::signature::create;
14
-
use clap::Parser;
15
-
use serde_json::json;
16
-
use std::{
17
-
collections::HashMap,
18
-
fs,
19
-
io::{self, Read},
20
-
};
21
-
22
-
/// AT Protocol Record Signing CLI
23
-
#[derive(Parser)]
24
-
#[command(
25
-
name = "atproto-record-sign",
26
-
version,
27
-
about = "Sign AT Protocol records with cryptographic signatures",
28
-
long_about = "
29
-
A command-line tool for signing AT Protocol records using DID keys. Reads a JSON
30
-
record from a file or stdin, applies a cryptographic signature, and outputs the
31
-
signed record with embedded signature metadata.
32
-
33
-
The tool accepts flexible argument ordering with DID keys, issuer DIDs, record
34
-
inputs, and key=value parameters for repository, collection, and custom metadata.
35
-
36
-
REQUIRED PARAMETERS:
37
-
repository=<DID> Repository context for the signature
38
-
collection=<name> Collection type context for the signature
39
-
40
-
OPTIONAL PARAMETERS:
41
-
Any additional key=value pairs are included in the signature metadata
42
-
(e.g., issuedAt=<timestamp>, purpose=<string>, expiry=<timestamp>)
43
-
44
-
EXAMPLES:
45
-
# Basic usage:
46
-
atproto-record-sign \\
47
-
did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\
48
-
./post.json \\
49
-
did:plc:tgudj2fjm77pzkuawquqhsxm \\
50
-
repository=did:plc:4zutorghlchjxzgceklue4la \\
51
-
collection=app.bsky.feed.post
52
-
53
-
# With custom metadata:
54
-
atproto-record-sign \\
55
-
did:key:z42tv1pb3... ./post.json did:plc:issuer... \\
56
-
repository=did:plc:repo... collection=app.bsky.feed.post \\
57
-
issuedAt=\"2024-01-01T00:00:00.000Z\" purpose=\"attestation\"
58
-
59
-
# Reading from stdin:
60
-
echo '{\"text\":\"Hello!\"}' | atproto-record-sign \\
61
-
did:key:z42tv1pb3... -- did:plc:issuer... \\
62
-
repository=did:plc:repo... collection=app.bsky.feed.post
63
-
64
-
SIGNATURE PROCESS:
65
-
- Creates $sig object with repository, collection, and custom metadata
66
-
- Serializes record using IPLD DAG-CBOR format
67
-
- Generates ECDSA signatures using P-256, P-384, or K-256 curves
68
-
- Embeds signatures with issuer and any provided metadata
69
-
"
70
-
)]
71
-
struct Args {
72
-
/// All arguments - flexible parsing handles DID keys, issuer DIDs, files, and key=value pairs
73
-
args: Vec<String>,
74
-
}
75
-
#[tokio::main]
76
-
async fn main() -> Result<()> {
77
-
let args = Args::parse();
78
-
79
-
let arguments = args.args.into_iter();
80
-
81
-
let mut collection: Option<String> = None;
82
-
let mut repository: Option<String> = None;
83
-
let mut record: Option<serde_json::Value> = None;
84
-
let mut issuer: Option<String> = None;
85
-
let mut key_data: Option<KeyData> = None;
86
-
let mut signature_extras: HashMap<String, String> = HashMap::default();
87
-
88
-
for argument in arguments {
89
-
if let Some((key, value)) = argument.split_once("=") {
90
-
match key {
91
-
"collection" => {
92
-
collection = Some(value.to_string());
93
-
}
94
-
"repository" => {
95
-
repository = Some(value.to_string());
96
-
}
97
-
_ => {
98
-
signature_extras.insert(key.to_string(), value.to_string());
99
-
}
100
-
}
101
-
} else if argument.starts_with("did:key:") {
102
-
// Parse the did:key to extract key data for signing
103
-
key_data = Some(identify_key(&argument)?);
104
-
} else if argument.starts_with("did:") {
105
-
match parse_input(&argument) {
106
-
Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => {
107
-
issuer = Some(did);
108
-
}
109
-
Ok(_) => {
110
-
return Err(CliError::UnsupportedDidMethod {
111
-
method: argument.clone(),
112
-
}
113
-
.into());
114
-
}
115
-
Err(_) => {
116
-
return Err(CliError::DidParseFailed {
117
-
did: argument.clone(),
118
-
}
119
-
.into());
120
-
}
121
-
}
122
-
} else if argument == "--" {
123
-
// Read record from stdin
124
-
if record.is_none() {
125
-
let mut stdin_content = String::new();
126
-
io::stdin()
127
-
.read_to_string(&mut stdin_content)
128
-
.map_err(|_| CliError::StdinReadFailed)?;
129
-
record = Some(
130
-
serde_json::from_str(&stdin_content)
131
-
.map_err(|_| CliError::StdinJsonParseFailed)?,
132
-
);
133
-
} else {
134
-
return Err(CliError::UnexpectedArgument {
135
-
argument: argument.clone(),
136
-
}
137
-
.into());
138
-
}
139
-
} else {
140
-
// Assume it's a file path to read the record from
141
-
if record.is_none() {
142
-
let file_content =
143
-
fs::read_to_string(&argument).map_err(|_| CliError::FileReadFailed {
144
-
path: argument.clone(),
145
-
})?;
146
-
record = Some(serde_json::from_str(&file_content).map_err(|_| {
147
-
CliError::FileJsonParseFailed {
148
-
path: argument.clone(),
149
-
}
150
-
})?);
151
-
} else {
152
-
return Err(CliError::UnexpectedArgument {
153
-
argument: argument.clone(),
154
-
}
155
-
.into());
156
-
}
157
-
}
158
-
}
159
-
160
-
let collection = collection.ok_or(CliError::MissingRequiredValue {
161
-
name: "collection".to_string(),
162
-
})?;
163
-
let repository = repository.ok_or(CliError::MissingRequiredValue {
164
-
name: "repository".to_string(),
165
-
})?;
166
-
let record = record.ok_or(CliError::MissingRequiredValue {
167
-
name: "record".to_string(),
168
-
})?;
169
-
let issuer = issuer.ok_or(CliError::MissingRequiredValue {
170
-
name: "issuer".to_string(),
171
-
})?;
172
-
let key_data = key_data.ok_or(CliError::MissingRequiredValue {
173
-
name: "signing_key".to_string(),
174
-
})?;
175
-
176
-
// Write "issuer" key to signature_extras
177
-
signature_extras.insert("issuer".to_string(), issuer);
178
-
179
-
let signature_object = json!(signature_extras);
180
-
let signed_record = create(
181
-
&key_data,
182
-
&record,
183
-
&repository,
184
-
&collection,
185
-
signature_object,
186
-
)?;
187
-
188
-
let pretty_signed_record = serde_json::to_string_pretty(&signed_record);
189
-
println!("{}", pretty_signed_record.unwrap());
190
-
191
-
Ok(())
192
-
}
-166
crates/atproto-record/src/bin/atproto-record-verify.rs
-166
crates/atproto-record/src/bin/atproto-record-verify.rs
···
1
-
//! Command-line tool for verifying cryptographic signatures on AT Protocol records.
2
-
//!
3
-
//! This tool validates signatures on AT Protocol records by reconstructing the
4
-
//! signed content and verifying ECDSA signatures against public keys. It ensures
5
-
//! that records have valid signatures from specified issuers.
6
-
7
-
use anyhow::Result;
8
-
use atproto_identity::{
9
-
key::{KeyData, identify_key},
10
-
resolve::{InputType, parse_input},
11
-
};
12
-
use atproto_record::errors::CliError;
13
-
use atproto_record::signature::verify;
14
-
use clap::Parser;
15
-
use std::{
16
-
fs,
17
-
io::{self, Read},
18
-
};
19
-
20
-
/// AT Protocol Record Verification CLI
21
-
#[derive(Parser)]
22
-
#[command(
23
-
name = "atproto-record-verify",
24
-
version,
25
-
about = "Verify cryptographic signatures of AT Protocol records",
26
-
long_about = "
27
-
A command-line tool for verifying cryptographic signatures of AT Protocol records.
28
-
Reads a signed JSON record from a file or stdin, validates the embedded signatures
29
-
using a public key, and reports verification success or failure.
30
-
31
-
The tool accepts flexible argument ordering with issuer DIDs, verification keys,
32
-
record inputs, and key=value parameters for repository and collection context.
33
-
34
-
REQUIRED PARAMETERS:
35
-
repository=<DID> Repository context used during signing
36
-
collection=<name> Collection type context used during signing
37
-
38
-
EXAMPLES:
39
-
# Basic verification:
40
-
atproto-record-verify \\
41
-
did:plc:tgudj2fjm77pzkuawquqhsxm \\
42
-
did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\
43
-
./signed_post.json \\
44
-
repository=did:plc:4zutorghlchjxzgceklue4la \\
45
-
collection=app.bsky.feed.post
46
-
47
-
# Verify from stdin:
48
-
echo '{\"signatures\":[...]}' | atproto-record-verify \\
49
-
did:plc:issuer... did:key:z42tv1pb3... -- \\
50
-
repository=did:plc:repo... collection=app.bsky.feed.post
51
-
52
-
VERIFICATION PROCESS:
53
-
- Extracts signatures from the signatures array
54
-
- Finds signatures matching the specified issuer DID
55
-
- Reconstructs $sig object with repository and collection context
56
-
- Validates ECDSA signatures using P-256 or K-256 curves
57
-
"
58
-
)]
59
-
struct Args {
60
-
/// All arguments - flexible parsing handles issuer DIDs, verification keys, files, and key=value pairs
61
-
args: Vec<String>,
62
-
}
63
-
#[tokio::main]
64
-
async fn main() -> Result<()> {
65
-
let args = Args::parse();
66
-
67
-
let arguments = args.args.into_iter();
68
-
69
-
let mut collection: Option<String> = None;
70
-
let mut repository: Option<String> = None;
71
-
let mut record: Option<serde_json::Value> = None;
72
-
let mut issuer: Option<String> = None;
73
-
let mut key_data: Option<KeyData> = None;
74
-
75
-
for argument in arguments {
76
-
if let Some((key, value)) = argument.split_once("=") {
77
-
match key {
78
-
"collection" => {
79
-
collection = Some(value.to_string());
80
-
}
81
-
"repository" => {
82
-
repository = Some(value.to_string());
83
-
}
84
-
_ => {}
85
-
}
86
-
} else if argument.starts_with("did:key:") {
87
-
// Parse the did:key to extract key data for verification
88
-
key_data = Some(identify_key(&argument)?);
89
-
} else if argument.starts_with("did:") {
90
-
match parse_input(&argument) {
91
-
Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => {
92
-
issuer = Some(did);
93
-
}
94
-
Ok(_) => {
95
-
return Err(CliError::UnsupportedDidMethod {
96
-
method: argument.clone(),
97
-
}
98
-
.into());
99
-
}
100
-
Err(_) => {
101
-
return Err(CliError::DidParseFailed {
102
-
did: argument.clone(),
103
-
}
104
-
.into());
105
-
}
106
-
}
107
-
} else if argument == "--" {
108
-
// Read record from stdin
109
-
if record.is_none() {
110
-
let mut stdin_content = String::new();
111
-
io::stdin()
112
-
.read_to_string(&mut stdin_content)
113
-
.map_err(|_| CliError::StdinReadFailed)?;
114
-
record = Some(
115
-
serde_json::from_str(&stdin_content)
116
-
.map_err(|_| CliError::StdinJsonParseFailed)?,
117
-
);
118
-
} else {
119
-
return Err(CliError::UnexpectedArgument {
120
-
argument: argument.clone(),
121
-
}
122
-
.into());
123
-
}
124
-
} else {
125
-
// Assume it's a file path to read the record from
126
-
if record.is_none() {
127
-
let file_content =
128
-
fs::read_to_string(&argument).map_err(|_| CliError::FileReadFailed {
129
-
path: argument.clone(),
130
-
})?;
131
-
record = Some(serde_json::from_str(&file_content).map_err(|_| {
132
-
CliError::FileJsonParseFailed {
133
-
path: argument.clone(),
134
-
}
135
-
})?);
136
-
} else {
137
-
return Err(CliError::UnexpectedArgument {
138
-
argument: argument.clone(),
139
-
}
140
-
.into());
141
-
}
142
-
}
143
-
}
144
-
145
-
let collection = collection.ok_or(CliError::MissingRequiredValue {
146
-
name: "collection".to_string(),
147
-
})?;
148
-
let repository = repository.ok_or(CliError::MissingRequiredValue {
149
-
name: "repository".to_string(),
150
-
})?;
151
-
let record = record.ok_or(CliError::MissingRequiredValue {
152
-
name: "record".to_string(),
153
-
})?;
154
-
let issuer = issuer.ok_or(CliError::MissingRequiredValue {
155
-
name: "issuer".to_string(),
156
-
})?;
157
-
let key_data = key_data.ok_or(CliError::MissingRequiredValue {
158
-
name: "key".to_string(),
159
-
})?;
160
-
161
-
verify(&issuer, &key_data, record, &repository, &collection)?;
162
-
163
-
println!("OK");
164
-
165
-
Ok(())
166
-
}
+46
-1
crates/atproto-record/src/errors.rs
+46
-1
crates/atproto-record/src/errors.rs
···
14
14
//! Errors occurring during AT-URI parsing and validation.
15
15
//! Error codes: aturi-1 through aturi-9
16
16
//!
17
+
//! ### `TidError` (Domain: tid)
18
+
//! Errors occurring during TID (Timestamp Identifier) parsing and decoding.
19
+
//! Error codes: tid-1 through tid-3
20
+
//!
17
21
//! ### `CliError` (Domain: cli)
18
22
//! Command-line interface specific errors for file I/O, argument parsing, and DID validation.
19
-
//! Error codes: cli-1 through cli-8
23
+
//! Error codes: cli-1 through cli-10
20
24
//!
21
25
//! ## Error Format
22
26
//!
···
220
224
/// record key component, which is not valid.
221
225
#[error("error-atproto-record-aturi-9 Record key component cannot be empty")]
222
226
EmptyRecordKey,
227
+
}
228
+
229
+
/// Errors that can occur during TID (Timestamp Identifier) operations.
230
+
///
231
+
/// This enum covers all validation failures when parsing and decoding TIDs,
232
+
/// including format violations, invalid characters, and encoding errors.
233
+
#[derive(Debug, Error)]
234
+
pub enum TidError {
235
+
/// Error when TID string length is invalid.
236
+
///
237
+
/// This error occurs when a TID string is not exactly 13 characters long,
238
+
/// which is required by the TID specification.
239
+
#[error("error-atproto-record-tid-1 Invalid TID length: expected {expected}, got {actual}")]
240
+
InvalidLength {
241
+
/// Expected length (always 13)
242
+
expected: usize,
243
+
/// Actual length of the provided string
244
+
actual: usize,
245
+
},
246
+
247
+
/// Error when TID contains an invalid character.
248
+
///
249
+
/// This error occurs when a TID string contains a character outside the
250
+
/// base32-sortable character set (234567abcdefghijklmnopqrstuvwxyz).
251
+
#[error("error-atproto-record-tid-2 Invalid character '{character}' at position {position}")]
252
+
InvalidCharacter {
253
+
/// The invalid character
254
+
character: char,
255
+
/// Position in the string (0-indexed)
256
+
position: usize,
257
+
},
258
+
259
+
/// Error when TID format is invalid.
260
+
///
261
+
/// This error occurs when the TID violates structural requirements,
262
+
/// such as having the top bit set (which must always be 0).
263
+
#[error("error-atproto-record-tid-3 Invalid TID format: {reason}")]
264
+
InvalidFormat {
265
+
/// Reason for the format violation
266
+
reason: String,
267
+
},
223
268
}
224
269
225
270
/// Errors specific to command-line interface operations.
+40
-19
crates/atproto-record/src/lib.rs
+40
-19
crates/atproto-record/src/lib.rs
···
16
16
//! ## Example Usage
17
17
//!
18
18
//! ```ignore
19
-
//! use atproto_record::signature;
20
-
//! use atproto_identity::key::identify_key;
19
+
//! use atproto_record::attestation;
20
+
//! use atproto_identity::key::{identify_key, sign, to_public};
21
+
//! use base64::engine::general_purpose::STANDARD;
21
22
//! use serde_json::json;
22
23
//!
23
-
//! // Sign a record
24
-
//! let key_data = identify_key("did:key:...")?;
25
-
//! let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"});
26
-
//! let sig_obj = json!({"issuer": "did:plc:..."});
24
+
//! let private_key = identify_key("did:key:zPrivate...")?;
25
+
//! let public_key = to_public(&private_key)?;
26
+
//! let key_reference = format!("{}", &public_key);
27
27
//!
28
-
//! let signed = signature::create(&key_data, &record, "did:plc:repo",
29
-
//! "app.bsky.feed.post", sig_obj).await?;
28
+
//! let record = json!({
29
+
//! "$type": "app.example.record",
30
+
//! "text": "Hello from attestation helpers!"
31
+
//! });
32
+
//!
33
+
//! let sig_metadata = json!({
34
+
//! "$type": "com.example.inlineSignature",
35
+
//! "key": &key_reference,
36
+
//! "purpose": "demo"
37
+
//! });
38
+
//!
39
+
//! let signing_record = attestation::prepare_signing_record(&record, &sig_metadata)?;
40
+
//! let cid = attestation::create_cid(&signing_record)?;
41
+
//! let signature_bytes = sign(&private_key, &cid.to_bytes())?;
42
+
//!
43
+
//! let inline_attestation = json!({
44
+
//! "$type": "com.example.inlineSignature",
45
+
//! "key": key_reference,
46
+
//! "purpose": "demo",
47
+
//! "signature": {"$bytes": STANDARD.encode(signature_bytes)}
48
+
//! });
30
49
//!
31
-
//! // Verify a signature
32
-
//! signature::verify("did:plc:issuer", &key_data, signed,
33
-
//! "did:plc:repo", "app.bsky.feed.post").await?;
50
+
//! let signed = attestation::create_inline_attestation_reference(&record, &inline_attestation)?;
51
+
//! let reports = tokio_test::block_on(async {
52
+
//! attestation::verify_all_signatures(&signed, None).await
53
+
//! })?;
54
+
//! assert!(matches!(reports[0].status, attestation::VerificationStatus::Valid { .. }));
34
55
//! ```
35
56
36
57
#![forbid(unsafe_code)]
···
42
63
/// and CLI operations. All errors follow the project's standardized format:
43
64
/// `error-atproto-record-{domain}-{number} {message}: {details}`
44
65
pub mod errors;
45
-
46
-
/// Core signature creation and verification.
47
-
///
48
-
/// Provides functions for creating and verifying cryptographic signatures on
49
-
/// AT Protocol records using IPLD DAG-CBOR serialization. Supports the
50
-
/// community.lexicon.attestation.signature specification with proper $sig
51
-
/// object handling and multiple signature support.
52
-
pub mod signature;
53
66
54
67
/// AT-URI parsing and validation.
55
68
///
···
84
97
/// in many AT Protocol lexicon structures. The wrapper can automatically add type
85
98
/// fields during serialization and validate them during deserialization.
86
99
pub mod typed;
100
+
101
+
/// Timestamp Identifier (TID) generation and parsing.
102
+
///
103
+
/// TIDs are sortable, distributed identifiers combining microsecond timestamps
104
+
/// with random clock identifiers. They provide a collision-resistant, monotonically
105
+
/// increasing identifier scheme for AT Protocol records encoded as 13-character
106
+
/// base32-sortable strings.
107
+
pub mod tid;
-672
crates/atproto-record/src/signature.rs
-672
crates/atproto-record/src/signature.rs
···
1
-
//! AT Protocol record signature creation and verification.
2
-
//!
3
-
//! This module provides comprehensive functionality for creating and verifying
4
-
//! cryptographic signatures on AT Protocol records following the
5
-
//! community.lexicon.attestation.signature specification.
6
-
//!
7
-
//! ## Signature Process
8
-
//!
9
-
//! 1. **Signing**: Records are augmented with a `$sig` object containing issuer,
10
-
//! timestamp, and context information, then serialized using IPLD DAG-CBOR
11
-
//! for deterministic encoding before signing with ECDSA.
12
-
//!
13
-
//! 2. **Storage**: Signatures are stored in a `signatures` array within the record,
14
-
//! allowing multiple signatures from different issuers.
15
-
//!
16
-
//! 3. **Verification**: The original signed content is reconstructed by replacing
17
-
//! the signatures array with the appropriate `$sig` object, then verified
18
-
//! using the issuer's public key.
19
-
//!
20
-
//! ## Supported Curves
21
-
//!
22
-
//! - P-256 (NIST P-256 / secp256r1)
23
-
//! - P-384 (NIST P-384 / secp384r1)
24
-
//! - K-256 (secp256k1)
25
-
//!
26
-
//! ## Example
27
-
//!
28
-
//! ```ignore
29
-
//! use atproto_record::signature::{create, verify};
30
-
//! use atproto_identity::key::identify_key;
31
-
//! use serde_json::json;
32
-
//!
33
-
//! // Create a signature
34
-
//! let key = identify_key("did:key:...")?;
35
-
//! let record = json!({"text": "Hello!"});
36
-
//! let sig_obj = json!({
37
-
//! "issuer": "did:plc:issuer"
38
-
//! // Optional: any additional fields like "issuedAt", "purpose", etc.
39
-
//! });
40
-
//!
41
-
//! let signed = create(&key, &record, "did:plc:repo",
42
-
//! "app.bsky.feed.post", sig_obj)?;
43
-
//!
44
-
//! // Verify the signature
45
-
//! verify("did:plc:issuer", &key, signed,
46
-
//! "did:plc:repo", "app.bsky.feed.post")?;
47
-
//! ```
48
-
49
-
use atproto_identity::key::{KeyData, sign, validate};
50
-
use base64::{Engine, engine::general_purpose::STANDARD};
51
-
use serde_json::json;
52
-
53
-
use crate::errors::VerificationError;
54
-
55
-
/// Creates a cryptographic signature for an AT Protocol record.
56
-
///
57
-
/// This function generates a signature following the community.lexicon.attestation.signature
58
-
/// specification. The record is augmented with a `$sig` object containing context information,
59
-
/// serialized using IPLD DAG-CBOR, signed with the provided key, and the signature is added
60
-
/// to a `signatures` array in the returned record.
61
-
///
62
-
/// # Parameters
63
-
///
64
-
/// * `key_data` - The signing key (private key) wrapped in KeyData
65
-
/// * `record` - The JSON record to be signed (will not be modified)
66
-
/// * `repository` - The repository DID where this record will be stored
67
-
/// * `collection` - The collection type (NSID) for this record
68
-
/// * `signature_object` - Metadata for the signature, must include:
69
-
/// - `issuer`: The DID of the entity creating the signature (required)
70
-
/// - Additional custom fields are preserved in the signature (optional)
71
-
///
72
-
/// # Returns
73
-
///
74
-
/// Returns a new record containing:
75
-
/// - All original record fields
76
-
/// - A `signatures` array with the new signature appended
77
-
/// - No `$sig` field (only used during signing)
78
-
///
79
-
/// # Errors
80
-
///
81
-
/// Returns [`VerificationError`] if:
82
-
/// - Required field `issuer` is missing from signature_object
83
-
/// - IPLD DAG-CBOR serialization fails
84
-
/// - Cryptographic signing operation fails
85
-
/// - JSON structure manipulation fails
86
-
pub fn create(
87
-
key_data: &KeyData,
88
-
record: &serde_json::Value,
89
-
repository: &str,
90
-
collection: &str,
91
-
signature_object: serde_json::Value,
92
-
) -> Result<serde_json::Value, VerificationError> {
93
-
if let Some(record_map) = signature_object.as_object() {
94
-
if !record_map.contains_key("issuer") {
95
-
return Err(VerificationError::SignatureObjectMissingField {
96
-
field: "issuer".to_string(),
97
-
});
98
-
}
99
-
} else {
100
-
return Err(VerificationError::InvalidSignatureObjectType);
101
-
};
102
-
103
-
// Prepare the $sig object.
104
-
let mut sig = signature_object.clone();
105
-
if let Some(record_map) = sig.as_object_mut() {
106
-
record_map.insert("repository".to_string(), json!(repository));
107
-
record_map.insert("collection".to_string(), json!(collection));
108
-
record_map.insert(
109
-
"$type".to_string(),
110
-
json!("community.lexicon.attestation.signature"),
111
-
);
112
-
}
113
-
114
-
// Create a copy of the record with the $sig object for signing.
115
-
let mut signing_record = record.clone();
116
-
if let Some(record_map) = signing_record.as_object_mut() {
117
-
record_map.remove("signatures");
118
-
record_map.remove("$sig");
119
-
record_map.insert("$sig".to_string(), sig);
120
-
}
121
-
122
-
// Create a signature.
123
-
let serialized_signing_record = serde_ipld_dagcbor::to_vec(&signing_record)?;
124
-
125
-
let signature: Vec<u8> = sign(key_data, &serialized_signing_record)?;
126
-
let encoded_signature = STANDARD.encode(&signature);
127
-
128
-
// Compose the proof object
129
-
let mut proof = signature_object.clone();
130
-
if let Some(record_map) = proof.as_object_mut() {
131
-
record_map.remove("repository");
132
-
record_map.remove("collection");
133
-
record_map.insert(
134
-
"signature".to_string(),
135
-
json!({"$bytes": json!(encoded_signature)}),
136
-
);
137
-
record_map.insert(
138
-
"$type".to_string(),
139
-
json!("community.lexicon.attestation.signature"),
140
-
);
141
-
}
142
-
143
-
// Add the signature to the original record
144
-
let mut signed_record = record.clone();
145
-
146
-
if let Some(record_map) = signed_record.as_object_mut() {
147
-
let mut signatures: Vec<serde_json::Value> = record
148
-
.get("signatures")
149
-
.and_then(|v| v.as_array().cloned())
150
-
.unwrap_or_default();
151
-
152
-
signatures.push(proof);
153
-
154
-
record_map.remove("$sig");
155
-
record_map.remove("signatures");
156
-
157
-
// Add the $sig field
158
-
record_map.insert("signatures".to_string(), json!(signatures));
159
-
}
160
-
161
-
Ok(signed_record)
162
-
}
163
-
164
-
/// Verifies a cryptographic signature on an AT Protocol record.
165
-
///
166
-
/// This function validates signatures by reconstructing the original signed content
167
-
/// (record with `$sig` object) and verifying the ECDSA signature against it.
168
-
/// It searches through all signatures in the record to find one matching the
169
-
/// specified issuer, then verifies it with the provided public key.
170
-
///
171
-
/// # Parameters
172
-
///
173
-
/// * `issuer` - The DID of the expected signature issuer to verify
174
-
/// * `key_data` - The public key for signature verification
175
-
/// * `record` - The signed record containing a `signatures` or `sigs` array
176
-
/// * `repository` - The repository DID used during signing (must match)
177
-
/// * `collection` - The collection type used during signing (must match)
178
-
///
179
-
/// # Returns
180
-
///
181
-
/// Returns `Ok(())` if a valid signature from the specified issuer is found
182
-
/// and successfully verified against the reconstructed signed content.
183
-
///
184
-
/// # Errors
185
-
///
186
-
/// Returns [`VerificationError`] if:
187
-
/// - No `signatures` or `sigs` field exists in the record
188
-
/// - No signature from the specified issuer is found
189
-
/// - The issuer's signature is malformed or missing required fields
190
-
/// - The signature is not in the expected `{"$bytes": "..."}` format
191
-
/// - Base64 decoding of the signature fails
192
-
/// - IPLD DAG-CBOR serialization of reconstructed content fails
193
-
/// - Cryptographic verification fails (invalid signature)
194
-
///
195
-
/// # Note
196
-
///
197
-
/// This function supports both `signatures` and `sigs` field names for
198
-
/// backward compatibility with different AT Protocol implementations.
199
-
pub fn verify(
200
-
issuer: &str,
201
-
key_data: &KeyData,
202
-
record: serde_json::Value,
203
-
repository: &str,
204
-
collection: &str,
205
-
) -> Result<(), VerificationError> {
206
-
let signatures = record
207
-
.get("sigs")
208
-
.or_else(|| record.get("signatures"))
209
-
.and_then(|v| v.as_array())
210
-
.ok_or(VerificationError::NoSignaturesField)?;
211
-
212
-
for sig_obj in signatures {
213
-
// Extract the issuer from the signature object
214
-
let signature_issuer = sig_obj
215
-
.get("issuer")
216
-
.and_then(|v| v.as_str())
217
-
.ok_or(VerificationError::MissingIssuerField)?;
218
-
219
-
let signature_value = sig_obj
220
-
.get("signature")
221
-
.and_then(|v| v.as_object())
222
-
.and_then(|obj| obj.get("$bytes"))
223
-
.and_then(|b| b.as_str())
224
-
.ok_or(VerificationError::MissingSignatureField)?;
225
-
226
-
if issuer != signature_issuer {
227
-
continue;
228
-
}
229
-
230
-
let mut sig_variable = sig_obj.clone();
231
-
232
-
if let Some(sig_map) = sig_variable.as_object_mut() {
233
-
sig_map.remove("signature");
234
-
sig_map.insert("repository".to_string(), json!(repository));
235
-
sig_map.insert("collection".to_string(), json!(collection));
236
-
}
237
-
238
-
let mut signed_record = record.clone();
239
-
if let Some(record_map) = signed_record.as_object_mut() {
240
-
record_map.remove("signatures");
241
-
record_map.remove("sigs");
242
-
record_map.insert("$sig".to_string(), sig_variable);
243
-
}
244
-
245
-
let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record)
246
-
.map_err(|error| VerificationError::RecordSerializationFailed { error })?;
247
-
248
-
let signature_bytes = STANDARD
249
-
.decode(signature_value)
250
-
.map_err(|error| VerificationError::SignatureDecodingFailed { error })?;
251
-
252
-
validate(key_data, &signature_bytes, &serialized_record)
253
-
.map_err(|error| VerificationError::CryptographicValidationFailed { error })?;
254
-
255
-
return Ok(());
256
-
}
257
-
258
-
Err(VerificationError::NoValidSignatureForIssuer {
259
-
issuer: issuer.to_string(),
260
-
})
261
-
}
262
-
263
-
#[cfg(test)]
264
-
mod tests {
265
-
use super::*;
266
-
use atproto_identity::key::{KeyType, generate_key, to_public};
267
-
use serde_json::json;
268
-
269
-
#[test]
270
-
fn test_create_sign_and_verify_record_p256() -> Result<(), Box<dyn std::error::Error>> {
271
-
// Step 1: Generate a P-256 key pair
272
-
let private_key = generate_key(KeyType::P256Private)?;
273
-
let public_key = to_public(&private_key)?;
274
-
275
-
// Step 2: Create a sample record
276
-
let record = json!({
277
-
"text": "Hello AT Protocol!",
278
-
"createdAt": "2025-01-19T10:00:00Z",
279
-
"langs": ["en"]
280
-
});
281
-
282
-
// Step 3: Define signature metadata
283
-
let issuer_did = "did:plc:test123";
284
-
let repository = "did:plc:repo456";
285
-
let collection = "app.bsky.feed.post";
286
-
287
-
let signature_object = json!({
288
-
"issuer": issuer_did,
289
-
"issuedAt": "2025-01-19T10:00:00Z",
290
-
"purpose": "attestation"
291
-
});
292
-
293
-
// Step 4: Sign the record
294
-
let signed_record = create(
295
-
&private_key,
296
-
&record,
297
-
repository,
298
-
collection,
299
-
signature_object.clone(),
300
-
)?;
301
-
302
-
// Verify that the signed record contains signatures array
303
-
assert!(signed_record.get("signatures").is_some());
304
-
let signatures = signed_record
305
-
.get("signatures")
306
-
.and_then(|v| v.as_array())
307
-
.expect("signatures should be an array");
308
-
assert_eq!(signatures.len(), 1);
309
-
310
-
// Verify signature object structure
311
-
let sig = &signatures[0];
312
-
assert_eq!(sig.get("issuer").and_then(|v| v.as_str()), Some(issuer_did));
313
-
assert!(sig.get("signature").is_some());
314
-
assert_eq!(
315
-
sig.get("$type").and_then(|v| v.as_str()),
316
-
Some("community.lexicon.attestation.signature")
317
-
);
318
-
319
-
// Step 5: Verify the signature
320
-
verify(
321
-
issuer_did,
322
-
&public_key,
323
-
signed_record.clone(),
324
-
repository,
325
-
collection,
326
-
)?;
327
-
328
-
Ok(())
329
-
}
330
-
331
-
#[test]
332
-
fn test_create_sign_and_verify_record_k256() -> Result<(), Box<dyn std::error::Error>> {
333
-
// Test with K-256 curve
334
-
let private_key = generate_key(KeyType::K256Private)?;
335
-
let public_key = to_public(&private_key)?;
336
-
337
-
let record = json!({
338
-
"subject": "at://did:plc:example/app.bsky.feed.post/123",
339
-
"likedAt": "2025-01-19T10:00:00Z"
340
-
});
341
-
342
-
let issuer_did = "did:plc:issuer789";
343
-
let repository = "did:plc:repo789";
344
-
let collection = "app.bsky.feed.like";
345
-
346
-
let signature_object = json!({
347
-
"issuer": issuer_did,
348
-
"issuedAt": "2025-01-19T10:00:00Z"
349
-
});
350
-
351
-
let signed_record = create(
352
-
&private_key,
353
-
&record,
354
-
repository,
355
-
collection,
356
-
signature_object,
357
-
)?;
358
-
359
-
verify(
360
-
issuer_did,
361
-
&public_key,
362
-
signed_record,
363
-
repository,
364
-
collection,
365
-
)?;
366
-
367
-
Ok(())
368
-
}
369
-
370
-
#[test]
371
-
fn test_create_sign_and_verify_record_p384() -> Result<(), Box<dyn std::error::Error>> {
372
-
// Test with P-384 curve
373
-
let private_key = generate_key(KeyType::P384Private)?;
374
-
let public_key = to_public(&private_key)?;
375
-
376
-
let record = json!({
377
-
"displayName": "Test User",
378
-
"description": "Testing P-384 signatures"
379
-
});
380
-
381
-
let issuer_did = "did:web:example.com";
382
-
let repository = "did:plc:profile123";
383
-
let collection = "app.bsky.actor.profile";
384
-
385
-
let signature_object = json!({
386
-
"issuer": issuer_did,
387
-
"issuedAt": "2025-01-19T10:00:00Z",
388
-
"expiresAt": "2025-01-20T10:00:00Z",
389
-
"customField": "custom value"
390
-
});
391
-
392
-
let signed_record = create(
393
-
&private_key,
394
-
&record,
395
-
repository,
396
-
collection,
397
-
signature_object.clone(),
398
-
)?;
399
-
400
-
// Verify custom fields are preserved in signature
401
-
let signatures = signed_record
402
-
.get("signatures")
403
-
.and_then(|v| v.as_array())
404
-
.expect("signatures should exist");
405
-
let sig = &signatures[0];
406
-
assert_eq!(
407
-
sig.get("customField").and_then(|v| v.as_str()),
408
-
Some("custom value")
409
-
);
410
-
411
-
verify(
412
-
issuer_did,
413
-
&public_key,
414
-
signed_record,
415
-
repository,
416
-
collection,
417
-
)?;
418
-
419
-
Ok(())
420
-
}
421
-
422
-
#[test]
423
-
fn test_multiple_signatures() -> Result<(), Box<dyn std::error::Error>> {
424
-
// Create a record with multiple signatures from different issuers
425
-
let private_key1 = generate_key(KeyType::P256Private)?;
426
-
let public_key1 = to_public(&private_key1)?;
427
-
428
-
let private_key2 = generate_key(KeyType::K256Private)?;
429
-
let public_key2 = to_public(&private_key2)?;
430
-
431
-
let record = json!({
432
-
"text": "Multi-signed content",
433
-
"important": true
434
-
});
435
-
436
-
let repository = "did:plc:repo_multi";
437
-
let collection = "app.example.document";
438
-
439
-
// First signature
440
-
let issuer1 = "did:plc:issuer1";
441
-
let sig_obj1 = json!({
442
-
"issuer": issuer1,
443
-
"issuedAt": "2025-01-19T09:00:00Z",
444
-
"role": "author"
445
-
});
446
-
447
-
let signed_once = create(&private_key1, &record, repository, collection, sig_obj1)?;
448
-
449
-
// Second signature on already signed record
450
-
let issuer2 = "did:plc:issuer2";
451
-
let sig_obj2 = json!({
452
-
"issuer": issuer2,
453
-
"issuedAt": "2025-01-19T10:00:00Z",
454
-
"role": "reviewer"
455
-
});
456
-
457
-
let signed_twice = create(
458
-
&private_key2,
459
-
&signed_once,
460
-
repository,
461
-
collection,
462
-
sig_obj2,
463
-
)?;
464
-
465
-
// Verify we have two signatures
466
-
let signatures = signed_twice
467
-
.get("signatures")
468
-
.and_then(|v| v.as_array())
469
-
.expect("signatures should exist");
470
-
assert_eq!(signatures.len(), 2);
471
-
472
-
// Verify both signatures independently
473
-
verify(
474
-
issuer1,
475
-
&public_key1,
476
-
signed_twice.clone(),
477
-
repository,
478
-
collection,
479
-
)?;
480
-
verify(
481
-
issuer2,
482
-
&public_key2,
483
-
signed_twice.clone(),
484
-
repository,
485
-
collection,
486
-
)?;
487
-
488
-
Ok(())
489
-
}
490
-
491
-
#[test]
492
-
fn test_verify_wrong_issuer_fails() -> Result<(), Box<dyn std::error::Error>> {
493
-
let private_key = generate_key(KeyType::P256Private)?;
494
-
let public_key = to_public(&private_key)?;
495
-
496
-
let record = json!({"test": "data"});
497
-
let repository = "did:plc:repo";
498
-
let collection = "app.test";
499
-
500
-
let sig_obj = json!({
501
-
"issuer": "did:plc:correct_issuer"
502
-
});
503
-
504
-
let signed = create(&private_key, &record, repository, collection, sig_obj)?;
505
-
506
-
// Try to verify with wrong issuer
507
-
let result = verify(
508
-
"did:plc:wrong_issuer",
509
-
&public_key,
510
-
signed,
511
-
repository,
512
-
collection,
513
-
);
514
-
515
-
assert!(result.is_err());
516
-
assert!(matches!(
517
-
result.unwrap_err(),
518
-
VerificationError::NoValidSignatureForIssuer { .. }
519
-
));
520
-
521
-
Ok(())
522
-
}
523
-
524
-
#[test]
525
-
fn test_verify_wrong_key_fails() -> Result<(), Box<dyn std::error::Error>> {
526
-
let private_key = generate_key(KeyType::P256Private)?;
527
-
let wrong_private_key = generate_key(KeyType::P256Private)?;
528
-
let wrong_public_key = to_public(&wrong_private_key)?;
529
-
530
-
let record = json!({"test": "data"});
531
-
let repository = "did:plc:repo";
532
-
let collection = "app.test";
533
-
let issuer = "did:plc:issuer";
534
-
535
-
let sig_obj = json!({ "issuer": issuer });
536
-
537
-
let signed = create(&private_key, &record, repository, collection, sig_obj)?;
538
-
539
-
// Try to verify with wrong key
540
-
let result = verify(issuer, &wrong_public_key, signed, repository, collection);
541
-
542
-
assert!(result.is_err());
543
-
assert!(matches!(
544
-
result.unwrap_err(),
545
-
VerificationError::CryptographicValidationFailed { .. }
546
-
));
547
-
548
-
Ok(())
549
-
}
550
-
551
-
#[test]
552
-
fn test_verify_tampered_record_fails() -> Result<(), Box<dyn std::error::Error>> {
553
-
let private_key = generate_key(KeyType::P256Private)?;
554
-
let public_key = to_public(&private_key)?;
555
-
556
-
let record = json!({"text": "original"});
557
-
let repository = "did:plc:repo";
558
-
let collection = "app.test";
559
-
let issuer = "did:plc:issuer";
560
-
561
-
let sig_obj = json!({ "issuer": issuer });
562
-
563
-
let mut signed = create(&private_key, &record, repository, collection, sig_obj)?;
564
-
565
-
// Tamper with the record content
566
-
if let Some(obj) = signed.as_object_mut() {
567
-
obj.insert("text".to_string(), json!("tampered"));
568
-
}
569
-
570
-
// Verification should fail
571
-
let result = verify(issuer, &public_key, signed, repository, collection);
572
-
573
-
assert!(result.is_err());
574
-
assert!(matches!(
575
-
result.unwrap_err(),
576
-
VerificationError::CryptographicValidationFailed { .. }
577
-
));
578
-
579
-
Ok(())
580
-
}
581
-
582
-
#[test]
583
-
fn test_create_missing_issuer_fails() -> Result<(), Box<dyn std::error::Error>> {
584
-
let private_key = generate_key(KeyType::P256Private)?;
585
-
586
-
let record = json!({"test": "data"});
587
-
let repository = "did:plc:repo";
588
-
let collection = "app.test";
589
-
590
-
// Signature object without issuer field
591
-
let sig_obj = json!({
592
-
"issuedAt": "2025-01-19T10:00:00Z"
593
-
});
594
-
595
-
let result = create(&private_key, &record, repository, collection, sig_obj);
596
-
597
-
assert!(result.is_err());
598
-
assert!(matches!(
599
-
result.unwrap_err(),
600
-
VerificationError::SignatureObjectMissingField { field } if field == "issuer"
601
-
));
602
-
603
-
Ok(())
604
-
}
605
-
606
-
#[test]
607
-
fn test_verify_supports_sigs_field() -> Result<(), Box<dyn std::error::Error>> {
608
-
// Test backward compatibility with "sigs" field name
609
-
let private_key = generate_key(KeyType::P256Private)?;
610
-
let public_key = to_public(&private_key)?;
611
-
612
-
let record = json!({"test": "data"});
613
-
let repository = "did:plc:repo";
614
-
let collection = "app.test";
615
-
let issuer = "did:plc:issuer";
616
-
617
-
let sig_obj = json!({ "issuer": issuer });
618
-
619
-
let mut signed = create(&private_key, &record, repository, collection, sig_obj)?;
620
-
621
-
// Rename "signatures" to "sigs"
622
-
if let Some(obj) = signed.as_object_mut()
623
-
&& let Some(signatures) = obj.remove("signatures")
624
-
{
625
-
obj.insert("sigs".to_string(), signatures);
626
-
}
627
-
628
-
// Should still verify successfully
629
-
verify(issuer, &public_key, signed, repository, collection)?;
630
-
631
-
Ok(())
632
-
}
633
-
634
-
#[test]
635
-
fn test_signature_preserves_original_record() -> Result<(), Box<dyn std::error::Error>> {
636
-
let private_key = generate_key(KeyType::P256Private)?;
637
-
638
-
let original_record = json!({
639
-
"text": "Original content",
640
-
"metadata": {
641
-
"author": "Test",
642
-
"version": 1
643
-
},
644
-
"tags": ["test", "sample"]
645
-
});
646
-
647
-
let repository = "did:plc:repo";
648
-
let collection = "app.test";
649
-
650
-
let sig_obj = json!({
651
-
"issuer": "did:plc:issuer"
652
-
});
653
-
654
-
let signed = create(
655
-
&private_key,
656
-
&original_record,
657
-
repository,
658
-
collection,
659
-
sig_obj,
660
-
)?;
661
-
662
-
// All original fields should be preserved
663
-
assert_eq!(signed.get("text"), original_record.get("text"));
664
-
assert_eq!(signed.get("metadata"), original_record.get("metadata"));
665
-
assert_eq!(signed.get("tags"), original_record.get("tags"));
666
-
667
-
// Plus the new signatures field
668
-
assert!(signed.get("signatures").is_some());
669
-
670
-
Ok(())
671
-
}
672
-
}
+492
crates/atproto-record/src/tid.rs
+492
crates/atproto-record/src/tid.rs
···
1
+
//! Timestamp Identifier (TID) generation and parsing.
2
+
//!
3
+
//! TIDs are 64-bit integers encoded as 13-character base32-sortable strings, combining
4
+
//! a microsecond timestamp with a random clock identifier for collision resistance.
5
+
//! They provide a sortable, distributed identifier scheme for AT Protocol records.
6
+
//!
7
+
//! ## Format
8
+
//!
9
+
//! - **Length**: Always 13 ASCII characters
10
+
//! - **Encoding**: Base32-sortable character set (`234567abcdefghijklmnopqrstuvwxyz`)
11
+
//! - **Structure**: 64-bit big-endian integer with:
12
+
//! - Bit 0 (top): Always 0
13
+
//! - Bits 1-53: Microseconds since UNIX epoch
14
+
//! - Bits 54-63: Random 10-bit clock identifier
15
+
//!
16
+
//! ## Example
17
+
//!
18
+
//! ```
19
+
//! use atproto_record::tid::Tid;
20
+
//!
21
+
//! // Generate a new TID
22
+
//! let tid = Tid::new();
23
+
//! let tid_str = tid.to_string();
24
+
//! assert_eq!(tid_str.len(), 13);
25
+
//!
26
+
//! // Parse a TID string
27
+
//! let parsed = tid_str.parse::<Tid>().unwrap();
28
+
//! assert_eq!(tid, parsed);
29
+
//!
30
+
//! // TIDs are sortable by timestamp
31
+
//! let tid1 = Tid::new();
32
+
//! std::thread::sleep(std::time::Duration::from_micros(10));
33
+
//! let tid2 = Tid::new();
34
+
//! assert!(tid1 < tid2);
35
+
//! ```
36
+
37
+
use std::fmt;
38
+
use std::str::FromStr;
39
+
use std::sync::Mutex;
40
+
use std::time::{SystemTime, UNIX_EPOCH};
41
+
42
+
use crate::errors::TidError;
43
+
44
+
/// Base32-sortable character set for TID encoding.
45
+
///
46
+
/// This character set maintains lexicographic sort order when encoded TIDs
47
+
/// are compared as strings, ensuring timestamp ordering is preserved.
48
+
const BASE32_SORTABLE: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
49
+
50
+
/// Reverse lookup table for base32-sortable decoding.
51
+
///
52
+
/// Maps ASCII character values to their corresponding 5-bit values.
53
+
/// Invalid characters are marked with 0xFF.
54
+
const BASE32_DECODE: [u8; 256] = {
55
+
let mut table = [0xFF; 256];
56
+
table[b'2' as usize] = 0;
57
+
table[b'3' as usize] = 1;
58
+
table[b'4' as usize] = 2;
59
+
table[b'5' as usize] = 3;
60
+
table[b'6' as usize] = 4;
61
+
table[b'7' as usize] = 5;
62
+
table[b'a' as usize] = 6;
63
+
table[b'b' as usize] = 7;
64
+
table[b'c' as usize] = 8;
65
+
table[b'd' as usize] = 9;
66
+
table[b'e' as usize] = 10;
67
+
table[b'f' as usize] = 11;
68
+
table[b'g' as usize] = 12;
69
+
table[b'h' as usize] = 13;
70
+
table[b'i' as usize] = 14;
71
+
table[b'j' as usize] = 15;
72
+
table[b'k' as usize] = 16;
73
+
table[b'l' as usize] = 17;
74
+
table[b'm' as usize] = 18;
75
+
table[b'n' as usize] = 19;
76
+
table[b'o' as usize] = 20;
77
+
table[b'p' as usize] = 21;
78
+
table[b'q' as usize] = 22;
79
+
table[b'r' as usize] = 23;
80
+
table[b's' as usize] = 24;
81
+
table[b't' as usize] = 25;
82
+
table[b'u' as usize] = 26;
83
+
table[b'v' as usize] = 27;
84
+
table[b'w' as usize] = 28;
85
+
table[b'x' as usize] = 29;
86
+
table[b'y' as usize] = 30;
87
+
table[b'z' as usize] = 31;
88
+
table
89
+
};
90
+
91
+
/// Timestamp Identifier (TID) for AT Protocol records.
92
+
///
93
+
/// A TID combines a microsecond-precision timestamp with a random clock identifier
94
+
/// to create a sortable, collision-resistant identifier. TIDs are represented as
95
+
/// 13-character base32-sortable strings.
96
+
///
97
+
/// ## Monotonicity
98
+
///
99
+
/// The TID generator ensures monotonically increasing values even when the system
100
+
/// clock moves backwards or multiple TIDs are generated within the same microsecond.
101
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
102
+
pub struct Tid(u64);
103
+
104
+
/// Thread-local state for monotonic TID generation.
105
+
///
106
+
/// Tracks the last generated timestamp and clock identifier to ensure
107
+
/// monotonically increasing TID values.
108
+
static LAST_TID: Mutex<Option<(u64, u16)>> = Mutex::new(None);
109
+
110
+
impl Tid {
111
+
/// The length of a TID string in characters.
112
+
pub const LENGTH: usize = 13;
113
+
114
+
/// Maximum valid timestamp value (53 bits).
115
+
const MAX_TIMESTAMP: u64 = (1u64 << 53) - 1;
116
+
117
+
/// Bitmask for extracting the 10-bit clock identifier.
118
+
const CLOCK_ID_MASK: u64 = 0x3FF;
119
+
120
+
/// Creates a new TID with the current timestamp and a random clock identifier.
121
+
///
122
+
/// This function ensures monotonically increasing TID values by tracking the
123
+
/// last generated TID and incrementing the clock identifier when necessary.
124
+
///
125
+
/// # Example
126
+
///
127
+
/// ```
128
+
/// use atproto_record::tid::Tid;
129
+
///
130
+
/// let tid = Tid::new();
131
+
/// println!("Generated TID: {}", tid);
132
+
/// ```
133
+
pub fn new() -> Self {
134
+
Self::new_with_time(Self::current_timestamp_micros())
135
+
}
136
+
137
+
/// Creates a new TID with a specific timestamp (for testing).
138
+
///
139
+
/// # Arguments
140
+
///
141
+
/// * `timestamp_micros` - Microseconds since UNIX epoch
142
+
///
143
+
/// # Panics
144
+
///
145
+
/// Panics if the timestamp exceeds 53 bits (year 2255+).
146
+
pub fn new_with_time(timestamp_micros: u64) -> Self {
147
+
assert!(
148
+
timestamp_micros <= Self::MAX_TIMESTAMP,
149
+
"Timestamp exceeds 53-bit maximum"
150
+
);
151
+
152
+
let mut last = LAST_TID.lock().unwrap();
153
+
154
+
let clock_id = if let Some((last_timestamp, last_clock)) = *last {
155
+
if timestamp_micros > last_timestamp {
156
+
// New timestamp, generate random clock ID
157
+
Self::random_clock_id()
158
+
} else if timestamp_micros == last_timestamp {
159
+
// Same timestamp, increment clock ID
160
+
if last_clock == Self::CLOCK_ID_MASK as u16 {
161
+
// Clock ID overflow, use random
162
+
Self::random_clock_id()
163
+
} else {
164
+
last_clock + 1
165
+
}
166
+
} else {
167
+
// Clock moved backwards, use last timestamp + 1
168
+
let adjusted_timestamp = last_timestamp + 1;
169
+
let adjusted_clock = Self::random_clock_id();
170
+
*last = Some((adjusted_timestamp, adjusted_clock));
171
+
return Self::from_parts(adjusted_timestamp, adjusted_clock);
172
+
}
173
+
} else {
174
+
// First TID, generate random clock ID
175
+
Self::random_clock_id()
176
+
};
177
+
178
+
*last = Some((timestamp_micros, clock_id));
179
+
Self::from_parts(timestamp_micros, clock_id)
180
+
}
181
+
182
+
/// Creates a TID from timestamp and clock identifier components.
183
+
///
184
+
/// # Arguments
185
+
///
186
+
/// * `timestamp_micros` - Microseconds since UNIX epoch (53 bits max)
187
+
/// * `clock_id` - Random clock identifier (10 bits max)
188
+
///
189
+
/// # Panics
190
+
///
191
+
/// Panics if timestamp exceeds 53 bits or clock_id exceeds 10 bits.
192
+
pub fn from_parts(timestamp_micros: u64, clock_id: u16) -> Self {
193
+
assert!(
194
+
timestamp_micros <= Self::MAX_TIMESTAMP,
195
+
"Timestamp exceeds 53-bit maximum"
196
+
);
197
+
assert!(
198
+
clock_id <= Self::CLOCK_ID_MASK as u16,
199
+
"Clock ID exceeds 10-bit maximum"
200
+
);
201
+
202
+
// Combine: top bit 0, 53 bits timestamp, 10 bits clock ID
203
+
let value = (timestamp_micros << 10) | (clock_id as u64);
204
+
Tid(value)
205
+
}
206
+
207
+
/// Returns the timestamp component in microseconds since UNIX epoch.
208
+
///
209
+
/// # Example
210
+
///
211
+
/// ```
212
+
/// use atproto_record::tid::Tid;
213
+
///
214
+
/// let tid = Tid::new();
215
+
/// let timestamp = tid.timestamp_micros();
216
+
/// println!("Timestamp: {} μs", timestamp);
217
+
/// ```
218
+
pub fn timestamp_micros(&self) -> u64 {
219
+
self.0 >> 10
220
+
}
221
+
222
+
/// Returns the clock identifier component (10 bits).
223
+
///
224
+
/// # Example
225
+
///
226
+
/// ```
227
+
/// use atproto_record::tid::Tid;
228
+
///
229
+
/// let tid = Tid::new();
230
+
/// let clock_id = tid.clock_id();
231
+
/// println!("Clock ID: {}", clock_id);
232
+
/// ```
233
+
pub fn clock_id(&self) -> u16 {
234
+
(self.0 & Self::CLOCK_ID_MASK) as u16
235
+
}
236
+
237
+
/// Returns the raw 64-bit integer value.
238
+
pub fn as_u64(&self) -> u64 {
239
+
self.0
240
+
}
241
+
242
+
/// Encodes the TID as a 13-character base32-sortable string.
243
+
///
244
+
/// # Example
245
+
///
246
+
/// ```
247
+
/// use atproto_record::tid::Tid;
248
+
///
249
+
/// let tid = Tid::new();
250
+
/// let encoded = tid.encode();
251
+
/// assert_eq!(encoded.len(), 13);
252
+
/// ```
253
+
pub fn encode(&self) -> String {
254
+
let mut chars = [0u8; Self::LENGTH];
255
+
let mut value = self.0;
256
+
257
+
// Encode from right to left (least significant to most significant)
258
+
for i in (0..Self::LENGTH).rev() {
259
+
chars[i] = BASE32_SORTABLE[(value & 0x1F) as usize];
260
+
value >>= 5;
261
+
}
262
+
263
+
// BASE32_SORTABLE only contains valid UTF-8 ASCII characters
264
+
String::from_utf8(chars.to_vec()).expect("base32-sortable encoding is always valid UTF-8")
265
+
}
266
+
267
+
/// Decodes a base32-sortable string into a TID.
268
+
///
269
+
/// # Errors
270
+
///
271
+
/// Returns [`TidError::InvalidLength`] if the string is not exactly 13 characters.
272
+
/// Returns [`TidError::InvalidCharacter`] if the string contains invalid characters.
273
+
/// Returns [`TidError::InvalidFormat`] if the decoded value has the top bit set.
274
+
///
275
+
/// # Example
276
+
///
277
+
/// ```
278
+
/// use atproto_record::tid::Tid;
279
+
///
280
+
/// let tid_str = "3jzfcijpj2z2a";
281
+
/// let tid = Tid::decode(tid_str).unwrap();
282
+
/// assert_eq!(tid.to_string(), tid_str);
283
+
/// ```
284
+
pub fn decode(s: &str) -> Result<Self, TidError> {
285
+
if s.len() != Self::LENGTH {
286
+
return Err(TidError::InvalidLength {
287
+
expected: Self::LENGTH,
288
+
actual: s.len(),
289
+
});
290
+
}
291
+
292
+
let bytes = s.as_bytes();
293
+
let mut value: u64 = 0;
294
+
295
+
for (i, &byte) in bytes.iter().enumerate() {
296
+
let decoded = BASE32_DECODE[byte as usize];
297
+
if decoded == 0xFF {
298
+
return Err(TidError::InvalidCharacter {
299
+
character: byte as char,
300
+
position: i,
301
+
});
302
+
}
303
+
value = (value << 5) | (decoded as u64);
304
+
}
305
+
306
+
// Verify top bit is 0
307
+
if value & (1u64 << 63) != 0 {
308
+
return Err(TidError::InvalidFormat {
309
+
reason: "Top bit must be 0".to_string(),
310
+
});
311
+
}
312
+
313
+
Ok(Tid(value))
314
+
}
315
+
316
+
/// Gets the current timestamp in microseconds since UNIX epoch.
317
+
fn current_timestamp_micros() -> u64 {
318
+
SystemTime::now()
319
+
.duration_since(UNIX_EPOCH)
320
+
.expect("System time before UNIX epoch")
321
+
.as_micros() as u64
322
+
}
323
+
324
+
/// Generates a random 10-bit clock identifier.
325
+
fn random_clock_id() -> u16 {
326
+
use rand::RngCore;
327
+
let mut rng = rand::thread_rng();
328
+
(rng.next_u32() as u16) & (Self::CLOCK_ID_MASK as u16)
329
+
}
330
+
}
331
+
332
+
impl Default for Tid {
333
+
fn default() -> Self {
334
+
Self::new()
335
+
}
336
+
}
337
+
338
+
impl fmt::Display for Tid {
339
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340
+
write!(f, "{}", self.encode())
341
+
}
342
+
}
343
+
344
+
impl FromStr for Tid {
345
+
type Err = TidError;
346
+
347
+
fn from_str(s: &str) -> Result<Self, Self::Err> {
348
+
Self::decode(s)
349
+
}
350
+
}
351
+
352
+
impl serde::Serialize for Tid {
353
+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
354
+
where
355
+
S: serde::Serializer,
356
+
{
357
+
serializer.serialize_str(&self.encode())
358
+
}
359
+
}
360
+
361
+
impl<'de> serde::Deserialize<'de> for Tid {
362
+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363
+
where
364
+
D: serde::Deserializer<'de>,
365
+
{
366
+
let s = String::deserialize(deserializer)?;
367
+
Self::decode(&s).map_err(serde::de::Error::custom)
368
+
}
369
+
}
370
+
371
+
#[cfg(test)]
372
+
mod tests {
373
+
use super::*;
374
+
375
+
#[test]
376
+
fn test_tid_encode_decode() {
377
+
let tid = Tid::new();
378
+
let encoded = tid.encode();
379
+
assert_eq!(encoded.len(), Tid::LENGTH);
380
+
381
+
let decoded = Tid::decode(&encoded).unwrap();
382
+
assert_eq!(tid, decoded);
383
+
}
384
+
385
+
#[test]
386
+
fn test_tid_from_parts() {
387
+
let timestamp = 1234567890123456u64;
388
+
let clock_id = 42u16;
389
+
let tid = Tid::from_parts(timestamp, clock_id);
390
+
391
+
assert_eq!(tid.timestamp_micros(), timestamp);
392
+
assert_eq!(tid.clock_id(), clock_id);
393
+
}
394
+
395
+
#[test]
396
+
fn test_tid_monotonic() {
397
+
let tid1 = Tid::new();
398
+
std::thread::sleep(std::time::Duration::from_micros(10));
399
+
let tid2 = Tid::new();
400
+
401
+
assert!(tid1 < tid2);
402
+
}
403
+
404
+
#[test]
405
+
fn test_tid_same_timestamp() {
406
+
let timestamp = 1234567890123456u64;
407
+
let tid1 = Tid::new_with_time(timestamp);
408
+
let tid2 = Tid::new_with_time(timestamp);
409
+
410
+
// Should have different clock IDs or incremented clock ID
411
+
assert!(tid1 < tid2 || tid1.clock_id() + 1 == tid2.clock_id());
412
+
}
413
+
414
+
#[test]
415
+
fn test_tid_string_roundtrip() {
416
+
let tid = Tid::new();
417
+
let s = tid.to_string();
418
+
let parsed: Tid = s.parse().unwrap();
419
+
assert_eq!(tid, parsed);
420
+
}
421
+
422
+
#[test]
423
+
fn test_tid_serde() {
424
+
let tid = Tid::new();
425
+
let json = serde_json::to_string(&tid).unwrap();
426
+
let parsed: Tid = serde_json::from_str(&json).unwrap();
427
+
assert_eq!(tid, parsed);
428
+
}
429
+
430
+
#[test]
431
+
fn test_tid_valid_examples() {
432
+
// Examples from the specification
433
+
let examples = ["3jzfcijpj2z2a", "7777777777777", "2222222222222"];
434
+
435
+
for example in &examples {
436
+
let tid = Tid::decode(example).unwrap();
437
+
assert_eq!(&tid.encode(), example);
438
+
}
439
+
}
440
+
441
+
#[test]
442
+
fn test_tid_invalid_length() {
443
+
let result = Tid::decode("123");
444
+
assert!(matches!(result, Err(TidError::InvalidLength { .. })));
445
+
}
446
+
447
+
#[test]
448
+
fn test_tid_invalid_character() {
449
+
let result = Tid::decode("123456789012!");
450
+
assert!(matches!(result, Err(TidError::InvalidCharacter { .. })));
451
+
}
452
+
453
+
#[test]
454
+
fn test_tid_first_char_range() {
455
+
// First character must be in valid range per spec
456
+
let tid = Tid::new();
457
+
let encoded = tid.encode();
458
+
let first_char = encoded.chars().next().unwrap();
459
+
460
+
// First char must be 234567abcdefghij (values 0-15 in base32-sortable)
461
+
assert!("234567abcdefghij".contains(first_char));
462
+
}
463
+
464
+
#[test]
465
+
fn test_tid_sortability() {
466
+
// TIDs with increasing timestamps should sort correctly as strings
467
+
let tid1 = Tid::from_parts(1000000, 0);
468
+
let tid2 = Tid::from_parts(2000000, 0);
469
+
let tid3 = Tid::from_parts(3000000, 0);
470
+
471
+
let s1 = tid1.to_string();
472
+
let s2 = tid2.to_string();
473
+
let s3 = tid3.to_string();
474
+
475
+
assert!(s1 < s2);
476
+
assert!(s2 < s3);
477
+
assert!(s1 < s3);
478
+
}
479
+
480
+
#[test]
481
+
fn test_tid_clock_backward() {
482
+
// Simulate clock moving backwards
483
+
let timestamp1 = 2000000u64;
484
+
let tid1 = Tid::new_with_time(timestamp1);
485
+
486
+
let timestamp2 = 1000000u64; // Earlier timestamp
487
+
let tid2 = Tid::new_with_time(timestamp2);
488
+
489
+
// TID should still be monotonically increasing
490
+
assert!(tid2 > tid1);
491
+
}
492
+
}
+16
-12
crates/atproto-xrpcs-helloworld/src/main.rs
+16
-12
crates/atproto-xrpcs-helloworld/src/main.rs
···
5
5
use atproto_identity::resolve::SharedIdentityResolver;
6
6
use atproto_identity::{
7
7
config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version},
8
-
key::{KeyData, KeyProvider, identify_key, to_public},
8
+
key::{KeyData, KeyResolver, identify_key, to_public},
9
9
resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver},
10
-
storage::DidDocumentStorage,
11
10
storage_lru::LruDidDocumentStorage,
11
+
traits::DidDocumentStorage,
12
12
};
13
13
use atproto_xrpcs::authorization::ResolvingAuthorization;
14
14
use axum::{
···
24
24
use std::{collections::HashMap, num::NonZeroUsize, ops::Deref, sync::Arc};
25
25
26
26
#[derive(Clone)]
27
-
pub struct SimpleKeyProvider {
27
+
pub struct SimpleKeyResolver {
28
28
keys: HashMap<String, KeyData>,
29
29
}
30
30
31
-
impl Default for SimpleKeyProvider {
31
+
impl Default for SimpleKeyResolver {
32
32
fn default() -> Self {
33
33
Self::new()
34
34
}
35
35
}
36
36
37
-
impl SimpleKeyProvider {
37
+
impl SimpleKeyResolver {
38
38
pub fn new() -> Self {
39
39
Self {
40
40
keys: HashMap::new(),
···
43
43
}
44
44
45
45
#[async_trait]
46
-
impl KeyProvider for SimpleKeyProvider {
47
-
async fn get_private_key_by_id(&self, key_id: &str) -> anyhow::Result<Option<KeyData>> {
48
-
Ok(self.keys.get(key_id).cloned())
46
+
impl KeyResolver for SimpleKeyResolver {
47
+
async fn resolve(&self, key: &str) -> anyhow::Result<KeyData> {
48
+
if let Some(key_data) = self.keys.get(key) {
49
+
Ok(key_data.clone())
50
+
} else {
51
+
identify_key(key).map_err(Into::into)
52
+
}
49
53
}
50
54
}
51
55
···
58
62
pub struct InnerWebContext {
59
63
pub http_client: reqwest::Client,
60
64
pub document_storage: Arc<dyn DidDocumentStorage>,
61
-
pub key_provider: Arc<dyn KeyProvider>,
65
+
pub key_resolver: Arc<dyn KeyResolver>,
62
66
pub service_document: ServiceDocument,
63
67
pub service_did: ServiceDID,
64
68
pub identity_resolver: Arc<dyn IdentityResolver>,
···
99
103
}
100
104
}
101
105
102
-
impl FromRef<WebContext> for Arc<dyn KeyProvider> {
106
+
impl FromRef<WebContext> for Arc<dyn KeyResolver> {
103
107
fn from_ref(context: &WebContext) -> Self {
104
-
context.0.key_provider.clone()
108
+
context.0.key_resolver.clone()
105
109
}
106
110
}
107
111
···
213
217
let web_context = WebContext(Arc::new(InnerWebContext {
214
218
http_client: http_client.clone(),
215
219
document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())),
216
-
key_provider: Arc::new(SimpleKeyProvider {
220
+
key_resolver: Arc::new(SimpleKeyResolver {
217
221
keys: signing_key_storage,
218
222
}),
219
223
service_document,