use anyhow::Result;
use atproto_identity::{
key::{KeyData, identify_key},
resolve::{InputType, parse_input},
};
use atproto_record::errors::CliError;
use atproto_record::signature::verify;
use clap::Parser;
use std::{
fs,
io::{self, Read},
};
#[derive(Parser)]
#[command(
name = "atproto-record-verify",
version,
about = "Verify cryptographic signatures of AT Protocol records",
long_about = "
A command-line tool for verifying cryptographic signatures of AT Protocol records.
Reads a signed JSON record from a file or stdin, validates the embedded signatures
using a public key, and reports verification success or failure.
The tool accepts flexible argument ordering with issuer DIDs, verification keys,
record inputs, and key=value parameters for repository and collection context.
REQUIRED PARAMETERS:
repository=<DID> Repository context used during signing
collection=<name> Collection type context used during signing
EXAMPLES:
# Basic verification:
atproto-record-verify \\
did:plc:tgudj2fjm77pzkuawquqhsxm \\
did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\
./signed_post.json \\
repository=did:plc:4zutorghlchjxzgceklue4la \\
collection=app.bsky.feed.post
# Verify from stdin:
echo '{\"signatures\":[...]}' | atproto-record-verify \\
did:plc:issuer... did:key:z42tv1pb3... -- \\
repository=did:plc:repo... collection=app.bsky.feed.post
VERIFICATION PROCESS:
- Extracts signatures from the signatures array
- Finds signatures matching the specified issuer DID
- Reconstructs $sig object with repository and collection context
- Validates ECDSA signatures using P-256 or K-256 curves
"
)]
struct Args {
args: Vec<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
let arguments = args.args.into_iter();
let mut collection: Option<String> = None;
let mut repository: Option<String> = None;
let mut record: Option<serde_json::Value> = None;
let mut issuer: Option<String> = None;
let mut key_data: Option<KeyData> = None;
for argument in arguments {
if let Some((key, value)) = argument.split_once("=") {
match key {
"collection" => {
collection = Some(value.to_string());
}
"repository" => {
repository = Some(value.to_string());
}
_ => {}
}
} else if argument.starts_with("did:key:") {
key_data = Some(identify_key(&argument)?);
} else if argument.starts_with("did:") {
match parse_input(&argument) {
Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => {
issuer = Some(did);
}
Ok(_) => {
return Err(CliError::UnsupportedDidMethod {
method: argument.clone(),
}
.into());
}
Err(_) => {
return Err(CliError::DidParseFailed {
did: argument.clone(),
}
.into());
}
}
} else if argument == "--" {
if record.is_none() {
let mut stdin_content = String::new();
io::stdin()
.read_to_string(&mut stdin_content)
.map_err(|_| CliError::StdinReadFailed)?;
record = Some(
serde_json::from_str(&stdin_content)
.map_err(|_| CliError::StdinJsonParseFailed)?,
);
} else {
return Err(CliError::UnexpectedArgument {
argument: argument.clone(),
}
.into());
}
} else {
if record.is_none() {
let file_content =
fs::read_to_string(&argument).map_err(|_| CliError::FileReadFailed {
path: argument.clone(),
})?;
record = Some(serde_json::from_str(&file_content).map_err(|_| {
CliError::FileJsonParseFailed {
path: argument.clone(),
}
})?);
} else {
return Err(CliError::UnexpectedArgument {
argument: argument.clone(),
}
.into());
}
}
}
let collection = collection.ok_or(CliError::MissingRequiredValue {
name: "collection".to_string(),
})?;
let repository = repository.ok_or(CliError::MissingRequiredValue {
name: "repository".to_string(),
})?;
let record = record.ok_or(CliError::MissingRequiredValue {
name: "record".to_string(),
})?;
let issuer = issuer.ok_or(CliError::MissingRequiredValue {
name: "issuer".to_string(),
})?;
let key_data = key_data.ok_or(CliError::MissingRequiredValue {
name: "key".to_string(),
})?;
verify(&issuer, &key_data, record, &repository, &collection).await?;
println!("OK");
Ok(())
}