atproto-attestation#
Utilities for preparing, signing, and verifying AT Protocol record attestations using the CID-first workflow.
Overview#
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).
The attestation workflow ensures deterministic signing payloads by:
- Preparing records with
$sigmetadata - Generating content identifiers (CIDs) using DAG-CBOR serialization
- Signing CID bytes with elliptic curve cryptography
- Embedding or referencing signatures in records
- Verifying signatures against resolved public keys
Features#
- Inline attestations: Embed cryptographic signatures directly in record structures
- Remote attestations: Create separate proof records with CID-based strongRef references
- CID-first workflow: Deterministic signing based on content identifiers
- Multi-curve support: Full support for P-256, P-384, and K-256 elliptic curves
- Signature normalization: Automatic low-S normalization for ECDSA signatures
- Key resolution: Resolve verification keys from DID documents or did:key identifiers
- Flexible verification: Verify individual signatures or all signatures in a record
- Structured reporting: Detailed verification reports with success/failure status
CLI Tools#
The following command-line tools are available when built with the clap and tokio features:
atproto-attestation-sign: Sign AT Protocol records with inline or remote attestationsatproto-attestation-verify: Verify cryptographic signatures on AT Protocol records
Library Usage#
Creating Inline Attestations#
Inline attestations embed the signature bytes directly in the record:
use atproto_identity::key::{identify_key, to_public};
use atproto_attestation::create_inline_attestation;
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Parse the signing key from a did:key
let private_key = identify_key("did:key:zQ3sh...")?;
let public_key = to_public(&private_key)?;
let key_reference = format!("{}", &public_key);
// The record to sign
let record = json!({
"$type": "app.bsky.feed.post",
"text": "Hello world!",
"createdAt": "2024-01-01T00:00:00.000Z"
});
// Attestation metadata (required fields: $type, key)
let sig_metadata = json!({
"$type": "com.example.inlineSignature",
"key": &key_reference,
"issuer": "did:plc:issuer123",
"issuedAt": "2024-01-01T00:00:00.000Z"
});
// Create inline attestation
let signed_record = create_inline_attestation(&record, &sig_metadata, &private_key)?;
println!("{}", serde_json::to_string_pretty(&signed_record)?);
Ok(())
}
The resulting record will have a signatures array:
{
"$type": "app.bsky.feed.post",
"text": "Hello world!",
"createdAt": "2024-01-01T00:00:00.000Z",
"signatures": [
{
"$type": "com.example.inlineSignature",
"key": "did:key:zQ3sh...",
"issuer": "did:plc:issuer123",
"issuedAt": "2024-01-01T00:00:00.000Z",
"signature": {
"$bytes": "base64-encoded-signature-bytes"
}
}
]
}
Creating Remote Attestations#
Remote attestations create a separate proof record that must be stored in a repository:
use atproto_attestation::{create_remote_attestation, create_remote_attestation_reference};
use serde_json::json;
let record = json!({
"$type": "app.bsky.feed.post",
"text": "Hello world!"
});
let metadata = json!({
"$type": "com.example.attestation",
"issuer": "did:plc:issuer123",
"purpose": "verification"
});
// Create the proof record (contains the CID)
let proof_record = create_remote_attestation(&record, &metadata)?;
// Create the source record with strongRef
let repository_did = "did:plc:repo123";
let attested_record = create_remote_attestation_reference(
&record,
&proof_record,
repository_did
)?;
// The proof_record should be stored in the repository
// The attested_record contains the strongRef reference
Verifying Signatures#
Verify signatures embedded in records:
use atproto_attestation::{verify_all_signatures, VerificationStatus};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Signed record with signatures array
let signed_record = /* ... */;
// Verify all signatures (remote attestations will be unverified)
let reports = verify_all_signatures(&signed_record, None).await?;
for report in reports {
match report.status {
VerificationStatus::Valid { cid } => {
println!("✓ Signature {} is valid (CID: {})", report.index, cid);
}
VerificationStatus::Invalid { error } => {
println!("✗ Signature {} is invalid: {}", report.index, error);
}
VerificationStatus::Unverified { reason } => {
println!("? Signature {} unverified: {}", report.index, reason);
}
}
}
Ok(())
}
Verifying with Custom Key Resolver#
For signatures that reference DID document keys (not did:key), provide a key resolver:
use atproto_attestation::verify_all_signatures;
use atproto_identity::key::IdentityDocumentKeyResolver;
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let http_client = reqwest::Client::new();
let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
// Create identity and key resolvers
let identity_resolver = Arc::new(InnerIdentityResolver {
http_client: http_client.clone(),
dns_resolver: Arc::new(dns_resolver),
plc_hostname: "plc.directory".to_string(),
});
let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver);
let signed_record = /* ... */;
// Verify with key resolver for DID document keys
let reports = verify_all_signatures(&signed_record, Some(&key_resolver)).await?;
Ok(())
}
Verifying Remote Attestations#
To verify remote attestations (strongRef), use verify_all_signatures_with_resolver and provide a RecordResolver that can fetch proof records:
use atproto_attestation::verify_all_signatures_with_resolver;
use atproto_client::record_resolver::RecordResolver;
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
use atproto_identity::traits::IdentityResolver;
use std::sync::Arc;
// Custom record resolver that resolves DIDs to find PDS endpoints
struct MyRecordResolver {
http_client: reqwest::Client,
identity_resolver: InnerIdentityResolver,
}
#[async_trait::async_trait]
impl RecordResolver for MyRecordResolver {
async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T>
where
T: serde::de::DeserializeOwned + Send,
{
// Parse AT-URI, resolve DID to PDS, fetch record
// See atproto-attestation-verify.rs for full implementation
todo!()
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let http_client = reqwest::Client::new();
let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
let identity_resolver = InnerIdentityResolver {
http_client: http_client.clone(),
dns_resolver: Arc::new(dns_resolver),
plc_hostname: "plc.directory".to_string(),
};
let record_resolver = MyRecordResolver {
http_client,
identity_resolver,
};
let signed_record = /* ... */;
// Verify all signatures including remote attestations
let reports = verify_all_signatures_with_resolver(&signed_record, None, Some(&record_resolver)).await?;
Ok(())
}
Manual CID Generation#
For advanced use cases, manually generate CIDs:
use atproto_attestation::{prepare_signing_record, create_cid};
use serde_json::json;
let record = json!({
"$type": "app.bsky.feed.post",
"text": "Manual CID generation"
});
let metadata = json!({
"$type": "com.example.signature",
"key": "did:key:z..."
});
// Prepare the signing record (adds $sig, removes signatures)
let signing_record = prepare_signing_record(&record, &metadata)?;
// Generate the CID
let cid = create_cid(&signing_record)?;
println!("CID: {}", cid);
Command Line Usage#
Signing Records#
Inline Attestation#
# Sign with inline attestation (signature embedded in record)
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \
inline \
record.json \
did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA \
metadata.json
# Using JSON strings instead of files
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \
inline \
'{"$type":"app.bsky.feed.post","text":"Hello!"}' \
did:key:zQ3sh... \
'{"$type":"com.example.sig","key":"did:key:zQ3sh..."}'
# Read record from stdin
cat record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \
inline \
- \
did:key:zQ3sh... \
metadata.json
Remote Attestation#
# Create remote attestation (generates proof record + strongRef)
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \
remote \
record.json \
did:plc:repo123... \
metadata.json
# This outputs TWO JSON objects:
# 1. Proof record (store this in the repository)
# 2. Source record with strongRef attestation
Verifying Signatures#
Verify All Signatures in a Record#
# Verify all signatures in a record from file
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
./signed_record.json
# Verify all signatures from AT-URI (fetches from PDS)
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g
# Verify from stdin
cat signed_record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- -
# Verify from inline JSON
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
'{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}'
# Output shows each signature status:
# ✓ Signature 0 valid (key: did:key:zQ3sh...pb3) [CID: bafyrei...]
# ? Signature 1 unverified: Remote attestations require fetching the proof record via strongRef.
#
# Summary: 2 total, 1 valid
Verify Specific Attestation Against Record#
# Verify a specific attestation record (both from files)
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
./record.json \
./attestation.json
# Verify attestation from AT-URI against local record
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \
./record.json \
at://did:plc:xyz/com.example.attestation/abc123
# On success, outputs:
# OK
# CID: bafyrei...
Attestation Specification#
This crate implements the CID-first attestation specification, which ensures:
- Deterministic signing: Records are serialized to DAG-CBOR with
$sigmetadata, producing consistent CIDs - Content addressing: Signatures are over CID bytes, not the full record
- Flexible metadata: Custom fields in
$sigare preserved and included in the CID calculation - Signature normalization: ECDSA signatures are normalized to low-S form
- Multiple attestations: Records can have multiple signatures in the
signaturesarray
Signature Structure#
Inline attestation entry:
{
"$type": "com.example.signature",
"key": "did:key:z...",
"issuer": "did:plc:...",
"signature": {
"$bytes": "base64-signature"
}
}
Remote attestation entry (strongRef):
{
"$type": "com.atproto.repo.strongRef",
"uri": "at://did:plc:repo/com.example.attestation/tid",
"cid": "bafyrei..."
}
Error Handling#
The crate provides structured error types via AttestationError:
RecordMustBeObject: Input must be a JSON objectMetadataMustBeObject: Attestation metadata must be a JSON objectSigMetadataMissing: No$sigfield found in prepared recordSignatureCreationFailed: Key signing operation failedSignatureValidationFailed: Signature verification failedSignatureNotNormalized: ECDSA signature not in low-S formKeyResolutionFailed: Could not resolve verification keyUnsupportedKeyType: Key type not supported for signing/verification
Security Considerations#
- Key management: Private keys should be protected and never logged or transmitted
- Signature normalization: All signatures are normalized to low-S form to prevent malleability
- CID verification: Always verify signatures against the reconstructed CID, not the record content
- Key resolution: Use trusted key resolvers to prevent key substitution attacks
- Timestamp validation: Check
issuedAtandexpiryfields if present in metadata
License#
MIT License