atproto-record 0.4.1

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

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},
};

/// AT Protocol Record Signing Tool
///
/// This command-line tool provides cryptographic signing capabilities for AT Protocol records.
/// It reads a JSON record from a file or stdin, applies a cryptographic signature using a DID key,
/// and outputs the signed record with embedded signature metadata.
///
/// ## Overview
///
/// The tool performs the following operations:
/// 1. **Command Line Parsing**: Extracts signing parameters from command line arguments
/// 2. **Key Resolution**: Converts DID key strings to cryptographic key material
/// 3. **Record Loading**: Reads and parses JSON records from disk files or stdin
/// 4. **Signature Creation**: Generates cryptographic signatures using IPLD DAG-CBOR serialization
/// 5. **Output Generation**: Produces signed records with embedded signature objects
///
/// ## Signature Process
///
/// The signing process follows AT Protocol conventions:
/// - Creates a `$sig` object with repository and collection context
/// - Serializes the record with `$sig` using IPLD DAG-CBOR format
/// - Generates ECDSA signatures using P-256 or K-256 curves
/// - Embeds signatures in a `signatures` array with issuer metadata
/// - Encodes signatures using multibase (base64url)
///
/// ## Arguments
///
/// The tool accepts flexible argument ordering:
/// - **DID Key** (`did:key:...`) - Cryptographic key for signing operations
/// - **Issuer DID** (`did:plc:...` or `did:web:...`) - Identity of the signature issuer  
/// - **Record Input** (file path or `--`) - JSON file containing the record to sign, or `--` to read from stdin
/// - **Parameters** (`key=value`) - Repository, collection, and signature metadata
///
/// ## Required Parameters
///
/// - `repository=<DID>` - Repository context for the signature
/// - `collection=<name>` - Collection type context for the signature
///
/// ## Optional Parameters
///
/// - `issued_at=<timestamp>` - RFC 3339 timestamp (defaults to current time)
/// - Custom fields can be added to the signature object via `key=value` pairs
///
/// ## Examples
///
/// ### Basic Usage
/// ```bash
/// atproto-record-sign \
///   did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \
///   ./post.json \
///   did:plc:tgudj2fjm77pzkuawquqhsxm \
///   repository=did:plc:4zutorghlchjxzgceklue4la \
///   collection=app.bsky.feed.post
/// ```
///
/// ### With Custom Timestamp
/// ```bash
/// atproto-record-sign \
///   did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \
///   ./badge.json \
///   did:plc:tgudj2fjm77pzkuawquqhsxm \
///   repository=did:plc:4zutorghlchjxzgceklue4la \
///   collection=community.lexicon.badge.award \
///   issued_at=2025-05-16T14:00:02.000Z
/// ```
///
/// ### Reading from Stdin
/// ```bash
/// echo '{"$type":"app.bsky.feed.post","text":"Hello from stdin!"}' | \
/// atproto-record-sign \
///   did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \
///   -- \
///   did:plc:tgudj2fjm77pzkuawquqhsxm \
///   repository=did:plc:4zutorghlchjxzgceklue4la \
///   collection=app.bsky.feed.post
/// ```
///
/// ### Input Record Example (`post.json`)
/// ```json
/// {
///   "$type": "app.bsky.feed.post",
///   "text": "Hello AT Protocol!",
///   "createdAt": "2024-01-01T00:00:00Z"
/// }
/// ```
///
/// ### Output Signed Record
/// ```json
/// {
///   "$type": "app.bsky.feed.post",
///   "text": "Hello AT Protocol!",
///   "createdAt": "2024-01-01T00:00:00Z",
///   "signatures": [
///     {
///       "issuer": "did:plc:tgudj2fjm77pzkuawquqhsxm",
///       "issued_at": "2025-05-30T16:27:20.532Z",
///       "signature": "uo36qFvSGV6QcFaxYYN9JCAGQNv2yVHK2LPN3lNp210v..."
///     }
///   ]
/// }
/// ```
///
/// ## Error Handling
///
/// The tool provides detailed error messages for:
/// - Missing or invalid DID keys and issuer DIDs
/// - File reading and JSON parsing failures  
/// - Missing required parameters (repository, collection)
/// - Cryptographic operation failures
/// - Unsupported DID methods
///
/// ## Security Considerations
///
/// - Private keys are handled in-memory only and not persisted
/// - Signatures are generated using industry-standard ECDSA algorithms
/// - All cryptographic operations follow AT Protocol specifications
/// - Input validation prevents malformed DID and parameter injection
#[tokio::main]
async fn main() -> Result<()> {
    // Check for help flags
    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:") {
            // 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(anyhow!("Unsupported DID method: {}", argument)),
                Err(e) => return Err(anyhow!("Failed to parse DID {}: {}", argument, e)),
            }
        } 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(|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 {
            // 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(|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"))?;

    // 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(())
}