parley-md 0.1.2

Reference CLI for the Parley agent-to-agent messaging protocol. Installs the `parley` binary.
//! `parley file send <handle> <path>` — encrypt + upload + post manifest.
//!
//! Wire format: spec/v0.4.md §4 (mls_file body) + §6 (single-shot blob upload).

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chacha20poly1305::aead::{Aead as _, KeyInit as _, OsRng as ChaChaOsRng};
use chacha20poly1305::XChaCha20Poly1305;
use serde_json::json;

use crate::client::Client;
use crate::cmd::peer::open_or_create_channel;
use crate::state::{load_identity, load_party_keys, load_server, save_party_keys};

pub async fn send(home: &Path, recipient: &str, path: &Path) -> Result<()> {
    let identity = load_identity(home)?;
    let server = load_server(home)?;
    if server.server_url.is_empty() {
        anyhow::bail!("server not configured. Run `parley register --server URL`.");
    }
    let party = load_party_keys(home, &identity)?;
    let client = Client::new(&server, &identity)?;

    // 1. Read file.
    let plaintext = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
    let plaintext_size =
        u64::try_from(plaintext.len()).map_err(|_| anyhow::anyhow!("file too large"))?;
    let filename = path
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("file")
        .to_owned();

    // 2. Hash plaintext (end-to-end integrity check, separate from AEAD tag).
    let sha_plain = {
        use sha2::{Digest as _, Sha256};
        let mut h = Sha256::new();
        h.update(&plaintext);
        let d = h.finalize();
        let mut out = [0u8; 32];
        out.copy_from_slice(&d);
        out
    };

    // 3. Generate fresh AEAD key + 24-byte nonce, encrypt.
    let key_bytes: [u8; 32] = {
        use rand::RngCore as _;
        let mut k = [0u8; 32];
        rand::thread_rng().fill_bytes(&mut k);
        k
    };
    let nonce_bytes: [u8; 24] = {
        use rand::RngCore as _;
        let mut n = [0u8; 24];
        rand::thread_rng().fill_bytes(&mut n);
        n
    };
    let _ = ChaChaOsRng; // pin import path
    let cipher = XChaCha20Poly1305::new((&key_bytes).into());
    let ciphertext_inner = cipher
        .encrypt((&nonce_bytes).into(), plaintext.as_slice())
        .map_err(|e| anyhow::anyhow!("encrypt: {e}"))?;

    // sealed = [24-byte nonce][ciphertext+tag]
    let mut sealed = Vec::with_capacity(24 + ciphertext_inner.len());
    sealed.extend_from_slice(&nonce_bytes);
    sealed.extend_from_slice(&ciphertext_inner);
    let ciphertext_size =
        u64::try_from(sealed.len()).map_err(|_| anyhow::anyhow!("ciphertext too large"))?;

    println!(
        "encrypted: plaintext={} bytes → ciphertext={} bytes",
        plaintext.len(),
        sealed.len()
    );

    // 4. Resolve recipient and open/create the 1:1 channel.
    let mut opened = open_or_create_channel(home, &party, &client, recipient).await?;

    // 5. Register upload intent → presigned PUT URL.
    let create = client.create_blob(ciphertext_size, None).await?;
    let blob_id = create
        .get("blob_id")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("create_blob response missing blob_id"))?
        .to_owned();
    let upload_url = create
        .get("upload_url")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("create_blob response missing upload_url"))?
        .to_owned();
    println!("blob_id: {blob_id}");

    // 6. PUT ciphertext to S3 directly via presigned URL.
    print!("uploading...");
    use std::io::Write as _;
    let _ = std::io::stdout().flush();
    client.put_raw_to_presigned(&upload_url, sealed).await?;
    println!(" ok");

    // 7. Finalize the blob server-side.
    let _ = client.complete_blob(&blob_id).await?;

    // 8. Build the mls_file manifest and post it as an mls_application.
    let manifest = json!({
        "kind": "file",
        "blob_id": blob_id,
        "key": URL_SAFE_NO_PAD.encode(key_bytes),
        "name": filename,
        "size": plaintext_size,
        "sha256_plain": URL_SAFE_NO_PAD.encode(sha_plain),
        "content_type": "application/octet-stream",
    });
    let manifest_bytes = serde_json::to_vec(&manifest)?;
    let mls_ct = opened
        .group
        .encrypt_application(&party, &manifest_bytes)
        .map_err(|e| anyhow::anyhow!("encrypt manifest: {e}"))?;
    let _ = client
        .post_mls_application(&opened.channel_id, &mls_ct)
        .await?;

    save_party_keys(home, &party)?;

    let _ = crate::history::append(
        home,
        &opened.channel_id,
        &crate::history::LogEntry {
            seq: None,
            ts: crate::history::now_unix(),
            direction: "out".into(),
            counterparty_pubkey: opened.recipient_pk_b64.clone(),
            counterparty_handle: Some(recipient.to_string()),
            kind: "file".into(),
            body: filename.clone(),
            size: Some(plaintext_size),
            saved_to: Some(path.display().to_string()),
        },
    );

    println!(
        "sent file '{filename}' ({plaintext_size} bytes) to {recipient} (channel {})",
        opened.channel_id
    );
    Ok(())
}

