rho-cli 0.1.28

Rho CLI tools for encrypted agent collaboration, dataset publishing, controlled runs, and result release workflows
Documentation
//! Rho Nostr messaging core: controller keys, NIP-44 v2 encryption, and the
//! event shapes used for discovery + encrypted DMs.
//!
//! Wire-compatible with the deployed relay and `relay/scripts/local-smoke.mjs`:
//! identity records are kind 30382, encrypted DMs are kind 1059 carrying a
//! `RhoEncryptedChatContent` envelope, and the crypto is standard NIP-44 v2.

use nostr::nips::nip44::{self, Version};
use nostr::{EventBuilder, Keys, Kind, PublicKey, Tag};
use serde::{Deserialize, Serialize};

use crate::RhoResult;

/// Parameterized-replaceable identity / directory record.
pub const IDENTITY_KIND: u16 = 30382;
/// Encrypted direct message (carries a `RhoEncryptedChatContent`).
pub const DM_KIND: u16 = 1059;
pub const IDENTITY_SCHEMA: &str = "rho.identity_record.v1";
pub const CHAT_SCHEMA: &str = "rho.encrypted_chat.v1";
pub const CHAT_SUITE: &str = "nip44-v2";

/// The opaque-to-relay payload stored in a kind 1059 event's `content`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RhoEncryptedChatContent {
    pub kind: String,
    pub version: u32,
    pub from: String,
    pub to: String,
    /// base64 NIP-44 v2 payload.
    pub envelope: String,
    pub suite: String,
}

/// A Rho messaging controller key (secp256k1 / Nostr). The secret never leaves
/// this process; only the x-only public key is published.
pub struct NostrIdentity {
    keys: Keys,
}

impl NostrIdentity {
    pub fn generate() -> Self {
        Self {
            keys: Keys::generate(),
        }
    }

    /// Load from a 64-char hex secret (or nsec).
    pub fn from_secret_hex(secret: &str) -> RhoResult<Self> {
        Ok(Self {
            keys: Keys::parse(secret.trim())?,
        })
    }

    pub fn secret_hex(&self) -> String {
        self.keys.secret_key().to_secret_hex()
    }

    /// x-only public key hex — this is the Nostr pubkey / controller id.
    pub fn pubkey_hex(&self) -> String {
        self.keys.public_key().to_hex()
    }

    pub fn keys(&self) -> &Keys {
        &self.keys
    }

    /// Encrypt `plaintext` to a recipient's x-only pubkey, returning the base64
    /// NIP-44 v2 envelope.
    pub fn encrypt_to(&self, recipient_pubkey_hex: &str, plaintext: &str) -> RhoResult<String> {
        let pk = PublicKey::from_hex(recipient_pubkey_hex)?;
        Ok(nip44::encrypt(
            self.keys.secret_key(),
            &pk,
            plaintext,
            Version::V2,
        )?)
    }

    /// Decrypt an envelope sent by `sender_pubkey_hex`.
    pub fn decrypt_from(&self, sender_pubkey_hex: &str, envelope: &str) -> RhoResult<String> {
        let pk = PublicKey::from_hex(sender_pubkey_hex)?;
        Ok(nip44::decrypt(self.keys.secret_key(), &pk, envelope)?)
    }
}

/// JSON content for a kind 30382 identity record. Matches the smoke fixture.
pub fn identity_record_content(
    rho_id: &str,
    display_name: &str,
    controller_pubkey_hex: &str,
    relay_url: &str,
) -> String {
    serde_json::json!({
        "schema": IDENTITY_SCHEMA,
        "revision": 1,
        "rho_id": rho_id,
        "controller": format!("nostr:{controller_pubkey_hex}"),
        "display_name": display_name,
        "keys": [{
            "kind": "messaging_ecdh",
            "algorithm": "nip44-v2",
            "public_key": controller_pubkey_hex,
        }],
        "resources": { "inbox": { "uri": relay_url } },
    })
    .to_string()
}

/// Build + sign a kind 30382 identity record event.
pub fn build_identity_event(
    identity: &NostrIdentity,
    rho_id: &str,
    display_name: &str,
    relay_url: &str,
) -> RhoResult<nostr::Event> {
    let pubkey = identity.pubkey_hex();
    let content = identity_record_content(rho_id, display_name, &pubkey, relay_url);
    let event = EventBuilder::new(Kind::from(IDENTITY_KIND), content)
        .tags([Tag::parse(["d", rho_id])?, Tag::parse(["p", &pubkey])?])
        .sign_with_keys(identity.keys())?;
    Ok(event)
}

