Skip to main content

wire/
nip_w1.rs

1//! RFC-007 D3.4 — NIP-W1: wire pairing over Nostr (**no-SPAKE2 variant**).
2//!
3//! NIP-W1 was originally sketched with SPAKE2 (`kind 21001/21002`), but wire
4//! removed the SPAKE2/SAS flow post-RFC-005 and (operator decision, 2026-06-15)
5//! does not reintroduce it. So pairing over Nostr reuses wire's **existing**
6//! consent model instead of a PAKE:
7//!
8//! 1. Agent A (who knows B's npub) sends a **pair-request** — a Nostr event whose
9//!    content is A's signed agent card (carrying the D3.1 nostr binding),
10//!    **NIP-44-encrypted to B** and signed by A's secp transport key, `p`-tagged
11//!    to B's npub.
12//! 2. B decrypts it → stashes a pending-inbound pair. The existing bilateral
13//!    **`wire accept`** gesture is the consent ceremony — no PAKE, no SAS.
14//! 3. On accept, B replies with a **pair-ack** (same shape, B's card).
15//!
16//! Identity is the signed cards + cross-signed bindings; trust is the explicit
17//! accept. This module is the **offline event codec** (build + open the
18//! pair-request / pair-ack). The receiver consent gate + daemon routing
19//! integration are separate, deferred slices.
20
21use serde_json::{Value, json};
22
23use crate::nip44::{self, Nip44Error};
24use crate::nostr_event::{NostrEvent, NostrEventError, nostr_event_id, verify_transport};
25use crate::nostr_key::{schnorr_sign_digest, xonly_from_secret};
26
27/// Ephemeral-range kind for a wire pair-request over Nostr.
28pub const PAIR_REQUEST_KIND: u32 = 21050;
29/// Ephemeral-range kind for a wire pair-ack over Nostr.
30pub const PAIR_ACK_KIND: u32 = 21051;
31
32/// Which half of the pairing handshake an event is.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PairKind {
35    Request,
36    Ack,
37}
38
39impl PairKind {
40    fn kind(self) -> u32 {
41        match self {
42            PairKind::Request => PAIR_REQUEST_KIND,
43            PairKind::Ack => PAIR_ACK_KIND,
44        }
45    }
46    /// The `t` discriminator inside the encrypted payload (defence-in-depth: the
47    /// inner tag must agree with the outer kind).
48    fn tag(self) -> &'static str {
49        match self {
50            PairKind::Request => "pair_req",
51            PairKind::Ack => "pair_ack",
52        }
53    }
54    fn from_kind(kind: u32) -> Option<PairKind> {
55        match kind {
56            PAIR_REQUEST_KIND => Some(PairKind::Request),
57            PAIR_ACK_KIND => Some(PairKind::Ack),
58            _ => None,
59        }
60    }
61}
62
63#[derive(Debug, PartialEq, Eq)]
64pub enum NipW1Error {
65    /// The event's kind isn't a pairing kind.
66    NotPairing,
67    /// The transport layer (id / schnorr) didn't verify.
68    Transport(NostrEventError),
69    /// A secp key was malformed.
70    Key,
71    /// NIP-44 decryption failed (not addressed to me / tampered / wrong key).
72    Decrypt(Nip44Error),
73    /// The decrypted payload wasn't a valid pairing payload, or the inner tag
74    /// disagreed with the outer kind.
75    BadPayload,
76}
77
78impl std::fmt::Display for NipW1Error {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            NipW1Error::NotPairing => write!(f, "event kind is not a wire pairing kind"),
82            NipW1Error::Transport(e) => write!(f, "pairing event transport: {e}"),
83            NipW1Error::Key => write!(f, "malformed secp256k1 key"),
84            NipW1Error::Decrypt(e) => write!(f, "pairing payload decrypt: {e}"),
85            NipW1Error::BadPayload => write!(f, "malformed pairing payload"),
86        }
87    }
88}
89
90/// Build a NIP-44-encrypted pairing event carrying `my_card`, addressed to
91/// `peer_xonly`, signed by my secp transport key (`my_secp_sk`). `created_at` is
92/// supplied by the caller (unix seconds) to keep this pure/testable.
93pub fn build_pair_event(
94    pair: PairKind,
95    my_secp_sk: &[u8; 32],
96    peer_xonly: &[u8; 32],
97    my_card: &Value,
98    created_at: i64,
99) -> Result<NostrEvent, NipW1Error> {
100    let payload = json!({ "t": pair.tag(), "card": my_card }).to_string();
101    let ck = nip44::conversation_key(my_secp_sk, peer_xonly).map_err(|_| NipW1Error::Key)?;
102    let content = nip44::encrypt(&ck, &payload).map_err(NipW1Error::Decrypt)?;
103
104    let my_xonly = xonly_from_secret(my_secp_sk).map_err(|_| NipW1Error::Key)?;
105    let pubkey_hex = hex::encode(my_xonly);
106    let tags = vec![vec!["p".to_string(), hex::encode(peer_xonly)]];
107    let id = nostr_event_id(&pubkey_hex, created_at, pair.kind(), &tags, &content);
108    let sig = schnorr_sign_digest(my_secp_sk, &id).map_err(|_| NipW1Error::Key)?;
109
110    Ok(NostrEvent {
111        id: hex::encode(id),
112        pubkey: pubkey_hex,
113        created_at,
114        kind: pair.kind(),
115        tags,
116        content,
117        sig: hex::encode(sig),
118    })
119}
120
121/// Open a pairing event addressed to me (`my_secp_sk`). Verifies the transport
122/// layer, NIP-44-decrypts the payload (the conversation key is symmetric, so my
123/// secret + the sender's pubkey reproduce it), and parses it. Returns the
124/// half-of-handshake and the **sender's signed agent card** — which the caller
125/// must still verify (card signature + the D3.1 binding) before pinning.
126/// **Fail-closed.**
127pub fn open_pair_event(
128    ev: &NostrEvent,
129    my_secp_sk: &[u8; 32],
130) -> Result<(PairKind, Value), NipW1Error> {
131    let pair = PairKind::from_kind(ev.kind).ok_or(NipW1Error::NotPairing)?;
132    // Transport authenticity: id + schnorr under the sender's npub.
133    let sender_xonly = verify_transport(ev).map_err(NipW1Error::Transport)?;
134    // NIP-44 decrypt (only succeeds if the event was sealed to MY key).
135    let ck = nip44::conversation_key(my_secp_sk, &sender_xonly).map_err(|_| NipW1Error::Key)?;
136    let plaintext = nip44::decrypt(&ck, &ev.content).map_err(NipW1Error::Decrypt)?;
137
138    let v: Value = serde_json::from_str(&plaintext).map_err(|_| NipW1Error::BadPayload)?;
139    // Inner tag must agree with the outer kind (no cross-kind confusion).
140    if v.get("t").and_then(Value::as_str) != Some(pair.tag()) {
141        return Err(NipW1Error::BadPayload);
142    }
143    let card = v.get("card").cloned().ok_or(NipW1Error::BadPayload)?;
144    Ok((pair, card))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::agent_card::{build_agent_card, sign_agent_card};
151    use crate::nostr_key::generate_transport_key;
152    use crate::signing::generate_keypair;
153
154    fn a_signed_card(handle: &str) -> Value {
155        let (sk, pk) = generate_keypair();
156        sign_agent_card(&build_agent_card(handle, &pk, None, None, None), &sk)
157    }
158
159    #[test]
160    fn request_roundtrips_a_to_b() {
161        let (sk_a, _xa) = generate_transport_key();
162        let (sk_b, xb) = generate_transport_key();
163        let card_a = a_signed_card("slate-lotus");
164
165        let ev = build_pair_event(PairKind::Request, &sk_a, &xb, &card_a, 1_700_000_000).unwrap();
166        assert_eq!(ev.kind, PAIR_REQUEST_KIND);
167        // p-tagged to B.
168        assert_eq!(ev.tags[0], vec!["p".to_string(), hex::encode(xb)]);
169        // content is NOT plaintext (it's NIP-44 ciphertext).
170        assert!(!ev.content.contains("slate-lotus"));
171
172        // B opens it → gets A's card back.
173        let (pair, card) = open_pair_event(&ev, &sk_b).unwrap();
174        assert_eq!(pair, PairKind::Request);
175        assert_eq!(card, card_a);
176    }
177
178    #[test]
179    fn ack_roundtrips_and_carries_kind() {
180        let (sk_a, xa) = generate_transport_key();
181        let (sk_b, _xb) = generate_transport_key();
182        let card_b = a_signed_card("raven-kettle");
183        let ev = build_pair_event(PairKind::Ack, &sk_b, &xa, &card_b, 1_700_000_001).unwrap();
184        assert_eq!(ev.kind, PAIR_ACK_KIND);
185        let (pair, card) = open_pair_event(&ev, &sk_a).unwrap();
186        assert_eq!(pair, PairKind::Ack);
187        assert_eq!(card, card_b);
188    }
189
190    #[test]
191    fn a_third_party_cannot_decrypt() {
192        let (sk_a, _xa) = generate_transport_key();
193        let (_sk_b, xb) = generate_transport_key();
194        let (sk_c, _xc) = generate_transport_key(); // eavesdropper
195        let ev = build_pair_event(
196            PairKind::Request,
197            &sk_a,
198            &xb,
199            &a_signed_card("x"),
200            1_700_000_000,
201        )
202        .unwrap();
203        // C is not the addressee → NIP-44 MAC fails.
204        assert!(matches!(
205            open_pair_event(&ev, &sk_c),
206            Err(NipW1Error::Decrypt(_))
207        ));
208    }
209
210    #[test]
211    fn tampered_content_fails_transport() {
212        let (sk_a, _xa) = generate_transport_key();
213        let (sk_b, xb) = generate_transport_key();
214        let mut ev = build_pair_event(
215            PairKind::Request,
216            &sk_a,
217            &xb,
218            &a_signed_card("x"),
219            1_700_000_000,
220        )
221        .unwrap();
222        ev.content.push('A'); // id no longer matches the content
223        assert!(matches!(
224            open_pair_event(&ev, &sk_b),
225            Err(NipW1Error::Transport(_))
226        ));
227    }
228
229    #[test]
230    fn non_pairing_kind_rejected() {
231        let (sk_a, _xa) = generate_transport_key();
232        let (sk_b, xb) = generate_transport_key();
233        let mut ev = build_pair_event(
234            PairKind::Request,
235            &sk_a,
236            &xb,
237            &a_signed_card("x"),
238            1_700_000_000,
239        )
240        .unwrap();
241        // Re-sign under kind 1 so the transport still verifies but it's not a
242        // pairing kind.
243        ev.kind = 1;
244        ev.id = hex::encode(nostr_event_id(
245            &ev.pubkey,
246            ev.created_at,
247            ev.kind,
248            &ev.tags,
249            &ev.content,
250        ));
251        let sig = schnorr_sign_digest(&sk_a, &hex32(&ev.id)).unwrap();
252        ev.sig = hex::encode(sig);
253        assert_eq!(open_pair_event(&ev, &sk_b), Err(NipW1Error::NotPairing));
254    }
255
256    fn hex32(s: &str) -> [u8; 32] {
257        hex::decode(s).unwrap().as_slice().try_into().unwrap()
258    }
259}