/// Decrypt + verify an mls_file manifest. Called from inbox.rs when a
/// decrypted mls_application turns out to be a file manifest.
pub async fn fetch_and_decrypt(
    client: &Client,
    files_dir: &Path,
    manifest: &serde_json::Value,
) -> Result<PathBuf> {
    let blob_id = manifest
        .get("blob_id")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("manifest missing blob_id"))?;
    let key_b64 = manifest
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("manifest missing key"))?;
    let name = manifest
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("manifest missing name"))?;
    let plaintext_size = manifest
        .get("size")
        .and_then(|v| v.as_u64())
        .ok_or_else(|| anyhow::anyhow!("manifest missing size"))?;
    let sha_plain_b64 = manifest
        .get("sha256_plain")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("manifest missing sha256_plain"))?;

    let key_bytes = URL_SAFE_NO_PAD.decode(key_b64).context("key b64")?;
    if key_bytes.len() != 32 {
        anyhow::bail!("key wrong length: {}", key_bytes.len());
    }
    let expected_sha = URL_SAFE_NO_PAD.decode(sha_plain_b64).context("sha b64")?;

    let download = client.download_blob(blob_id).await?;
    let url = download
        .get("download_url")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("download response missing download_url"))?;

    let sealed = client.get_raw_from_presigned(url).await?;
    if sealed.len() < 24 + 16 {
        anyhow::bail!("ciphertext too short: {}", sealed.len());
    }
    let nonce = &sealed[..24];
    let ct = &sealed[24..];

    let cipher = XChaCha20Poly1305::new(key_bytes.as_slice().into());
    let plaintext = cipher
        .decrypt(nonce.into(), ct)
        .map_err(|e| anyhow::anyhow!("decrypt: {e}"))?;

    if u64::try_from(plaintext.len()).unwrap_or(0) != plaintext_size {
        anyhow::bail!(
            "size mismatch: declared {plaintext_size}, decrypted {}",
            plaintext.len()
        );
    }
    let actual_sha = {
        use sha2::{Digest as _, Sha256};
        let mut h = Sha256::new();
        h.update(&plaintext);
        h.finalize().to_vec()
    };
    if actual_sha != expected_sha {
        anyhow::bail!("sha256_plain mismatch — file is corrupted or manifest was tampered with");
    }

    std::fs::create_dir_all(files_dir).with_context(|| format!("mkdir {}", files_dir.display()))?;
    let safe_name = sanitize_filename(name);
    let dest = files_dir.join(safe_name);
    std::fs::write(&dest, &plaintext).with_context(|| format!("write {}", dest.display()))?;
    Ok(dest)
}

/// Strip path components and disallow weird names. Receiver MUST NOT trust
/// the sender-supplied filename for filesystem placement.
fn sanitize_filename(name: &str) -> String {
    let stem = Path::new(name)
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("file");
    if stem.is_empty() || stem == "." || stem == ".." {
        return "file".to_owned();
    }
    stem.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect()
}