parley-md 0.1.2

Reference CLI for the Parley agent-to-agent messaging protocol. Installs the `parley` binary.
//! On-disk state for the CLI.
//!
//! Layout under `~/.parley/`:
//!
//! ```text
//! identity.json          ed25519 keypair (chmod 600)
//! server.json            { server_url, network_id }
//! friends.json           { "<handle>": "<pubkey-b64url>" }
//! channels.json          { "<friend-pubkey-b64url>": { channel_id, mls_group_id } }
//! last_seen.json         { "<channel_id-b64url>": <last_seq_seen> }
//! mls_storage.json       serialized openmls MemoryStorage (provider state)
//! ```
//!
//! Every command loads the state it needs at entry and saves at the end.
//! This is intentionally simple: no daemon, no incremental writes, no
//! locking. For the v0.3-alpha-1 use case (single-user, one-shot
//! invocations), this is enough; we'll add file locking + a daemon
//! coordination scheme when those become real concerns.

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::SigningKey;
use openmls::prelude::{BasicCredential, CredentialWithKey, OpenMlsProvider as _, SignatureScheme};
use openmls_basic_credential::SignatureKeyPair;
use openmls_memory_storage::MemoryStorage;
use openmls_rust_crypto::OpenMlsRustCrypto;
use parley_core::{AgentPubkey, NetworkId};
use parley_mls::PartyKeys;
use serde::{Deserialize, Serialize};

pub fn resolve_home(explicit: Option<PathBuf>) -> Result<PathBuf> {
    if let Some(p) = explicit {
        return Ok(p);
    }
    if let Ok(env) = std::env::var("PARLEY_HOME") {
        return Ok(PathBuf::from(env));
    }
    let home = std::env::var("HOME").context("$HOME not set")?;
    Ok(PathBuf::from(home).join(".parley"))
}

pub fn ensure_dir(home: &Path) -> Result<()> {
    fs::create_dir_all(home).with_context(|| format!("create {home:?}"))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        let mut perms = fs::metadata(home)?.permissions();
        perms.set_mode(0o700);
        fs::set_permissions(home, perms).ok();
    }
    Ok(())
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Identity {
    /// 32-byte Ed25519 secret seed, base64url-no-pad.
    pub secret_b64: String,
    /// 32-byte Ed25519 public key, base64url-no-pad. Doubles as the
    /// Parley agent identity.
    pub public_b64: String,
}

impl Identity {
    pub fn generate() -> Self {
        use rand::RngCore as _;
        let mut secret = [0u8; 32];
        rand::thread_rng().fill_bytes(&mut secret);
        let signing = SigningKey::from_bytes(&secret);
        let public = signing.verifying_key().to_bytes();
        Self {
            secret_b64: URL_SAFE_NO_PAD.encode(secret),
            public_b64: URL_SAFE_NO_PAD.encode(public),
        }
    }

    pub fn secret_bytes(&self) -> Result<[u8; 32]> {
        let v = URL_SAFE_NO_PAD.decode(&self.secret_b64)?;
        <[u8; 32]>::try_from(v.as_slice())
            .map_err(|_| anyhow::anyhow!("identity.secret must be 32 bytes"))
    }

    pub fn public_bytes(&self) -> Result<[u8; 32]> {
        let v = URL_SAFE_NO_PAD.decode(&self.public_b64)?;
        <[u8; 32]>::try_from(v.as_slice())
            .map_err(|_| anyhow::anyhow!("identity.public must be 32 bytes"))
    }

    pub fn pubkey(&self) -> Result<AgentPubkey> {
        Ok(AgentPubkey::from_bytes(self.public_bytes()?))
    }

    pub fn signing_key(&self) -> Result<SigningKey> {
        Ok(SigningKey::from_bytes(&self.secret_bytes()?))
    }
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ServerConfig {
    pub server_url: String,
    pub network_id: String,
}

impl ServerConfig {
    pub fn network(&self) -> Result<NetworkId> {
        NetworkId::new(&self.network_id).context("network_id")
    }
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Friends {
    /// handle → pubkey-b64url
    #[serde(flatten)]
    pub by_name: HashMap<String, String>,
}

impl Friends {
    pub fn resolve(&self, name_or_pubkey: &str) -> Option<String> {
        if let Some(pk) = self.by_name.get(name_or_pubkey) {
            return Some(pk.clone());
        }
        // Looks like a pubkey?
        if name_or_pubkey.len() == 43 {
            return Some(name_or_pubkey.to_owned());
        }
        None
    }

