use anyhow::{anyhow, Result};
use atproto_identity::{
key::{identify_key, KeyData},
resolve::{parse_input, InputType},
};
use atproto_record::signature::create;
use chrono::{SecondsFormat, Utc};
use serde_json::json;
use std::{
collections::HashMap,
env, fs,
io::{self, Read},
};
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = env::args().skip(1).collect();
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
println!("AT Protocol Record Signing Tool");
println!();
println!("USAGE:");
println!(" atproto-record-sign <ISSUER_DID> <SIGNING_KEY> <RECORD_INPUT> repository=<REPO> collection=<COLLECTION> [key=value...]");
println!();
println!("ARGUMENTS:");
println!(" <ISSUER_DID> DID of the issuer (e.g., did:plc:...)");
println!(" <SIGNING_KEY> DID key for signing (e.g., did:key:z42tv1...)");
println!(" <RECORD_INPUT> Path to JSON file containing the record to sign, or '--' to read from stdin");
println!();
println!("REQUIRED PARAMETERS:");
println!(" repository=<REPO> Repository DID context");
println!(" collection=<COLLECTION> Collection name context");
println!();
println!("OPTIONAL PARAMETERS:");
println!(" issued_at=<TIMESTAMP> RFC 3339 timestamp (defaults to current time)");
println!(" [key=value...] Additional fields for signature object");
println!();
println!("EXAMPLES:");
println!(" # Sign from file:");
println!(" atproto-record-sign \\");
println!(" did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\");
println!(" ./record.json \\");
println!(" did:plc:tgudj2fjm77pzkuawquqhsxm \\");
println!(" repository=did:plc:4zutorghlchjxzgceklue4la \\");
println!(" collection=community.lexicon.badge.award");
println!();
println!(" # Sign from stdin:");
println!(" echo '{{\"$type\":\"app.bsky.feed.post\",\"text\":\"Hello!\"}}' | atproto-record-sign \\");
println!(" did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\");
println!(" -- \\");
println!(" did:plc:tgudj2fjm77pzkuawquqhsxm \\");
println!(" repository=did:plc:4zutorghlchjxzgceklue4la \\");
println!(" collection=app.bsky.feed.post");
return Ok(());
}
let arguments = 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(anyhow!("Unsupported DID method: {}", argument)),
Err(e) => return Err(anyhow!("Failed to parse DID {}: {}", argument, e)),
}
} else if argument == "--" {
if record.is_none() {
let mut stdin_content = String::new();
io::stdin()
.read_to_string(&mut stdin_content)
.map_err(|e| anyhow!("Failed to read from stdin: {}", e))?;
record = Some(
serde_json::from_str(&stdin_content)
.map_err(|e| anyhow!("Failed to parse JSON from stdin: {}", e))?,
);
} else {
return Err(anyhow!("Unexpected argument: {}", argument));
}
} else {
if record.is_none() {
let file_content = fs::read_to_string(&argument)
.map_err(|e| anyhow!("Failed to read file {}: {}", argument, e))?;
record =
Some(serde_json::from_str(&file_content).map_err(|e| {
anyhow!("Failed to parse JSON from file {}: {}", argument, e)
})?);
} else {
return Err(anyhow!("Unexpected argument: {}", argument));
}
}
}
let collection = collection.ok_or(anyhow!("missing collection value"))?;
let repository = repository.ok_or(anyhow!("missing repository value"))?;
let record = record.ok_or(anyhow!("missing record source value"))?;
let issuer = issuer.ok_or(anyhow!("missing issuer value"))?;
let key_data = key_data.ok_or(anyhow!("missing key data"))?;
signature_extras.insert("issuer".to_string(), issuer);
if !signature_extras.contains_key("issuedAt") {
let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
signature_extras.insert("issuedAt".to_string(), timestamp);
}
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(())
}