iris-chat 0.1.47

Iris Chat command line client and shared encrypted chat core
Documentation
//! Private, opt-in synthetic identity receipt for an isolated control fixture.
#[cfg(unix)]
use anyhow::Context;
use anyhow::Result;
#[cfg(unix)]
use serde_json::json;
#[cfg(unix)]
use std::fs::OpenOptions;
#[cfg(unix)]
use std::io::Write;
use std::path::Path;

pub fn restore_exact(
    path: &Path,
    expected_owner: &str,
    expected_device: &str,
) -> Result<iris_chat_core::AppAction> {
    let action = restore(path, expected_device)?;
    anyhow::ensure!(
        matches!(&action, iris_chat_core::AppAction::RestoreAccountBundle { owner_pubkey_hex, .. }
        if owner_pubkey_hex == expected_owner),
        "Fixture owner does not match the saved pairing"
    );
    Ok(action)
}

#[cfg(unix)]
pub fn restore(path: &Path, expected_device: &str) -> Result<iris_chat_core::AppAction> {
    use nostr::{Keys, ToBech32};
    use std::os::unix::fs::PermissionsExt;
    #[derive(serde::Deserialize)]
    #[serde(deny_unknown_fields)]
    struct Bundle {
        version: u8,
        owner_nsec: String,
        owner_pubkey_hex: String,
        device_nsec: String,
    }
    let metadata = std::fs::symlink_metadata(path).context("Read control receipt metadata")?;
    anyhow::ensure!(
        metadata.is_file()
            && metadata.permissions().mode() & 0o777 == 0o600
            && (1..=4096).contains(&metadata.len()),
        "Control receipt must be a private regular file"
    );
    let bytes = std::fs::read(path).context("Read private control identity")?;
    let bundle: Bundle = serde_json::from_slice(&bytes)
        .map_err(|_| anyhow::anyhow!("Invalid control identity receipt"))?;
    anyhow::ensure!(
        bundle.version == 1
            && bundle.owner_nsec.starts_with("nsec1")
            && bundle.device_nsec.starts_with("nsec1"),
        "Unsupported control identity receipt"
    );
    let owner = Keys::parse(&bundle.owner_nsec)
        .map_err(|_| anyhow::anyhow!("Invalid control owner identity"))?;
    let device = Keys::parse(&bundle.device_nsec)
        .map_err(|_| anyhow::anyhow!("Invalid control device identity"))?;
    anyhow::ensure!(
        owner.public_key().to_hex() == bundle.owner_pubkey_hex
            && device.public_key().to_bech32()? == expected_device,
        "Control identity does not match preserved pairing"
    );
    Ok(iris_chat_core::AppAction::RestoreAccountBundle {
        owner_nsec: Some(bundle.owner_nsec),
        owner_pubkey_hex: bundle.owner_pubkey_hex,
        device_nsec: bundle.device_nsec,
    })
}

#[cfg(not(unix))]
pub fn restore(_path: &Path, _expected_device: &str) -> Result<iris_chat_core::AppAction> {
    anyhow::bail!("Private control identity receipts require a Unix host")
}

#[cfg(unix)]
pub fn save(path: &Path, owner_nsec: Option<&str>, owner: &str, device_nsec: &str) -> Result<()> {
    use std::os::unix::fs::OpenOptionsExt;
    let owner_nsec = owner_nsec.context("Control fixture requires its fresh owner identity")?;
    let mut options = OpenOptions::new();
    options.write(true).create_new(true).mode(0o600);
    let mut file = options
        .open(path)
        .context("Create private control identity receipt")?;
    serde_json::to_writer(&mut file, &json!({
        "version": 1, "owner_nsec": owner_nsec, "owner_pubkey_hex": owner, "device_nsec": device_nsec,
    })).context("Write private control identity receipt")?;
    file.flush()
        .context("Flush private control identity receipt")?;
    file.sync_all()
        .context("Sync private control identity receipt")?;
    Ok(())
}

#[cfg(not(unix))]
pub fn save(
    _path: &Path,
    _owner_nsec: Option<&str>,
    _owner: &str,
    _device_nsec: &str,
) -> Result<()> {
    anyhow::bail!("Private control identity receipts require a Unix host")
}

#[cfg(all(test, unix))]
mod tests {
    use super::*;
    use std::os::unix::fs::PermissionsExt;

    #[test]
    fn receipt_is_private_complete_and_cannot_overwrite_an_identity() {
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("bundle.json");
        save(
            &path,
            Some("test-owner-secret"),
            "test-owner",
            "test-device-secret",
        )
        .unwrap();
        assert_eq!(
            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
            0o600
        );
        let bytes = std::fs::read(&path).unwrap();
        let bundle: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(
            bundle,
            json!({"version":1,"owner_nsec":"test-owner-secret",
            "owner_pubkey_hex":"test-owner","device_nsec":"test-device-secret"})
        );
        assert!(save(&path, Some("replacement"), "replacement", "replacement").is_err());
        assert_eq!(std::fs::read(&path).unwrap(), bytes);
    }

    #[test]
    fn missing_owner_fails_without_creating_a_partial_receipt() {
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("bundle.json");
        assert!(save(&path, None, "test-owner", "test-device-secret").is_err());
        assert!(!path.exists());
    }

    #[test]
    fn restore_requires_private_receipt_and_exact_paired_device() {
        use nostr::{Keys, ToBech32};
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("bundle.json");
        let owner = Keys::generate();
        let device = Keys::generate();
        let expected = device.public_key().to_bech32().unwrap();
        save(
            &path,
            Some(&owner.secret_key().to_bech32().unwrap()),
            &owner.public_key().to_hex(),
            &device.secret_key().to_bech32().unwrap(),
        )
        .unwrap();
        assert!(matches!(
            restore(&path, &expected).unwrap(),
            iris_chat_core::AppAction::RestoreAccountBundle { .. }
        ));
        assert!(restore(&path, &owner.public_key().to_bech32().unwrap()).is_err());
        assert!(restore_exact(&path, &owner.public_key().to_hex(), &expected).is_ok());
        assert!(restore_exact(&path, &device.public_key().to_hex(), &expected).is_err());
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
        assert!(restore(&path, &expected).is_err());
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
        let link = directory.path().join("link.json");
        std::os::unix::fs::symlink(&path, &link).unwrap();
        assert!(restore(&link, &expected).is_err());
    }
}