crtx 0.1.1

CLI for the Cortex supervisory memory substrate.
//! `cortex sign` — detached Ed25519 signing of an arbitrary payload file.
//!
//! Phase 4.A polish surface (closes one of the
//! `docs/RUNBOOK_PRODUCTION_RESTORE_DRILL.md` §9 known gaps): the
//! production restore drill scripts and the operator playbook reference
//! `cortex sign --key --payload --output` as the first-class way to
//! produce a detached signature over canonical bytes (the same shape
//! `cortex restore apply` consumes via `--restore-intent-signature`).
//! Until this command landed, the playbook fell back to inline Python
//! using the `cryptography` package — see the §3.5 fallback path the
//! drill script `scripts/restore-drill-production.sh` carries.
//!
//! ## Contract
//!
//! - The signing key is a base64-encoded 32-byte Ed25519 seed
//!   (`SigningKey::from_bytes`). Operators typically vend it via
//!   `tsafe get cortex/operator_signing_key_b64` and pipe it in on
//!   stdin (`--signing-key-b64 -`) to avoid writing the seed to disk.
//! - The payload is read byte-for-byte from `--payload`. The signature
//!   is computed over those bytes verbatim — there is no
//!   re-serialization. (This matches the `RESTORE_INTENT` contract: the
//!   verifier hashes `canonical_bytes` exactly as read from disk.)
//! - The signature is 64 raw bytes (Ed25519 detached signature, no
//!   envelope). The verifier expects exactly this shape — see
//!   `cortex_cli::cmd::restore::intent::verify_detached_signature`.
//!
//! ## Why not reuse `cortex migrate sign-operator-attestation`?
//!
//! `sign-operator-attestation` is purpose-built for the v1->v2
//! migration boundary: it opens the default store, builds a dry-run
//! plan, and binds the signature into a *JSON envelope* with extra
//! metadata. `cortex sign` is the unstructured primitive — it signs
//! whatever bytes the operator hands it, with no store dependency, and
//! emits a raw detached `.sig` file. The two surfaces compose: the
//! drill orchestrator builds the `RESTORE_INTENT` JSON via `cortex
//! restore intent build` and then signs it with `cortex sign` (or, with
//! `--sign`, both steps happen in one invocation).

use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};

use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use clap::Args;
use ed25519_dalek::{Signer, SigningKey};
use serde_json::json;

use crate::exit::Exit;
use crate::output::{self, Envelope};

/// Arguments for `cortex sign`.
#[derive(Debug, Args)]
pub struct SignArgs {
    /// Base64-encoded 32-byte Ed25519 signing seed. Pass `-` to read
    /// the base64 string from stdin (no trailing newline required —
    /// surrounding whitespace is trimmed) so operators can pipe the
    /// secret from `tsafe` / `pass` / `op read` without ever writing
    /// it to disk.
    #[arg(long, value_name = "BASE64_OR_DASH")]
    pub signing_key_b64: String,
    /// Path to the payload file. The signature is computed over the
    /// on-disk bytes verbatim — no re-serialization, no canonicalization.
    #[arg(long, value_name = "PATH")]
    pub payload: PathBuf,
    /// Destination for the detached 64-byte Ed25519 signature. Refuses
    /// if the file already exists, so an accidental re-run cannot
    /// silently clobber an existing signature.
    #[arg(long, value_name = "PATH")]
    pub output: PathBuf,
}

/// Stable invariant emitted when `--signing-key-b64` does not base64-decode
/// or does not decode to exactly 32 bytes (Ed25519 seed length).
pub const SIGN_KEY_MALFORMED_INVARIANT: &str = "sign.signing_key_b64.malformed";
/// Stable invariant emitted when `--payload` cannot be read.
pub const SIGN_PAYLOAD_UNREADABLE_INVARIANT: &str = "sign.payload.unreadable";
/// Stable invariant emitted when `--output` already exists (refusal —
/// the command will not clobber an existing detached signature).
pub const SIGN_OUTPUT_ALREADY_EXISTS_INVARIANT: &str = "sign.output.already_exists";
/// Stable invariant emitted when writing the detached signature fails.
pub const SIGN_OUTPUT_WRITE_FAILED_INVARIANT: &str = "sign.output.write_failed";

