use nostr::nips::nip44::{self, Version};
use nostr::{EventBuilder, Keys, Kind, PublicKey, Tag};
use serde::{Deserialize, Serialize};
use crate::RhoResult;
pub const IDENTITY_KIND: u16 = 30382;
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";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RhoEncryptedChatContent {
pub kind: String,
pub version: u32,
pub from: String,
pub to: String,
pub envelope: String,
pub suite: String,
}
pub struct NostrIdentity {
keys: Keys,
}
impl NostrIdentity {
pub fn generate() -> Self {
Self {
keys: Keys::generate(),
}
}
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()
}
pub fn pubkey_hex(&self) -> String {
self.keys.public_key().to_hex()
}
pub fn keys(&self) -> &Keys {
&self.keys
}
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,
)?)
}
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)?)
}
}
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()
}
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)
}
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();
let ck = ConversationKey::derive(alice.secret_key(), &bob.public_key()).unwrap();
assert_eq!(to_hex(ck.as_bytes()), EXPECTED_CK);
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));
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");
}
}