atproto-record 0.9.1

AT Protocol record signature operations - cryptographic signing and verification for AT Protocol records
Documentation
//! CLI tool for signing AT Protocol records.

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 chrono::{SecondsFormat, Utc};
use clap::Parser;
use serde_json::json;
use std::{
    collections::HashMap,
    fs,
    io::{self, Read},
};

/// AT Protocol Record Signing CLI
#[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 and collection context.

REQUIRED PARAMETERS:
  repository=<DID>        Repository context for the signature
  collection=<name>       Collection type context for the signature

EXAMPLES:
  # Basic usage:
  atproto-record-sign \\
    did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\
    ./post.json \\
    did:plc:tgudj2fjm77pzkuawquqhsxm \\
    repository=did:plc:4zutorghlchjxzgceklue4la \\
    collection=app.bsky.feed.post

  # 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 and collection context
  - Serializes record using IPLD DAG-CBOR format
  - Generates ECDSA signatures using P-256 or K-256 curves
  - Embeds signatures with issuer metadata
"
)]
struct Args {
    /// All arguments - flexible parsing handles DID keys, issuer DIDs, files, and key=value pairs
    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:") {
            // Parse the did:key to extract key data for signing
            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 == "--" {
            // Read record from stdin
            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 {
            // Assume it's a file path to read the record from
            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(),
    })?;

    // Write "issuer" key to signature_extras
    signature_extras.insert("issuer".to_string(), issuer);

    // If signature_extras does not contain "issued_at" key, create a RFC 3339 formatted timestamp
    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(())
}