void-cli 0.0.2

CLI for void — anonymous encrypted source control
//! OS keyring integration for caching decrypted identity keys.
//!
//! Stores decrypted identity key bytes in the OS keyring (macOS Keychain,
//! Windows Credential Manager, Linux Secret Service) so the PIN only needs
//! to be entered once per session.
//!
//! In test builds (`cfg(test)`), a process-global in-memory store replaces
//! the OS keyring entirely — no Keychain prompts during `cargo test`.
//!
//! Binary envelope format:
//! `[1B version][1B flags][32B signing][32B recipient][32B nostr if flag set]`

use void_core::collab::{Identity, NostrSecretKey, RecipientSecretKey, SigningSecretKey};

#[cfg(not(test))]
const SERVICE: &str = "void";
const ENVELOPE_VERSION: u8 = 0x01;
const FLAG_HAS_NOSTR: u8 = 0x01;

// ---------------------------------------------------------------------------
// Test-only in-memory store (replaces OS keyring during `cargo test`)
// ---------------------------------------------------------------------------

#[cfg(test)]
mod mock_store {
    use std::collections::HashMap;
    use std::sync::{Mutex, OnceLock};

    static STORE: OnceLock<Mutex<HashMap<String, Vec<u8>>>> = OnceLock::new();

    fn store() -> &'static Mutex<HashMap<String, Vec<u8>>> {
        STORE.get_or_init(|| Mutex::new(HashMap::new()))
    }

    pub fn get(key: &str) -> Option<Vec<u8>> {
        store().lock().unwrap().get(key).cloned()
    }

    pub fn set(key: &str, value: Vec<u8>) {
        store().lock().unwrap().insert(key.to_string(), value);
    }

    pub fn remove(key: &str) -> bool {
        store().lock().unwrap().remove(key).is_some()
    }
}

/// Print a one-time warning when the keyring is unavailable.
#[cfg(not(test))]
fn warn_keyring_unavailable(err: &keyring::Error) {
    use std::sync::OnceLock;
    static WARNED: OnceLock<()> = OnceLock::new();
    WARNED.get_or_init(|| {
        eprintln!(
            "void: keyring unavailable ({}), PIN will be required each time",
            err
        );
    });
}

/// Build the binary cache envelope from identity key material.
fn build_cache_envelope(identity: &Identity) -> Vec<u8> {
    let signing = identity.signing_key_bytes();
    let recipient = identity.recipient_key_bytes();
    let nostr = identity.nostr_key_bytes();

    let has_nostr = nostr.is_some();
    let flags = if has_nostr { FLAG_HAS_NOSTR } else { 0 };
    let capacity = 2 + 32 + 32 + if has_nostr { 32 } else { 0 };

    let mut buf = Vec::with_capacity(capacity);
    buf.push(ENVELOPE_VERSION);
    buf.push(flags);
    buf.extend_from_slice(signing.as_bytes());
    buf.extend_from_slice(recipient.as_bytes());
    if let Some(ref nostr_key) = nostr {
        buf.extend_from_slice(nostr_key.as_bytes());
    }
    buf
}

/// Parse the binary cache envelope back into an Identity.
fn parse_cache_envelope(data: &[u8]) -> Option<Identity> {
    if data.len() < 2 {
        return None;
    }

    if data[0] != ENVELOPE_VERSION {
        return None;
    }

    let flags = data[1];
    let has_nostr = flags & FLAG_HAS_NOSTR != 0;
    let expected_len = 2 + 32 + 32 + if has_nostr { 32 } else { 0 };

    if data.len() != expected_len {
        return None;
    }

    let mut signing_bytes = [0u8; 32];
    let mut recipient_bytes = [0u8; 32];
    signing_bytes.copy_from_slice(&data[2..34]);
    recipient_bytes.copy_from_slice(&data[34..66]);

    let signing = SigningSecretKey::from_bytes(signing_bytes);
    let recipient = RecipientSecretKey::from_bytes(recipient_bytes);

    if has_nostr {
        let mut nostr_bytes = [0u8; 32];
        nostr_bytes.copy_from_slice(&data[66..98]);
        let nostr = NostrSecretKey::from_bytes(nostr_bytes);
        Some(Identity::from_bytes_with_nostr(&signing, &recipient, nostr))
    } else {
        Some(Identity::from_bytes(&signing, &recipient))
    }
}

/// Try to load cached identity keys from the OS keyring.
///
/// Returns `None` on cache miss or any keyring error (silently degrades).
pub fn load_cached_keys(signing_pubkey_hex: &str) -> Option<Identity> {
    #[cfg(test)]
    {
        return mock_store::get(signing_pubkey_hex).and_then(|data| parse_cache_envelope(&data));
    }

    #[cfg(not(test))]
    {
        let entry = match keyring::Entry::new(SERVICE, signing_pubkey_hex) {
            Ok(e) => e,
            Err(err) => {
                warn_keyring_unavailable(&err);
                return None;
            }
        };

        match entry.get_secret() {
            Ok(data) => {
                let result = parse_cache_envelope(&data);
                if result.is_none() {
                    eprintln!(
                        "void: keyring cache corrupt (len={}), re-prompting",
                        data.len()
                    );
                }
                result
            }
            Err(keyring::Error::NoEntry) => None,
            Err(err) => {
                warn_keyring_unavailable(&err);
                None
            }
        }
    }
}