/// Run `cortex sign`.
pub fn run(args: SignArgs) -> Exit {
    let key_b64 = match resolve_signing_key_b64(&args.signing_key_b64) {
        Ok(value) => value,
        Err(exit) => {
            return finish(
                exit,
                "signing key could not be read",
                Some(SIGN_KEY_MALFORMED_INVARIANT),
                &args.payload,
                &args.output,
            );
        }
    };
    let seed = match decode_signing_seed(&key_b64) {
        Ok(seed) => seed,
        Err(detail) => {
            eprintln!("cortex sign: invariant={SIGN_KEY_MALFORMED_INVARIANT} {detail}");
            return finish(
                Exit::PreconditionUnmet,
                &detail,
                Some(SIGN_KEY_MALFORMED_INVARIANT),
                &args.payload,
                &args.output,
            );
        }
    };

    let payload = match fs::read(&args.payload) {
        Ok(bytes) => bytes,
        Err(err) => {
            let detail = format!("cannot read --payload `{}`: {err}", args.payload.display());
            eprintln!("cortex sign: invariant={SIGN_PAYLOAD_UNREADABLE_INVARIANT} {detail}");
            return finish(
                Exit::PreconditionUnmet,
                &detail,
                Some(SIGN_PAYLOAD_UNREADABLE_INVARIANT),
                &args.payload,
                &args.output,
            );
        }
    };

    if args.output.exists() {
        let detail = format!(
            "refusing to overwrite existing --output `{}`",
            args.output.display()
        );
        eprintln!("cortex sign: invariant={SIGN_OUTPUT_ALREADY_EXISTS_INVARIANT} {detail}");
        return finish(
            Exit::PreconditionUnmet,
            &detail,
            Some(SIGN_OUTPUT_ALREADY_EXISTS_INVARIANT),
            &args.payload,
            &args.output,
        );
    }

    let signing_key = SigningKey::from_bytes(&seed);
    let verifying_key = signing_key.verifying_key();
    let signature = signing_key.sign(&payload);
    let sig_bytes = signature.to_bytes();

    if let Err(err) = fs::write(&args.output, sig_bytes) {
        let detail = format!(
            "cannot write detached signature to `{}`: {err}",
            args.output.display()
        );
        eprintln!("cortex sign: invariant={SIGN_OUTPUT_WRITE_FAILED_INVARIANT} {detail}");
        return finish(
            Exit::Internal,
            &detail,
            Some(SIGN_OUTPUT_WRITE_FAILED_INVARIANT),
            &args.payload,
            &args.output,
        );
    }

    if !output::json_enabled() {
        println!(
            "cortex sign: signed `{}` -> `{}` (64-byte Ed25519 detached signature)",
            args.payload.display(),
            args.output.display()
        );
    }
    let payload_b3 = blake3_hex(&payload);
    let pubkey_hex = hex_lower(verifying_key.as_bytes());
    finish_ok(payload_b3, pubkey_hex, &args.payload, &args.output)
}

/// Resolve the signing-key value, transparently reading from stdin when the
/// operator passed `-`. The stdin path keeps the secret out of `argv` (which
/// is visible to other users via `/proc/<pid>/cmdline`).
fn resolve_signing_key_b64(value: &str) -> Result<String, Exit> {
    if value == "-" {
        let mut buf = String::new();
        if let Err(err) = io::stdin().read_to_string(&mut buf) {
            eprintln!(
                "cortex sign: invariant={SIGN_KEY_MALFORMED_INVARIANT} cannot read --signing-key-b64 from stdin: {err}"
            );
            return Err(Exit::PreconditionUnmet);
        }
        Ok(buf.trim().to_string())
    } else {
        Ok(value.trim().to_string())
    }
}

fn decode_signing_seed(b64: &str) -> Result<[u8; 32], String> {
    let raw = STANDARD
        .decode(b64.as_bytes())
        .map_err(|err| format!("--signing-key-b64 is not valid base64: {err}"))?;
    raw.as_slice().try_into().map_err(|_| {
        format!(
            "--signing-key-b64 must decode to exactly 32 raw bytes (Ed25519 seed); got {}",
            raw.len()
        )
    })
}

fn blake3_hex(bytes: &[u8]) -> String {
    format!("blake3:{}", blake3::hash(bytes).to_hex())
}

fn hex_lower(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        out.push(HEX[(byte >> 4) as usize] as char);
        out.push(HEX[(byte & 0x0f) as usize] as char);
    }
    out
}

fn finish_ok(
    payload_blake3: String,
    operator_pubkey_hex: String,
    payload: &Path,
    output: &Path,
) -> Exit {
    if !output::json_enabled() {
        return Exit::Ok;
    }
    let report = json!({
        "command": "cortex.sign",
        "payload_path": payload.display().to_string(),
        "payload_blake3": payload_blake3,
        "signature_path": output.display().to_string(),
        "signature_bytes": 64,
        "operator_pubkey_hex": operator_pubkey_hex,
        "persisted": true,
    });
    let envelope = Envelope::new("cortex.sign", Exit::Ok, report);
    output::emit(&envelope, Exit::Ok)
}

