Skip to main content

wire/
nostr_event.rs

1//! RFC-007 D3.2a: the NIP-01 event codec — wire event ⇄ Nostr event.
2//!
3//! Wire events are already ~90% Nostr events (`signing.rs`: "Ed25519
4//! sign-over-event_id, NIP-01 style"; the kind ranges are NIP-01's). The gap is
5//! the wire format vs the exact NIP-01 envelope a public relay verifies. This
6//! module is that translation — pure + offline; the WebSocket plumbing that
7//! carries these events (`NostrWs` + the `Transport` trait) is the D3.2b slice.
8//!
9//! ## The two-signature chain
10//!
11//! A Nostr-delivered wire message carries TWO signatures:
12//!
13//! 1. **outer (transport)** — the secp256k1 schnorr signature over the NIP-01
14//!    `id`, by the agent's D3.1 transport key. This is what a public relay
15//!    checks; it proves the event came from that `npub`.
16//! 2. **inner (identity)** — the original Ed25519 wire signature, carried intact
17//!    inside the Nostr event's `content` (the full signed wire event). This
18//!    proves the message came from that `did:wire`.
19//!
20//! A receiver verifies both, plus the D3.1 binding tying `npub → did:wire`. No
21//! single signature is load-bearing alone: the transport sig says "this npub
22//! sent it", the binding says "this npub is that did", the inner sig says "that
23//! did authored it". The identity anchor stays Ed25519 (ONE-NAME invariant);
24//! the npub is transport only.
25
26use serde::{Deserialize, Serialize};
27use serde_json::{Value, json};
28use sha2::{Digest, Sha256};
29
30use crate::nostr_key::{
31    NostrKeyError, schnorr_sign_digest, schnorr_verify_digest, xonly_from_secret,
32};
33
34/// A NIP-01 event in its wire (relay) JSON shape. All binary fields are
35/// lowercase hex, per NIP-01.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct NostrEvent {
38    /// 32-byte event id (hex) = sha256 of the NIP-01 serialization.
39    pub id: String,
40    /// 32-byte x-only secp256k1 public key (hex) — the sender's npub material.
41    pub pubkey: String,
42    /// Unix seconds.
43    pub created_at: i64,
44    pub kind: u32,
45    /// NIP-01 tags (`[["p","<hex>"], ["wire","did:wire:…"], …]`).
46    pub tags: Vec<Vec<String>>,
47    /// Event content. For a wire message this is the FULL signed wire event JSON.
48    pub content: String,
49    /// 64-byte BIP-340 schnorr signature (hex) over `id`.
50    pub sig: String,
51}
52
53#[derive(Debug, PartialEq, Eq)]
54pub enum NostrEventError {
55    /// A required wire field was missing or the wrong type.
56    BadField(&'static str),
57    /// hex / length decode failure on a NIP-01 field.
58    BadEncoding,
59    /// The recomputed NIP-01 id did not match the event's `id`.
60    IdMismatch,
61    /// The schnorr signature did not verify, or a secp key was malformed.
62    Sig(NostrKeyError),
63    /// The `content` did not parse back into a JSON object (wire event).
64    BadContent,
65}
66
67impl std::fmt::Display for NostrEventError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            NostrEventError::BadField(s) => write!(f, "wire event missing/invalid field: {s}"),
71            NostrEventError::BadEncoding => write!(f, "malformed NIP-01 field encoding"),
72            NostrEventError::IdMismatch => write!(f, "NIP-01 id does not match the event body"),
73            NostrEventError::Sig(e) => write!(f, "NIP-01 signature: {e}"),
74            NostrEventError::BadContent => write!(f, "event content is not a wire event"),
75        }
76    }
77}
78
79/// Compute the 32-byte NIP-01 event id: `sha256` over the canonical NIP-01
80/// serialization `[0, pubkey, created_at, kind, tags, content]`.
81///
82/// NIP-01 mandates a compact (no-whitespace) UTF-8 JSON array that escapes only
83/// the minimal set (`"`, `\`, and control chars) and does NOT `\u`-escape
84/// non-ASCII. `serde_json`'s default string serialization produces exactly that,
85/// so the array built here serializes to the spec-required preimage.
86pub fn nostr_event_id(
87    pubkey_hex: &str,
88    created_at: i64,
89    kind: u32,
90    tags: &[Vec<String>],
91    content: &str,
92) -> [u8; 32] {
93    let preimage = serde_json::to_string(&json!([0, pubkey_hex, created_at, kind, tags, content]))
94        .expect("a JSON array of scalars/strings always serializes");
95    let mut h = Sha256::new();
96    h.update(preimage.as_bytes());
97    let d = h.finalize();
98    let mut out = [0u8; 32];
99    out.copy_from_slice(&d);
100    out
101}
102
103/// Parse an RFC-3339 timestamp into unix seconds.
104fn unix_from_rfc3339(ts: &str) -> Option<i64> {
105    time::OffsetDateTime::parse(ts, &time::format_description::well_known::Rfc3339)
106        .ok()
107        .map(|t| t.unix_timestamp())
108}
109
110/// Encode a signed wire event as a NIP-01 event, signed by the secp256k1
111/// transport key (`nostr_secp_sk`). The full wire event rides in `content` (its
112/// inner Ed25519 signature intact); `kind` and `created_at` mirror the wire
113/// event; a `["wire", <from_did>]` tag carries the sender's did for traceability.
114/// Recipient `p`-tags are added by the transport/routing layer (D3.2b).
115pub fn wire_to_nostr(
116    wire_event: &Value,
117    nostr_secp_sk: &[u8; 32],
118) -> Result<NostrEvent, NostrEventError> {
119    wire_to_nostr_tagged(wire_event, nostr_secp_sk, &[])
120}
121
122/// Encode a signed wire event as a NIP-01 event **addressed to a recipient** —
123/// same as [`wire_to_nostr`] but adds a `["p", <peer_xonly_hex>]` tag so the
124/// peer's `#p`-filtered relay subscription (see `wire nostr fetch`) selects it.
125/// The p-tag is part of the signed `id` preimage, so it must be baked in here,
126/// before signing — it cannot be appended after the fact.
127pub fn wire_to_nostr_addressed(
128    wire_event: &Value,
129    nostr_secp_sk: &[u8; 32],
130    peer_xonly_hex: &str,
131) -> Result<NostrEvent, NostrEventError> {
132    let p_tag = vec!["p".to_string(), peer_xonly_hex.to_string()];
133    wire_to_nostr_tagged(wire_event, nostr_secp_sk, std::slice::from_ref(&p_tag))
134}
135
136/// Shared builder: encode `wire_event` as a NIP-01 event signed by the secp
137/// transport key, with `["wire", <from_did>]` first and any `extra_tags`
138/// appended (e.g. a recipient `p`-tag) — all covered by the signed id.
139fn wire_to_nostr_tagged(
140    wire_event: &Value,
141    nostr_secp_sk: &[u8; 32],
142    extra_tags: &[Vec<String>],
143) -> Result<NostrEvent, NostrEventError> {
144    let kind = wire_event
145        .get("kind")
146        .and_then(Value::as_u64)
147        .ok_or(NostrEventError::BadField("kind"))? as u32;
148    let ts = wire_event
149        .get("timestamp")
150        .and_then(Value::as_str)
151        .ok_or(NostrEventError::BadField("timestamp"))?;
152    let created_at = unix_from_rfc3339(ts).ok_or(NostrEventError::BadField("timestamp"))?;
153
154    let xonly = xonly_from_secret(nostr_secp_sk).map_err(NostrEventError::Sig)?;
155    let pubkey_hex = hex::encode(xonly);
156
157    let mut tags: Vec<Vec<String>> = Vec::new();
158    if let Some(from) = wire_event.get("from").and_then(Value::as_str) {
159        tags.push(vec!["wire".to_string(), from.to_string()]);
160    }
161    tags.extend(extra_tags.iter().cloned());
162
163    // content = the full signed wire event (inner Ed25519 sig preserved).
164    let content = serde_json::to_string(wire_event).map_err(|_| NostrEventError::BadContent)?;
165
166    let id = nostr_event_id(&pubkey_hex, created_at, kind, &tags, &content);
167    let sig = schnorr_sign_digest(nostr_secp_sk, &id).map_err(NostrEventError::Sig)?;
168
169    Ok(NostrEvent {
170        id: hex::encode(id),
171        pubkey: pubkey_hex,
172        created_at,
173        kind,
174        tags,
175        content,
176        sig: hex::encode(sig),
177    })
178}
179
180/// Verify a NIP-01 event's transport layer and return the inner wire event.
181///
182/// Checks (fail-closed): (1) the recomputed NIP-01 id matches `ev.id`, (2) the
183/// schnorr signature verifies under `ev.pubkey`. Returns the parsed wire event
184/// from `content`. The caller MUST still verify the inner Ed25519 wire signature
185/// and the D3.1 `npub → did` binding before trusting the message — this function
186/// only authenticates the *transport* hop.
187pub fn verify_and_decode(ev: &NostrEvent) -> Result<Value, NostrEventError> {
188    verify_transport(ev)?;
189    let wire: Value = serde_json::from_str(&ev.content).map_err(|_| NostrEventError::BadContent)?;
190    if !wire.is_object() {
191        return Err(NostrEventError::BadContent);
192    }
193    Ok(wire)
194}
195
196/// Authenticate a Nostr event's transport layer **without** interpreting its
197/// `content`: recompute the NIP-01 id and verify the schnorr signature under
198/// `ev.pubkey`. Returns the sender's x-only pubkey on success. Used for events
199/// whose content is not a wire event — e.g. a NIP-44-encrypted pairing payload
200/// (NIP-W1, D3.4) — where `verify_and_decode`'s wire-event parse doesn't apply.
201pub fn verify_transport(ev: &NostrEvent) -> Result<[u8; 32], NostrEventError> {
202    let pubkey = hex_exact::<32>(&ev.pubkey)?;
203    let claimed_id = hex_exact::<32>(&ev.id)?;
204    let sig = hex_exact::<64>(&ev.sig)?;
205    let id = nostr_event_id(&ev.pubkey, ev.created_at, ev.kind, &ev.tags, &ev.content);
206    if id != claimed_id {
207        return Err(NostrEventError::IdMismatch);
208    }
209    schnorr_verify_digest(&pubkey, &id, &sig).map_err(NostrEventError::Sig)?;
210    Ok(pubkey)
211}
212
213/// Decode a hex string into exactly `N` bytes; wrong length or bad hex →
214/// `BadEncoding`.
215fn hex_exact<const N: usize>(s: &str) -> Result<[u8; N], NostrEventError> {
216    let v = hex::decode(s).map_err(|_| NostrEventError::BadEncoding)?;
217    v.as_slice()
218        .try_into()
219        .map_err(|_| NostrEventError::BadEncoding)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::nostr_key::generate_transport_key;
226    use crate::signing::{generate_keypair, sign_message_v31};
227
228    fn signed_wire_event() -> Value {
229        let (sk, pk) = generate_keypair();
230        let msg = json!({
231            "v": "3.1",
232            "timestamp": "2026-06-14T12:00:00Z",
233            "from": "did:wire:slate-lotus-88232017",
234            "to": "did:wire:raven-kettle-1234",
235            "kind": 1,
236            "body": {"content": "hello over nostr"},
237        });
238        sign_message_v31(&msg, &sk, &pk, "slate-lotus").unwrap()
239    }
240
241    #[test]
242    fn id_is_sha256_of_canonical_nip01_array() {
243        // The preimage is the compact array; the id is its sha256. Lock the
244        // serialization shape (compact, no spaces, 0-prefixed).
245        let preimage = serde_json::to_string(&json!([
246            0,
247            "ab",
248            1700000000i64,
249            1u32,
250            [["wire", "x"]],
251            "hi"
252        ]))
253        .unwrap();
254        assert_eq!(preimage, r#"[0,"ab",1700000000,1,[["wire","x"]],"hi"]"#);
255    }
256
257    #[test]
258    fn roundtrip_encode_then_verify() {
259        let wire = signed_wire_event();
260        let (nsk, _x) = generate_transport_key();
261        let ev = wire_to_nostr(&wire, &nsk).unwrap();
262        // The transport layer authenticates + hands back the inner wire event.
263        let decoded = verify_and_decode(&ev).unwrap();
264        assert_eq!(decoded, wire, "inner wire event must survive intact");
265        // kind + created_at mirror the wire event.
266        assert_eq!(ev.kind, 1);
267        assert_eq!(
268            ev.created_at,
269            unix_from_rfc3339("2026-06-14T12:00:00Z").unwrap()
270        );
271        // The sender's did rides as a wire tag.
272        assert!(ev.tags.iter().any(|t| t[0] == "wire"));
273    }
274
275    #[test]
276    fn addressed_event_carries_p_tag_and_still_verifies() {
277        let wire = signed_wire_event();
278        let (nsk, _x) = generate_transport_key();
279        let (_psk, peer_x) = generate_transport_key();
280        let peer_hex = hex::encode(peer_x);
281        let ev = wire_to_nostr_addressed(&wire, &nsk, &peer_hex).unwrap();
282        // The recipient p-tag is present (this is what the peer's #p filter selects on).
283        assert!(
284            ev.tags
285                .iter()
286                .any(|t| t.first().map(String::as_str) == Some("p") && t.get(1) == Some(&peer_hex)),
287            "addressed event must carry a [\"p\", <peer>] tag, tags={:?}",
288            ev.tags
289        );
290        // The wire did-tag still leads (order is stable).
291        assert_eq!(ev.tags[0][0], "wire");
292        // Transport still authenticates and the inner wire event survives intact —
293        // proving the p-tag was part of the signed id, not appended after.
294        assert_eq!(verify_and_decode(&ev).unwrap(), wire);
295    }
296
297    #[test]
298    fn tampered_content_fails_id_check() {
299        let wire = signed_wire_event();
300        let (nsk, _x) = generate_transport_key();
301        let mut ev = wire_to_nostr(&wire, &nsk).unwrap();
302        ev.content.push_str("tampered");
303        assert_eq!(verify_and_decode(&ev), Err(NostrEventError::IdMismatch));
304    }
305
306    #[test]
307    fn tampered_id_with_resigned_body_still_needs_matching_sig() {
308        // Recompute a valid id for tampered content but keep the old sig → the
309        // schnorr check fails (sig was over the original id).
310        let wire = signed_wire_event();
311        let (nsk, _x) = generate_transport_key();
312        let mut ev = wire_to_nostr(&wire, &nsk).unwrap();
313        ev.content = serde_json::to_string(&json!({"kind":1,"body":"evil"})).unwrap();
314        ev.id = hex::encode(nostr_event_id(
315            &ev.pubkey,
316            ev.created_at,
317            ev.kind,
318            &ev.tags,
319            &ev.content,
320        ));
321        // id now matches the tampered content, but the sig doesn't.
322        assert_eq!(
323            verify_and_decode(&ev),
324            Err(NostrEventError::Sig(NostrKeyError::PossessionSig))
325        );
326    }
327
328    #[test]
329    fn forged_pubkey_rejected() {
330        // Swap in a different pubkey: the id recompute incorporates it, so either
331        // the id mismatches or (if id is recomputed) the sig fails under the new
332        // key. Here we recompute id for the swapped key → sig fails.
333        let wire = signed_wire_event();
334        let (nsk, _x) = generate_transport_key();
335        let mut ev = wire_to_nostr(&wire, &nsk).unwrap();
336        let (_other_sk, other_x) = generate_transport_key();
337        ev.pubkey = hex::encode(other_x);
338        ev.id = hex::encode(nostr_event_id(
339            &ev.pubkey,
340            ev.created_at,
341            ev.kind,
342            &ev.tags,
343            &ev.content,
344        ));
345        assert_eq!(
346            verify_and_decode(&ev),
347            Err(NostrEventError::Sig(NostrKeyError::PossessionSig))
348        );
349    }
350
351    #[test]
352    fn missing_kind_or_timestamp_errors() {
353        let (nsk, _x) = generate_transport_key();
354        assert_eq!(
355            wire_to_nostr(&json!({"timestamp":"2026-06-14T12:00:00Z"}), &nsk),
356            Err(NostrEventError::BadField("kind"))
357        );
358        assert_eq!(
359            wire_to_nostr(&json!({"kind":1}), &nsk),
360            Err(NostrEventError::BadField("timestamp"))
361        );
362    }
363
364    #[test]
365    fn nostr_event_serde_roundtrips_relay_shape() {
366        let wire = signed_wire_event();
367        let (nsk, _x) = generate_transport_key();
368        let ev = wire_to_nostr(&wire, &nsk).unwrap();
369        let s = serde_json::to_string(&ev).unwrap();
370        // The relay JSON has exactly the NIP-01 keys.
371        let v: Value = serde_json::from_str(&s).unwrap();
372        for k in [
373            "id",
374            "pubkey",
375            "created_at",
376            "kind",
377            "tags",
378            "content",
379            "sig",
380        ] {
381            assert!(v.get(k).is_some(), "NIP-01 event must carry `{k}`");
382        }
383        let back: NostrEvent = serde_json::from_str(&s).unwrap();
384        assert_eq!(back, ev);
385    }
386}