Skip to main content

wire/
nostr_key.rs

1//! RFC-007 D3.1 (curve spike → Option 1): the Nostr **transport** key binding.
2//!
3//! Wire identities are Ed25519; Nostr verifies secp256k1/schnorr (BIP-340) and
4//! public relays reject anything else. The curve spike
5//! (`docs/history/0007-spike-curve-derivation.md`) resolved the gap to **dual-key,
6//! transport-only, cross-signed** — never derive one key from the other (the
7//! cross-curve anti-pattern SLIP-0010 exists to prevent). So an agent keeps its
8//! one Ed25519 identity and mints a SEPARATE secp256k1 key that is *only* a
9//! transport endpoint, bound to the identity by a cross-signature.
10//!
11//! ## The binding (mutual)
12//!
13//! Carried as an additive `nostr_pubkey` card field (sibling of `dh_pubkey` /
14//! `op_did`, RFC-006 reservation discipline). Both directions are proven over
15//! the domain-separated message
16//!
17//! ```text
18//! wire-nostr-binding-v1|<session_did>|<nostr_xonly_hex>
19//! ```
20//!
21//! - **`ed_sig`** — the Ed25519 *identity* key signs the message: "this npub is
22//!   my Nostr transport". This is the spike's specified direction.
23//! - **`schnorr_sig`** — the secp256k1 transport key signs the same message:
24//!   proof-of-possession. Without it, a card could claim *any* npub as its
25//!   transport (the card signature alone would "vouch" for a key the agent
26//!   doesn't hold) — letting an agent squat someone else's npub binding. The
27//!   possession proof closes that.
28//!
29//! ## ONE-NAME invariant
30//!
31//! The secp key is a transport endpoint, **never** a persona/identity anchor.
32//! `did:wire` stays the only name; `nostr_pubkey` is plumbing. No code path may
33//! promote an npub to an identity. (`[[project_wire_one_name_invariant]]`.)
34
35use rand::RngCore;
36use secp256k1::{Keypair, Secp256k1, SecretKey, XOnlyPublicKey, schnorr::Signature};
37use sha2::{Digest, Sha256};
38
39use crate::identity::{CertError, sign_did_cert, verify_payload_sig};
40use crate::signing::{b64decode, b64encode};
41
42/// Domain-separation tag for the cross-signature. `v1` lets the binding
43/// construction evolve without renaming the card field.
44pub const NOSTR_BINDING_DOMAIN: &str = "wire-nostr-binding-v1";
45
46/// Errors building / verifying a Nostr transport binding. Verify-side variants
47/// are all fall-throughs: an invalid binding means "no usable Nostr transport",
48/// never a hard failure of card processing.
49#[derive(Debug, PartialEq, Eq)]
50pub enum NostrKeyError {
51    /// A secp256k1 secret/pubkey/signature was malformed.
52    Secp,
53    /// A field was not valid base64, or the wrong length.
54    BadEncoding,
55    /// The Ed25519 identity cross-signature did not verify.
56    IdentitySig(CertError),
57    /// The secp256k1 possession (schnorr) signature did not verify.
58    PossessionSig,
59}
60
61impl std::fmt::Display for NostrKeyError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            NostrKeyError::Secp => write!(f, "malformed secp256k1 key/signature"),
65            NostrKeyError::BadEncoding => write!(f, "malformed binding field encoding"),
66            NostrKeyError::IdentitySig(e) => write!(f, "identity cross-signature: {e}"),
67            NostrKeyError::PossessionSig => write!(f, "secp256k1 possession proof failed"),
68        }
69    }
70}
71
72/// The three card fields of a Nostr transport binding (all base64).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct NostrBinding {
75    /// secp256k1 x-only public key (32 bytes) — the Nostr `npub` material.
76    pub pubkey: String,
77    /// Ed25519 signature by the identity key over the binding message.
78    pub ed_sig: String,
79    /// schnorr signature by the secp256k1 transport key over the binding message
80    /// (proof-of-possession).
81    pub schnorr_sig: String,
82}
83
84/// Generate a fresh secp256k1 transport keypair. Returns `(secret_32, xonly_32)`.
85/// The secret is stored under `nostr.key`; the x-only pubkey is the npub.
86pub fn generate_transport_key() -> ([u8; 32], [u8; 32]) {
87    let secp = Secp256k1::new();
88    // Use the project's rand (0.8) rather than secp256k1's bundled rand trait,
89    // and rejection-sample into a valid secret scalar (a uniform 32-byte draw is
90    // out of `[1, n-1]` only with negligible probability).
91    let mut rng = rand::thread_rng();
92    loop {
93        let mut seed = [0u8; 32];
94        rng.fill_bytes(&mut seed);
95        if let Ok(sk) = SecretKey::from_byte_array(seed) {
96            let kp = Keypair::from_secret_key(&secp, &sk);
97            return (sk.secret_bytes(), kp.x_only_public_key().0.serialize());
98        }
99    }
100}
101
102/// The x-only public key for a stored secret.
103pub fn xonly_from_secret(secret: &[u8; 32]) -> Result<[u8; 32], NostrKeyError> {
104    let secp = Secp256k1::new();
105    let kp = Keypair::from_seckey_byte_array(&secp, *secret).map_err(|_| NostrKeyError::Secp)?;
106    Ok(kp.x_only_public_key().0.serialize())
107}
108
109/// The domain-separated message both keys sign. The npub is lowercase hex so
110/// the message is a plain printable string on the same `sign_did_cert` path the
111/// other wire certs use.
112pub fn binding_payload(session_did: &str, nostr_xonly: &[u8; 32]) -> String {
113    format!(
114        "{NOSTR_BINDING_DOMAIN}|{session_did}|{}",
115        hex::encode(nostr_xonly)
116    )
117}
118
119/// The 32-byte digest the secp/schnorr signature is computed over (BIP-340
120/// signs a 32-byte message). sha256 of the canonical binding string.
121fn binding_digest(session_did: &str, nostr_xonly: &[u8; 32]) -> [u8; 32] {
122    let mut h = Sha256::new();
123    h.update(binding_payload(session_did, nostr_xonly).as_bytes());
124    let d = h.finalize();
125    let mut out = [0u8; 32];
126    out.copy_from_slice(&d);
127    out
128}
129
130/// Build the mutual binding for `session_did`: the Ed25519 identity key vouches
131/// for the secp transport key, and the secp key proves possession.
132pub fn build_binding(
133    session_ed_sk: &[u8],
134    nostr_secp_sk: &[u8; 32],
135    session_did: &str,
136) -> Result<NostrBinding, NostrKeyError> {
137    let secp = Secp256k1::new();
138    let kp =
139        Keypair::from_seckey_byte_array(&secp, *nostr_secp_sk).map_err(|_| NostrKeyError::Secp)?;
140    let xonly = kp.x_only_public_key().0.serialize();
141
142    // Identity direction: Ed25519 over the canonical string.
143    let payload = binding_payload(session_did, &xonly);
144    let ed_sig = sign_did_cert(session_ed_sk, &payload).map_err(NostrKeyError::IdentitySig)?;
145
146    // Possession direction: schnorr (BIP-340) over the 32-byte digest.
147    let digest = binding_digest(session_did, &xonly);
148    let sig = schnorr_sign_digest(nostr_secp_sk, &digest)?;
149
150    Ok(NostrBinding {
151        pubkey: b64encode(&xonly),
152        ed_sig,
153        schnorr_sig: b64encode(&sig),
154    })
155}
156
157/// Sign a 32-byte digest with a secp256k1 key (BIP-340 schnorr, no aux rand →
158/// deterministic). Returns the 64-byte signature. The low-level secp primitive
159/// behind the binding's possession proof AND the NIP-01 event signature (D3.2a),
160/// so secp usage stays centralized in this module.
161pub fn schnorr_sign_digest(
162    secp_sk: &[u8; 32],
163    digest: &[u8; 32],
164) -> Result<[u8; 64], NostrKeyError> {
165    let secp = Secp256k1::new();
166    let kp = Keypair::from_seckey_byte_array(&secp, *secp_sk).map_err(|_| NostrKeyError::Secp)?;
167    Ok(*secp.sign_schnorr_no_aux_rand(digest, &kp).as_ref())
168}
169
170/// Verify a 64-byte schnorr signature over a 32-byte digest under an x-only
171/// public key. `Err(PossessionSig)` on any failure (malformed key/sig or bad
172/// signature) — fail-closed.
173pub fn schnorr_verify_digest(
174    xonly: &[u8; 32],
175    digest: &[u8; 32],
176    sig: &[u8; 64],
177) -> Result<(), NostrKeyError> {
178    let secp = Secp256k1::new();
179    let pk = XOnlyPublicKey::from_byte_array(*xonly).map_err(|_| NostrKeyError::Secp)?;
180    let sig = Signature::from_byte_array(*sig);
181    secp.verify_schnorr(&sig, digest, &pk)
182        .map_err(|_| NostrKeyError::PossessionSig)
183}
184
185/// Verify a card's Nostr binding (both directions). `session_ed_pubkey` is the
186/// card's identity verify key. Returns the verified x-only npub on success.
187/// **Fail-closed**: any malformed/failed check returns `Err`.
188pub fn verify_binding(
189    session_ed_pubkey: &[u8],
190    pubkey_b64: &str,
191    ed_sig_b64: &str,
192    schnorr_sig_b64: &str,
193    session_did: &str,
194) -> Result<[u8; 32], NostrKeyError> {
195    let xonly_bytes = b64decode(pubkey_b64).map_err(|_| NostrKeyError::BadEncoding)?;
196    if xonly_bytes.len() != 32 {
197        return Err(NostrKeyError::BadEncoding);
198    }
199    let mut xonly_arr = [0u8; 32];
200    xonly_arr.copy_from_slice(&xonly_bytes);
201
202    // Identity cross-signature (Ed25519 over the canonical string).
203    let payload = binding_payload(session_did, &xonly_arr);
204    verify_payload_sig(session_ed_pubkey, ed_sig_b64, &payload)
205        .map_err(NostrKeyError::IdentitySig)?;
206
207    // Possession proof (schnorr over the digest).
208    let sig_bytes = b64decode(schnorr_sig_b64).map_err(|_| NostrKeyError::BadEncoding)?;
209    let sig_arr: [u8; 64] = sig_bytes
210        .as_slice()
211        .try_into()
212        .map_err(|_| NostrKeyError::BadEncoding)?;
213    let digest = binding_digest(session_did, &xonly_arr);
214    schnorr_verify_digest(&xonly_arr, &digest, &sig_arr)?;
215
216    Ok(xonly_arr)
217}
218
219/// Read `nostr_pubkey` (the binding sub-object) from a card and verify it
220/// against the card's identity key. `Ok(None)` = no Nostr transport claimed;
221/// `Err` = a claim is present but broken. The session's identity verify key is
222/// passed in (the caller already resolved it from the card's `verify_keys`).
223pub fn card_nostr_binding(
224    card: &serde_json::Value,
225    session_ed_pubkey: &[u8],
226) -> Result<Option<[u8; 32]>, NostrKeyError> {
227    let Some(b) = card.get("nostr_pubkey") else {
228        return Ok(None);
229    };
230    let session_did = card.get("did").and_then(|v| v.as_str()).unwrap_or_default();
231    let pubkey = b.get("pubkey").and_then(|v| v.as_str());
232    let ed_sig = b.get("ed_sig").and_then(|v| v.as_str());
233    let schnorr_sig = b.get("schnorr_sig").and_then(|v| v.as_str());
234    let (Some(pubkey), Some(ed_sig), Some(schnorr_sig)) = (pubkey, ed_sig, schnorr_sig) else {
235        return Err(NostrKeyError::BadEncoding);
236    };
237    verify_binding(session_ed_pubkey, pubkey, ed_sig, schnorr_sig, session_did).map(Some)
238}
239
240/// Card-emit hook (RFC-007 D3.1): if a Nostr transport key is present
241/// (`nostr.key`), attach a freshly cross-signed `nostr_pubkey` binding to the
242/// (unsigned) `card`. No-op when not keyed, so card-build stays correct for the
243/// common case. The returned card is UNSIGNED; the caller signs it. Fail-soft —
244/// a build error degrades to "no binding" rather than breaking card-build
245/// (init/up is critical-path).
246pub fn with_nostr_binding_if_keyed(
247    mut card: crate::agent_card::AgentCard,
248) -> anyhow::Result<crate::agent_card::AgentCard> {
249    let Ok(nostr_sk) = crate::config::read_nostr_key() else {
250        return Ok(card); // no transport key → no binding
251    };
252    let Ok(session_sk) = crate::config::read_private_key() else {
253        return Ok(card);
254    };
255    let session_did = card
256        .get("did")
257        .and_then(|v| v.as_str())
258        .unwrap_or_default()
259        .to_string();
260    if session_did.is_empty() {
261        return Ok(card);
262    }
263    match build_binding(&session_sk, &nostr_sk, &session_did) {
264        Ok(b) => {
265            if let Some(obj) = card.as_object_mut() {
266                obj.insert(
267                    "nostr_pubkey".into(),
268                    serde_json::json!({
269                        "pubkey": b.pubkey,
270                        "ed_sig": b.ed_sig,
271                        "schnorr_sig": b.schnorr_sig,
272                    }),
273                );
274            }
275            Ok(card)
276        }
277        Err(e) => {
278            eprintln!("wire: nostr binding skipped (build failed: {e})");
279            Ok(card)
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::signing::generate_keypair;
288
289    #[test]
290    fn roundtrip_binding_verifies() {
291        let (ed_sk, ed_pk) = generate_keypair();
292        let (nostr_sk, nostr_xonly) = generate_transport_key();
293        let did = "did:wire:slate-lotus-88232017";
294        let b = build_binding(&ed_sk, &nostr_sk, did).unwrap();
295        // The published pubkey is the transport key's x-only.
296        assert_eq!(b64decode(&b.pubkey).unwrap(), nostr_xonly.to_vec());
297        // Both directions verify, and the recovered npub matches.
298        assert_eq!(
299            verify_binding(&ed_pk, &b.pubkey, &b.ed_sig, &b.schnorr_sig, did),
300            Ok(nostr_xonly)
301        );
302    }
303
304    #[test]
305    fn wrong_identity_key_rejected() {
306        let (ed_sk, _ed_pk) = generate_keypair();
307        let (_other_sk, other_pk) = generate_keypair();
308        let (nostr_sk, _x) = generate_transport_key();
309        let did = "did:wire:x-1";
310        let b = build_binding(&ed_sk, &nostr_sk, did).unwrap();
311        // Verifying under a DIFFERENT identity key fails the identity direction.
312        assert!(matches!(
313            verify_binding(&other_pk, &b.pubkey, &b.ed_sig, &b.schnorr_sig, did),
314            Err(NostrKeyError::IdentitySig(_))
315        ));
316    }
317
318    /// npub-squat: an attacker publishes a victim's npub but cannot produce the
319    /// schnorr possession proof (doesn't hold the secp secret). Forge the
320    /// identity sig over the victim's key — possession proof still fails.
321    #[test]
322    fn npub_squat_without_possession_rejected() {
323        let (ed_sk, ed_pk) = generate_keypair();
324        let (_victim_sk, victim_xonly) = generate_transport_key();
325        let did = "did:wire:squatter-9";
326        // Attacker legitimately signs (their OWN identity) over the victim's npub,
327        // but has no schnorr sig for it — fake one from a key they DO hold.
328        let payload = binding_payload(did, &victim_xonly);
329        let ed_sig = sign_did_cert(&ed_sk, &payload).unwrap();
330        let (attacker_sk, _ax) = generate_transport_key();
331        let secp = Secp256k1::new();
332        let kp = Keypair::from_seckey_byte_array(&secp, attacker_sk).unwrap();
333        let digest = binding_digest(did, &victim_xonly);
334        let bad_sig = secp.sign_schnorr_no_aux_rand(&digest, &kp);
335        assert_eq!(
336            verify_binding(
337                &ed_pk,
338                &b64encode(&victim_xonly),
339                &ed_sig,
340                &b64encode(bad_sig.as_ref()),
341                did
342            ),
343            Err(NostrKeyError::PossessionSig)
344        );
345    }
346
347    #[test]
348    fn tampered_did_rejected() {
349        let (ed_sk, ed_pk) = generate_keypair();
350        let (nostr_sk, _x) = generate_transport_key();
351        let b = build_binding(&ed_sk, &nostr_sk, "did:wire:real-1").unwrap();
352        // Verify against a different session_did → both sigs were over the real
353        // one → identity direction fails first.
354        assert!(
355            verify_binding(
356                &ed_pk,
357                &b.pubkey,
358                &b.ed_sig,
359                &b.schnorr_sig,
360                "did:wire:other-2"
361            )
362            .is_err()
363        );
364    }
365
366    #[test]
367    fn malformed_fields_rejected() {
368        let (_sk, pk) = generate_keypair();
369        assert_eq!(
370            verify_binding(&pk, "!!notb64", "x", "y", "did:wire:z"),
371            Err(NostrKeyError::BadEncoding)
372        );
373        assert_eq!(
374            verify_binding(&pk, &b64encode(b"short"), "x", "y", "did:wire:z"),
375            Err(NostrKeyError::BadEncoding)
376        );
377    }
378
379    #[test]
380    fn xonly_from_secret_matches_generation() {
381        let (sk, xonly) = generate_transport_key();
382        assert_eq!(xonly_from_secret(&sk).unwrap(), xonly);
383    }
384
385    #[test]
386    fn card_nostr_binding_reads_and_verifies() {
387        let (ed_sk, ed_pk) = generate_keypair();
388        let (nostr_sk, nostr_xonly) = generate_transport_key();
389        let did = "did:wire:reader-1";
390        let b = build_binding(&ed_sk, &nostr_sk, did).unwrap();
391        let card = serde_json::json!({
392            "did": did,
393            "nostr_pubkey": {
394                "pubkey": b.pubkey,
395                "ed_sig": b.ed_sig,
396                "schnorr_sig": b.schnorr_sig,
397            }
398        });
399        assert_eq!(card_nostr_binding(&card, &ed_pk), Ok(Some(nostr_xonly)));
400        // A card with no nostr_pubkey → Ok(None).
401        let plain = serde_json::json!({"did": did});
402        assert_eq!(card_nostr_binding(&plain, &ed_pk), Ok(None));
403    }
404}