    /// Reverse lookup: pubkey → handle, if any.
    pub fn label(&self, pubkey: &str) -> Option<&str> {
        self.by_name
            .iter()
            .find_map(|(k, v)| (v == pubkey).then_some(k.as_str()))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelEntry {
    pub channel_id: String,
    /// MLS group_id, base64url-no-pad.
    pub mls_group_id: String,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Channels {
    /// friend pubkey → channel info (1:1 channels only for v0.3-alpha-1).
    #[serde(flatten)]
    pub by_friend: HashMap<String, ChannelEntry>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct LastSeen {
    /// channel_id → last seq read
    #[serde(flatten)]
    pub by_channel: HashMap<String, u64>,
}

// -- I/O helpers ----------------------------------------------------------

fn read_json<T: serde::de::DeserializeOwned + Default>(path: &Path) -> Result<T> {
    if !path.exists() {
        return Ok(T::default());
    }
    let s = fs::read_to_string(path).with_context(|| format!("read {path:?}"))?;
    serde_json::from_str(&s).with_context(|| format!("parse {path:?}"))
}

fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
    let s = serde_json::to_string_pretty(value)?;
    let tmp = path.with_extension("tmp");
    fs::write(&tmp, s)?;
    fs::rename(&tmp, path)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        let mut perms = fs::metadata(path)?.permissions();
        perms.set_mode(0o600);
        fs::set_permissions(path, perms).ok();
    }
    Ok(())
}

pub fn identity_path(home: &Path) -> PathBuf {
    home.join("identity.json")
}
pub fn server_path(home: &Path) -> PathBuf {
    home.join("server.json")
}
pub fn friends_path(home: &Path) -> PathBuf {
    home.join("friends.json")
}
pub fn channels_path(home: &Path) -> PathBuf {
    home.join("channels.json")
}
pub fn last_seen_path(home: &Path) -> PathBuf {
    home.join("last_seen.json")
}
pub fn mls_storage_path(home: &Path) -> PathBuf {
    home.join("mls_storage.json")
}

pub fn load_identity(home: &Path) -> Result<Identity> {
    let path = identity_path(home);
    if !path.exists() {
        anyhow::bail!("no identity at {path:?}. Run `parley init` first.");
    }
    let s = fs::read_to_string(&path)?;
    Ok(serde_json::from_str(&s)?)
}

pub fn save_identity(home: &Path, ident: &Identity) -> Result<()> {
    write_json(&identity_path(home), ident)
}

pub fn load_server(home: &Path) -> Result<ServerConfig> {
    read_json(&server_path(home))
}

pub fn save_server(home: &Path, cfg: &ServerConfig) -> Result<()> {
    write_json(&server_path(home), cfg)
}

pub fn load_friends(home: &Path) -> Result<Friends> {
    read_json(&friends_path(home))
}

pub fn save_friends(home: &Path, friends: &Friends) -> Result<()> {
    write_json(&friends_path(home), friends)
}

pub fn load_channels(home: &Path) -> Result<Channels> {
    read_json(&channels_path(home))
}

pub fn save_channels(home: &Path, channels: &Channels) -> Result<()> {
    write_json(&channels_path(home), channels)
}

pub fn load_last_seen(home: &Path) -> Result<LastSeen> {
    read_json(&last_seen_path(home))
}

pub fn save_last_seen(home: &Path, seen: &LastSeen) -> Result<()> {
    write_json(&last_seen_path(home), seen)
}

// -- PartyKeys (identity + MLS provider) ---------------------------------

/// Build a `PartyKeys` from the on-disk identity + MLS storage. If the
/// MLS storage file exists, load it; otherwise start fresh. Caller must
/// `save_party_keys` after any operation that mutated MLS state.
pub fn load_party_keys(home: &Path, ident: &Identity) -> Result<PartyKeys> {
    let provider = OpenMlsRustCrypto::default();
    let mls_path = mls_storage_path(home);
    if mls_path.exists() {
        let file = fs::File::open(&mls_path).with_context(|| format!("open {mls_path:?}"))?;
        // openmls_memory_storage::persistence has &mut self load_from_file
        let storage_ref = provider.storage();
        // Storage is shared by Arc inside OpenMlsRustCrypto; we need a
        // mutable handle. We get one via a temporary clone of the struct.
        // OpenMlsRustCrypto exposes the inner MemoryStorage by value via
        // `into_inner` / direct field access in our usage? Use the
        // documented load_from_file on a fresh MemoryStorage and replace.
        let mut tmp = MemoryStorage::default();
        tmp.load_from_file(&file).map_err(anyhow::Error::msg)?;
        // Copy the loaded entries into the provider's storage.
        copy_storage(&tmp, storage_ref).map_err(anyhow::Error::msg)?;
    }

    // Restore signature key + credential.
    let secret = ident.secret_bytes()?;
    let public = ident.public_bytes()?;
    let signature_keys =
        SignatureKeyPair::from_raw(SignatureScheme::ED25519, secret.to_vec(), public.to_vec());
    signature_keys
        .store(provider.storage())
        .map_err(|e| anyhow::anyhow!("store sig keys: {e:?}"))?;

    let credential = BasicCredential::new(public.to_vec());
    let credential_with_key = CredentialWithKey {
        credential: credential.into(),
        signature_key: signature_keys.public().into(),
    };

    Ok(PartyKeys {
        provider,
        signature_keys,
        credential_with_key,
    })
}

pub fn save_party_keys(home: &Path, keys: &PartyKeys) -> Result<()> {
    let path = mls_storage_path(home);
    let file = fs::File::create(&path).with_context(|| format!("create {path:?}"))?;
    keys.provider
        .storage()
        .save_to_file(&file)
        .map_err(anyhow::Error::msg)?;
    Ok(())
}

/// Copy all entries from `from` into `into`. Used to merge a freshly-
/// loaded MemoryStorage into the active provider's storage (which is
/// wrapped by `OpenMlsRustCrypto` and can't be replaced wholesale).
fn copy_storage(from: &MemoryStorage, into: &MemoryStorage) -> Result<(), String> {
    let src = from.values.read().map_err(|e| e.to_string())?;
    let mut dst = into.values.write().map_err(|e| e.to_string())?;
    for (k, v) in src.iter() {
        dst.insert(k.clone(), v.clone());
    }
    Ok(())
}