use anyhow::Result;
use atproto_identity::{
key::{KeyData, identify_key},
resolve::{InputType, parse_input},
};
use atproto_record::errors::CliError;
use atproto_record::signature::create;
use clap::Parser;
use serde_json::json;
use std::{
collections::HashMap,
fs,
io::{self, Read},
};
#[derive(Parser)]
#[command(
name = "atproto-record-sign",
version,
about = "Sign AT Protocol records with cryptographic signatures",
long_about = "
A command-line tool for signing AT Protocol records using DID keys. Reads a JSON
record from a file or stdin, applies a cryptographic signature, and outputs the
signed record with embedded signature metadata.
The tool accepts flexible argument ordering with DID keys, issuer DIDs, record
inputs, and key=value parameters for repository, collection, and custom metadata.
REQUIRED PARAMETERS:
repository=<DID> Repository context for the signature
collection=<name> Collection type context for the signature
OPTIONAL PARAMETERS:
Any additional key=value pairs are included in the signature metadata
(e.g., issuedAt=<timestamp>, purpose=<string>, expiry=<timestamp>)
EXAMPLES:
# Basic usage:
atproto-record-sign \\
did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\
./post.json \\
did:plc:tgudj2fjm77pzkuawquqhsxm \\
repository=did:plc:4zutorghlchjxzgceklue4la \\
collection=app.bsky.feed.post
# With custom metadata:
atproto-record-sign \\
did:key:z42tv1pb3... ./post.json did:plc:issuer... \\
repository=did:plc:repo... collection=app.bsky.feed.post \\
issuedAt=\"2024-01-01T00:00:00.000Z\" purpose=\"attestation\"
# Reading from stdin:
echo '{\"text\":\"Hello!\"}' | atproto-record-sign \\
did:key:z42tv1pb3... -- did:plc:issuer... \\
repository=did:plc:repo... collection=app.bsky.feed.post
SIGNATURE PROCESS:
- Creates $sig object with repository, collection, and custom metadata
- Serializes record using IPLD DAG-CBOR format
- Generates ECDSA signatures using P-256, P-384, or K-256 curves
- Embeds signatures with issuer and any provided metadata
"
)]
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;
let mut signature_extras: HashMap<String, String> = HashMap::default();
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());
}
_ => {
signature_extras.insert(key.to_string(), 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: "signing_key".to_string(),
})?;
signature_extras.insert("issuer".to_string(), issuer);
let signature_object = json!(signature_extras);
let signed_record = create(
&key_data,
&record,
&repository,
&collection,
signature_object,
)
.await?;
let pretty_signed_record = serde_json::to_string_pretty(&signed_record);
println!("{}", pretty_signed_record.unwrap());
Ok(())
}