/// Build + sign a kind 1059 encrypted DM to `recipient_pubkey_hex`.
pub fn build_dm_event(
    identity: &NostrIdentity,
    recipient_pubkey_hex: &str,
    from_rho_id: &str,
    to_rho_id: &str,
    plaintext: &str,
) -> RhoResult<nostr::Event> {
    let envelope = identity.encrypt_to(recipient_pubkey_hex, plaintext)?;
    let content = RhoEncryptedChatContent {
        kind: CHAT_SCHEMA.to_string(),
        version: 1,
        from: from_rho_id.to_string(),
        to: to_rho_id.to_string(),
        envelope,
        suite: CHAT_SUITE.to_string(),
    };
    let event = EventBuilder::new(Kind::from(DM_KIND), serde_json::to_string(&content)?)
        .tags([Tag::parse(["p", recipient_pubkey_hex])?])
        .sign_with_keys(identity.keys())?;
    Ok(event)
}

#[cfg(test)]
mod tests {
    use super::*;
    use nostr::nips::nip44::v2::ConversationKey;

    const SEC1: &str = "0000000000000000000000000000000000000000000000000000000000000001";
    const SEC2: &str = "0000000000000000000000000000000000000000000000000000000000000002";
    const EXPECTED_CK: &str = "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d";
    const EXPECTED_PAYLOAD: &str = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb";

    fn to_hex(bytes: &[u8]) -> String {
        bytes.iter().map(|b| format!("{b:02x}")).collect()
    }

    #[test]
    fn nip44_v2_matches_official_vector() {
        let alice = Keys::parse(SEC1).unwrap();
        let bob = Keys::parse(SEC2).unwrap();
        // Conversation key matches the published NIP-44 v2 vector.
        let ck = ConversationKey::derive(alice.secret_key(), &bob.public_key()).unwrap();
        assert_eq!(to_hex(ck.as_bytes()), EXPECTED_CK);
        // The exact payload the JS fixture produced decrypts back to "a".
        let plaintext =
            nip44::decrypt(bob.secret_key(), &alice.public_key(), EXPECTED_PAYLOAD).unwrap();
        assert_eq!(plaintext, "a");
    }

    #[test]
    fn encrypt_decrypt_roundtrip() {
        let alice = NostrIdentity::generate();
        let bob = NostrIdentity::generate();
        let env = alice.encrypt_to(&bob.pubkey_hex(), "hello bob").unwrap();
        let got = bob.decrypt_from(&alice.pubkey_hex(), &env).unwrap();
        assert_eq!(got, "hello bob");
    }

    #[test]
    fn secret_hex_roundtrips() {
        let id = NostrIdentity::generate();
        let again = NostrIdentity::from_secret_hex(&id.secret_hex()).unwrap();
        assert_eq!(id.pubkey_hex(), again.pubkey_hex());
    }

    #[test]
    fn identity_record_has_expected_shape() {
        let id = NostrIdentity::from_secret_hex(SEC1).unwrap();
        let content =
            identity_record_content("rho://id/github/alice", "Alice", &id.pubkey_hex(), "ws://x");
        let v: serde_json::Value = serde_json::from_str(&content).unwrap();
        assert_eq!(v["schema"], IDENTITY_SCHEMA);
        assert_eq!(v["rho_id"], "rho://id/github/alice");
        assert_eq!(v["controller"], format!("nostr:{}", id.pubkey_hex()));
        assert_eq!(v["keys"][0]["algorithm"], "nip44-v2");
    }

    #[test]
    fn dm_event_is_opaque_kind_1059() {
        let alice = NostrIdentity::generate();
        let bob = NostrIdentity::generate();
        let event = build_dm_event(
            &alice,
            &bob.pubkey_hex(),
            "rho://id/github/alice",
            "rho://id/github/bob",
            "secret text",
        )
        .unwrap();
        assert_eq!(event.kind, Kind::from(DM_KIND));
        // Plaintext must not leak into the relay-visible content.
        assert!(!event.content.contains("secret text"));
        let parsed: RhoEncryptedChatContent = serde_json::from_str(&event.content).unwrap();
        assert_eq!(parsed.suite, CHAT_SUITE);
        let back = bob
            .decrypt_from(&alice.pubkey_hex(), &parsed.envelope)
            .unwrap();
        assert_eq!(back, "secret text");
    }
}