atproto-record 0.4.1

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

use anyhow::{anyhow, Result};
use atproto_identity::{
    key::{identify_key, KeyData},
    resolve::{parse_input, InputType},
};
use atproto_record::signature::verify;
use std::{
    env, fs,
    io::{self, Read},
};

/// AT Protocol Record Verification Tool
///
/// This command-line tool provides cryptographic signature verification capabilities for AT Protocol records.
/// It reads a signed JSON record from a file or stdin, validates the embedded cryptographic signatures using a public key,
/// and reports whether the signature verification succeeds or fails.
///
/// ## Overview
///
/// The tool performs the following operations:
/// 1. **Command Line Parsing**: Extracts verification parameters from command line arguments
/// 2. **Key Resolution**: Converts DID key strings to cryptographic key material for verification
/// 3. **Record Loading**: Reads and parses signed JSON records from disk files or stdin
/// 4. **Signature Verification**: Validates cryptographic signatures using IPLD DAG-CBOR deserialization
/// 5. **Result Reporting**: Outputs verification success or detailed failure information
///
/// ## Verification Process
///
/// The verification process follows AT Protocol conventions:
/// - Extracts signatures from the `signatures` array in the record
/// - Finds signatures matching the specified issuer DID
/// - Reconstructs the `$sig` object with repository and collection context
/// - Deserializes the record with `$sig` using IPLD DAG-CBOR format
/// - Validates ECDSA signatures using P-256 or K-256 curves
/// - Decodes signatures from multibase (base64url) encoding
/// - Verifies cryptographic signatures against the public key
///
/// ## Arguments
///
/// The tool accepts flexible argument ordering:
/// - **Issuer DID** (`did:plc:...` or `did:web:...`) - Identity of the expected signature issuer
/// - **Verification Key** (`did:key:...`) - Public key for signature verification
/// - **Record Input** (file path or `--`) - JSON file containing the signed record to verify, or `--` to read from stdin
/// - **Parameters** (`key=value`) - Repository and collection context for verification
///
/// ## Required Parameters
///
/// - `repository=<DID>` - Repository context that was used during signing
/// - `collection=<name>` - Collection type context that was used during signing
///
/// ## Examples
///
/// ### Basic Verification
/// ```bash
/// atproto-record-verify \
///   did:plc:tgudj2fjm77pzkuawquqhsxm \
///   did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \
///   ./signed_post.json \
///   repository=did:plc:4zutorghlchjxzgceklue4la \
///   collection=app.bsky.feed.post
/// ```
///
/// ### Verifying Badge Awards
/// ```bash
/// atproto-record-verify \
///   did:plc:tgudj2fjm77pzkuawquqhsxm \
///   did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \
///   ./signed_badge.json \
///   repository=did:plc:4zutorghlchjxzgceklue4la \
///   collection=community.lexicon.badge.award
/// ```
///
/// ### Verifying from Stdin
/// ```bash
/// echo '{"signatures":[...],"text":"Hello!"}' | \
/// atproto-record-verify \
///   did:plc:tgudj2fjm77pzkuawquqhsxm \
///   did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \
///   -- \
///   repository=did:plc:4zutorghlchjxzgceklue4la \
///   collection=app.bsky.feed.post
/// ```
///
/// ### Input Signed Record Example (`signed_post.json`)
/// ```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..."
///     }
///   ]
/// }
/// ```
///
/// ### Successful Verification Output
/// ```
/// OK
/// ```
///
/// ### Failed Verification Output
/// ```
/// Error: Signature verification failed: error validating signature: ...
/// ```
///
/// ## Verification Workflow
///
/// 1. **Parse Arguments**: Extract issuer DID, verification key, record file, and context parameters
/// 2. **Load Record**: Read and parse the signed JSON record from the specified file
/// 3. **Extract Signatures**: Find signature objects in the `signatures` array matching the issuer
/// 4. **Reconstruct Context**: Recreate the `$sig` object with repository and collection context
/// 5. **Serialize Content**: Convert the record with `$sig` to IPLD DAG-CBOR format
/// 6. **Decode Signature**: Convert multibase-encoded signature to raw bytes
/// 7. **Verify Cryptographically**: Validate the signature against the serialized content using the public key
/// 8. **Report Result**: Output "OK" for valid signatures or detailed error messages for failures
///
/// ## 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)
/// - Records without signature fields or matching issuer signatures
/// - Signature decoding and deserialization failures
/// - Cryptographic verification failures
/// - Unsupported DID methods and malformed signatures
///
/// ## Security Considerations
///
/// - Public keys are used for verification only, no private key handling
/// - Signature verification uses industry-standard ECDSA algorithms
/// - All cryptographic operations follow AT Protocol specifications
/// - Input validation prevents malformed DID and parameter injection
/// - Failed verifications provide diagnostic information without exposing sensitive data
///
/// ## Integration with Signing Tool
///
/// This tool is designed to work with records produced by `atproto-record-sign`:
/// 1. Use `atproto-record-sign` to create signed records with embedded signatures
/// 2. Use `atproto-record-verify` to validate those signatures using the corresponding public key
/// 3. The same repository and collection parameters must be used for both signing and verification
/// 4. The verification key should correspond to the private key used for signing
#[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 Verifying Tool");
        println!();
        println!("USAGE:");
        println!("    atproto-record-verify <ISSUER_DID> <KEY> <RECORD_INPUT> repository=<REPO> collection=<COLLECTION> [key=value...]");
        println!();
        println!("ARGUMENTS:");
        println!("    <ISSUER_DID>     DID of the issuer (e.g., did:plc:...)");
        println!("    <KEY>            DID key for verifying (e.g., did:key:z42tv1...)");
        println!("    <RECORD_INPUT>   Path to JSON file containing the record to verify, or '--' to read from stdin");
        println!();
        println!("REQUIRED PARAMETERS:");
        println!("    repository=<REPO>       Repository DID context");
        println!("    collection=<COLLECTION> Collection name context");
        println!();
        println!("EXAMPLES:");
        println!("    # Verify from file:");
        println!("    atproto-record-verify \\");
        println!("      did:plc:tgudj2fjm77pzkuawquqhsxm \\");
        println!("      did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\");
        println!("      ./signed_record.json \\");
        println!("      repository=did:plc:4zutorghlchjxzgceklue4la \\");
        println!("      collection=community.lexicon.badge.award");
        println!();
        println!("    # Verify from stdin:");
        println!("    echo '{{\"signatures\":[...],...}}' | atproto-record-verify \\");
        println!("      did:plc:tgudj2fjm77pzkuawquqhsxm \\");
        println!("      did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\");
        println!("      -- \\");
        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;

    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:") {
            // Parse the did:key to extract key data for verification
            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"))?;

    verify(&issuer, &key_data, record, &repository, &collection).await?;

    println!("OK");

    Ok(())
}