cinchcli-core 0.1.0

Shared client-side primitives for Cinch (cinchcli.com): generated wire DTOs, REST/WebSocket clients, AES-256-GCM + X25519 crypto, credential storage, local SQLite store, and sync helpers.
Documentation
//! Single atomic entry point for installing a fresh sign-in onto disk.
//!
//! Replaces the historical pattern where each caller (CLI `run_login`,
//! desktop `sign_in`, desktop `handle_deeplink`) wrote credentials in three
//! independent steps:
//!
//!   1. `auth::write_credentials` — token + user_id + device_id + bump
//!   2. `credstore::write_encryption_key` — generated lazily, sometimes
//!      after the version bump fired
//!   3. `credstore::write_device_privkey` — generated lazily as well
//!
//! The lazy generation race meant the desktop FS watcher could fire on the
//! version bump from step 1 and adopt credentials before steps 2-3 had
//! produced the AES + X25519 material. `install_credentials` collapses all
//! three writes into a single transaction with exactly one
//! `credential_version` bump at the end.
//!
//! AES + X25519 are generated up-front (eager) and reused if the user
//! already has them on this machine.

use crate::auth::{load_multi_config, save_multi_config, CredentialError};
use crate::config::RelayProfile;
use crate::credstore;
use crate::crypto;

/// Inputs for an atomic credential install. Everything the relay returned
/// for a fresh device-code or pair handshake.
pub struct InstallParams<'a> {
    pub user_id: &'a str,
    pub device_id: &'a str,
    pub token: &'a str,
    pub relay_url: &'a str,
    pub hostname: &'a str,
    /// Optional pre-supplied X25519 device private key (base64url). When
    /// `None`, `install_credentials` generates a fresh keypair if the user
    /// does not already have one on this machine.
    pub device_private_key: Option<&'a str>,
    /// Verified email returned by the OAuth provider. Empty string when not available.
    pub email: &'a str,
    /// OAuth identity provider name ("google" or "github"). Empty string when not available.
    pub identity_provider: &'a str,
}

/// Outcome of `install_credentials` — useful for callers that want to
/// surface "this is the first sign-in on this machine" or report which
/// credstore backend was used.
#[derive(Debug, Clone)]
pub struct InstallOutcome {
    /// Active relay_id after the install (matches `MultiConfig.active_relay_id`).
    pub active_relay_id: String,
    /// New `credential_version` value persisted to disk.
    pub credential_version: u64,
    /// Backend used for the AES key write: `"keyring"` or `"plaintext"`.
    pub encryption_backend: &'static str,
    /// True when this call generated the AES key (vs. reused an existing one).
    pub generated_encryption_key: bool,
    /// True when this call generated the X25519 device key.
    pub generated_device_private_key: bool,
}