fn finish(
    exit: Exit,
    detail: &str,
    invariant: Option<&'static str>,
    payload: &Path,
    output: &Path,
) -> Exit {
    if !output::json_enabled() {
        return exit;
    }
    let report = json!({
        "command": "cortex.sign",
        "payload_path": payload.display().to_string(),
        "signature_path": output.display().to_string(),
        "detail": detail,
        "invariant": invariant,
        "persisted": false,
    });
    let envelope = Envelope::new("cortex.sign", exit, report);
    output::emit(&envelope, exit)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::Verifier;

    fn b64_seed(seed: [u8; 32]) -> String {
        STANDARD.encode(seed)
    }

    #[test]
    fn decode_signing_seed_rejects_wrong_length() {
        let short = STANDARD.encode([0u8; 16]);
        let err = decode_signing_seed(&short).unwrap_err();
        assert!(err.contains("32 raw bytes"), "got: {err}");
    }

    #[test]
    fn decode_signing_seed_rejects_non_base64() {
        let err = decode_signing_seed("!!! not base64 !!!").unwrap_err();
        assert!(err.contains("not valid base64"), "got: {err}");
    }

    #[test]
    fn decode_signing_seed_accepts_32_bytes() {
        let seed = [9u8; 32];
        let decoded = decode_signing_seed(&b64_seed(seed)).unwrap();
        assert_eq!(decoded, seed);
    }

    #[test]
    fn signed_payload_round_trips_with_verifying_key() {
        let dir = tempfile::tempdir().unwrap();
        let payload = dir.path().join("payload.bin");
        let signature = dir.path().join("payload.sig");
        let payload_bytes = b"the operator-signed payload bytes".to_vec();
        fs::write(&payload, &payload_bytes).unwrap();

        let seed = [42u8; 32];
        let args = SignArgs {
            signing_key_b64: b64_seed(seed),
            payload: payload.clone(),
            output: signature.clone(),
        };

        let exit = run(args);
        assert_eq!(exit, Exit::Ok);

        let sig_bytes = fs::read(&signature).unwrap();
        assert_eq!(sig_bytes.len(), 64, "Ed25519 signature must be 64 bytes");

        let signing_key = SigningKey::from_bytes(&seed);
        let verifying_key = signing_key.verifying_key();
        let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().unwrap();
        let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
        verifying_key
            .verify(&payload_bytes, &sig)
            .expect("signature must verify against the derived public key");
    }

    #[test]
    fn refuses_when_output_already_exists() {
        let dir = tempfile::tempdir().unwrap();
        let payload = dir.path().join("payload.bin");
        let signature = dir.path().join("payload.sig");
        fs::write(&payload, b"bytes").unwrap();
        fs::write(&signature, b"pre-existing").unwrap();

        let args = SignArgs {
            signing_key_b64: b64_seed([7u8; 32]),
            payload,
            output: signature.clone(),
        };

        let exit = run(args);
        assert_eq!(exit, Exit::PreconditionUnmet);
        // Pre-existing bytes must be left untouched.
        let after = fs::read(&signature).unwrap();
        assert_eq!(after, b"pre-existing");
    }

    #[test]
    fn refuses_when_signing_key_is_malformed() {
        let dir = tempfile::tempdir().unwrap();
        let payload = dir.path().join("payload.bin");
        fs::write(&payload, b"bytes").unwrap();
        let args = SignArgs {
            signing_key_b64: "definitely-not-base64-32-bytes".to_string(),
            payload,
            output: dir.path().join("payload.sig"),
        };
        let exit = run(args);
        assert_eq!(exit, Exit::PreconditionUnmet);
        assert!(
            !dir.path().join("payload.sig").exists(),
            "output must NOT be written when the signing key is rejected"
        );
    }

    #[test]
    fn refuses_when_payload_is_missing() {
        let dir = tempfile::tempdir().unwrap();
        let args = SignArgs {
            signing_key_b64: b64_seed([1u8; 32]),
            payload: dir.path().join("does-not-exist"),
            output: dir.path().join("payload.sig"),
        };
        let exit = run(args);
        assert_eq!(exit, Exit::PreconditionUnmet);
        assert!(
            !dir.path().join("payload.sig").exists(),
            "output must NOT be written when the payload is unreadable"
        );
    }
}