/// Cache identity keys in the OS keyring.
///
/// Silently ignores errors — keyring is a UX optimization, not critical.
pub fn cache_keys(signing_pubkey_hex: &str, identity: &Identity) {
    let envelope = build_cache_envelope(identity);

    #[cfg(test)]
    {
        mock_store::set(signing_pubkey_hex, envelope);
        return;
    }

    #[cfg(not(test))]
    {
        let entry = match keyring::Entry::new(SERVICE, signing_pubkey_hex) {
            Ok(e) => e,
            Err(err) => {
                warn_keyring_unavailable(&err);
                return;
            }
        };

        if let Err(err) = entry.set_secret(&envelope) {
            warn_keyring_unavailable(&err);
        }
    }
}

/// Clear cached identity keys from the OS keyring.
///
/// Returns `true` if the entry was deleted, `false` if it didn't exist or on error.
pub fn clear_cached_keys(signing_pubkey_hex: &str) -> bool {
    #[cfg(test)]
    {
        return mock_store::remove(signing_pubkey_hex);
    }

    #[cfg(not(test))]
    {
        let entry = match keyring::Entry::new(SERVICE, signing_pubkey_hex) {
            Ok(e) => e,
            Err(_) => return false,
        };

        match entry.delete_credential() {
            Ok(()) => true,
            Err(keyring::Error::NoEntry) => false,
            Err(_) => false,
        }
    }
}

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

    #[test]
    fn test_envelope_roundtrip_without_nostr() {
        let signing = SigningSecretKey::from_bytes([0xaa; 32]);
        let recipient = RecipientSecretKey::from_bytes([0xbb; 32]);
        let identity = Identity::from_bytes(&signing, &recipient);

        let envelope = build_cache_envelope(&identity);
        assert_eq!(envelope.len(), 66);
        assert_eq!(envelope[0], ENVELOPE_VERSION);
        assert_eq!(envelope[1], 0); // no nostr flag

        let restored = parse_cache_envelope(&envelope).expect("should parse");
        assert_eq!(identity.signing_pubkey(), restored.signing_pubkey());
        assert_eq!(identity.recipient_pubkey(), restored.recipient_pubkey());
    }

    #[test]
    fn test_envelope_roundtrip_with_nostr() {
        let signing = SigningSecretKey::from_bytes([0xaa; 32]);
        let recipient = RecipientSecretKey::from_bytes([0xbb; 32]);
        let nostr = NostrSecretKey::from_bytes([0xcc; 32]);
        let identity = Identity::from_bytes_with_nostr(&signing, &recipient, nostr);

        let envelope = build_cache_envelope(&identity);
        assert_eq!(envelope.len(), 98);
        assert_eq!(envelope[0], ENVELOPE_VERSION);
        assert_eq!(envelope[1], FLAG_HAS_NOSTR);

        let restored = parse_cache_envelope(&envelope).expect("should parse");
        assert_eq!(identity.signing_pubkey(), restored.signing_pubkey());
        assert_eq!(identity.recipient_pubkey(), restored.recipient_pubkey());
        assert_eq!(identity.nostr_pubkey(), restored.nostr_pubkey());
    }

    #[test]
    fn test_mock_store_roundtrip() {
        let signing = SigningSecretKey::from_bytes([0x11; 32]);
        let recipient = RecipientSecretKey::from_bytes([0x22; 32]);
        let identity = Identity::from_bytes(&signing, &recipient);

        let key = "test_mock_store_roundtrip";
        cache_keys(key, &identity);
        let loaded = load_cached_keys(key).expect("should find cached identity");
        assert_eq!(identity.signing_pubkey(), loaded.signing_pubkey());
        assert_eq!(identity.recipient_pubkey(), loaded.recipient_pubkey());

        assert!(clear_cached_keys(key));
        assert!(load_cached_keys(key).is_none());
    }

    #[test]
    fn test_parse_empty_data() {
        assert!(parse_cache_envelope(&[]).is_none());
    }

    #[test]
    fn test_parse_wrong_version() {
        let mut data = vec![0x99, 0x00];
        data.extend_from_slice(&[0u8; 64]);
        assert!(parse_cache_envelope(&data).is_none());
    }

    #[test]
    fn test_parse_wrong_length() {
        let data = vec![ENVELOPE_VERSION, 0x00, 0x01, 0x02];
        assert!(parse_cache_envelope(&data).is_none());
    }

    #[test]
    fn test_parse_nostr_flag_but_missing_data() {
        // Has nostr flag but only 66 bytes (missing the 32-byte nostr key)
        let mut data = vec![ENVELOPE_VERSION, FLAG_HAS_NOSTR];
        data.extend_from_slice(&[0u8; 64]);
        assert!(parse_cache_envelope(&data).is_none());
    }
}