/// Install credentials atomically: writes the AES user key + X25519 device
/// key first, then updates `~/.cinch/config.json` with token / user_id /
/// device_id / hostname / machine_id and bumps `credential_version` exactly
/// once at the end.
///
/// This is the only function CLI / desktop should call when persisting a
/// fresh sign-in. It guarantees the desktop FS watcher sees a fully-formed
/// credential set on the version bump.
pub fn install_credentials(p: InstallParams<'_>) -> Result<InstallOutcome, CredentialError> {
    if p.user_id.is_empty() || p.device_id.is_empty() || p.token.is_empty() {
        return Err(CredentialError::BadConfig(
            "user_id, device_id, token are required".into(),
        ));
    }

    // Step 1: ensure the user-scoped AES key exists. Generate eagerly when missing.
    let mut generated_encryption_key = false;
    let encryption_backend: &'static str = "plaintext";
    if credstore::read_encryption_key(p.user_id).is_none() {
        let key = crypto::generate_aes_key();
        credstore::write_encryption_key(p.user_id, &key)
            .map_err(|e| CredentialError::Io(format!("encryption key: {}", e)))?;
        generated_encryption_key = true;
    }

    // Step 2: ensure a per-device X25519 keypair exists.
    let mut generated_device_private_key = false;
    let device_priv_b64: String = if let Some(provided) = p.device_private_key {
        if !provided.is_empty() {
            credstore::write_device_privkey(p.user_id, p.device_id, provided)
                .map_err(|e| CredentialError::Io(format!("device key: {}", e)))?;
            provided.to_string()
        } else {
            install_or_reuse_device_privkey(
                p.user_id,
                p.device_id,
                &mut generated_device_private_key,
            )?
        }
    } else {
        install_or_reuse_device_privkey(p.user_id, p.device_id, &mut generated_device_private_key)?
    };

    // Step 3: update config.json — set/replace the active profile and bump
    // credential_version exactly once.
    let mut mc = load_multi_config()?;
    let next_version = mc
        .relays
        .iter()
        .map(|r| r.credential_version)
        .max()
        .unwrap_or(0)
        .checked_add(1)
        .ok_or_else(|| CredentialError::BadConfig("credential_version overflow".into()))?;

    let machine_id = crate::machine::stable_machine_id();

    if let Some(profile) = mc.active_profile_mut() {
        profile.token = p.token.to_string();
        profile.user_id = p.user_id.to_string();
        profile.device_id = p.device_id.to_string();
        profile.relay_url = p.relay_url.to_string();
        profile.hostname = p.hostname.to_string();
        profile.device_private_key = device_priv_b64;
        profile.machine_id = machine_id;
        profile.credential_version = next_version;
        if !p.email.is_empty() {
            profile.email = p.email.to_string();
        }
        if !p.identity_provider.is_empty() {
            profile.identity_provider = p.identity_provider.to_string();
        }
    } else {
        use ulid::Ulid;
        let label = url::Url::parse(p.relay_url)
            .ok()
            .and_then(|u| u.host_str().map(|h| h.to_string()))
            .unwrap_or_else(|| p.relay_url.to_string());
        let profile = RelayProfile {
            id: Ulid::new().to_string(),
            label,
            relay_url: p.relay_url.to_string(),
            user_id: p.user_id.to_string(),
            device_id: p.device_id.to_string(),
            hostname: p.hostname.to_string(),
            encryption_key: String::new(),
            device_private_key: device_priv_b64,
            credential_version: next_version,
            token: p.token.to_string(),
            machine_id,
            email: p.email.to_string(),
            identity_provider: p.identity_provider.to_string(),
        };
        let id = profile.id.clone();
        mc.relays.push(profile);
        mc.active_relay_id = Some(id);
    }

    let active_relay_id = mc.active_relay_id.clone().unwrap_or_default();
    save_multi_config(&mc)?;

    Ok(InstallOutcome {
        active_relay_id,
        credential_version: next_version,
        encryption_backend,
        generated_encryption_key,
        generated_device_private_key,
    })
}

/// Error returned when the E2EE key is not available for a user.
#[derive(Debug, thiserror::Error)]
pub enum RequireKeyError {
    #[error("encryption key not found for user")]
    Missing,
}

/// E2EE precondition. Returns the user's AES-256 key or a clear error.
/// Callers map `Missing` to the `ENCRYPTION_REQUIRED` exit code.
pub fn require_encryption_key(user_id: &str) -> Result<[u8; 32], RequireKeyError> {
    if user_id.is_empty() {
        return Err(RequireKeyError::Missing);
    }
    credstore::read_encryption_key(user_id).ok_or(RequireKeyError::Missing)
}

fn install_or_reuse_device_privkey(
    user_id: &str,
    device_id: &str,
    generated_flag: &mut bool,
) -> Result<String, CredentialError> {
    // Best-effort: if the active profile already has a non-empty device_private_key
    // for this same (user_id, device_id), reuse it. Otherwise generate one.
    let existing = load_multi_config()
        .ok()
        .and_then(|mc| mc.active_profile().cloned())
        .filter(|p| {
            p.user_id == user_id && p.device_id == device_id && !p.device_private_key.is_empty()
        })
        .map(|p| p.device_private_key);

    if let Some(priv_b64) = existing {
        return Ok(priv_b64);
    }

    let (priv_b64, _pub_b64) = crypto::generate_device_keypair();
    credstore::write_device_privkey(user_id, device_id, &priv_b64)
        .map_err(|e| CredentialError::Io(format!("device key: {}", e)))?;
    *generated_flag = true;
    Ok(priv_b64)
}

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

    #[test]
    fn rejects_empty_required_fields() {
        let p = InstallParams {
            user_id: "",
            device_id: "d1",
            token: "tok",
            relay_url: "http://localhost:8080",
            hostname: "h",
            device_private_key: None,
            email: "",
            identity_provider: "",
        };
        assert!(install_credentials(p).is_err());
    }

    #[test]
    fn require_encryption_key_errors_when_missing() {
        let err = require_encryption_key("test-no-key-x7k9q").unwrap_err();
        assert!(matches!(err, RequireKeyError::Missing));
    }

    #[test]
    fn require_encryption_key_errors_on_empty_user_id() {
        let err = require_encryption_key("").unwrap_err();
        assert!(matches!(err, RequireKeyError::Missing));
    }
}