Skip to main content

wire/
signing.rs

1//! Ed25519 sign-over-event_id (Nostr NIP-01 style).
2//!
3//! Sign flow:
4//!   1. Compute SHA-256 over canonical bytes of `msg` (strict: drops event_id).
5//!   2. That 32-byte digest IS the `event_id` (hex-encoded for transport).
6//!   3. Sign the raw 32-byte digest. The signature commits to event_id, which
7//!      transitively commits to the canonical body — tamper anything, the
8//!      digest changes, the signature fails.
9//!
10//! Why sign the id and not the body: lets relays/index layers cite events by
11//! id without re-canonicalizing every body. Same property Nostr exploits.
12
13use base64::Engine as _;
14use base64::engine::general_purpose::STANDARD as B64;
15use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
16use rand::rngs::OsRng;
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use std::collections::BTreeMap;
20use std::ops::Range;
21use thiserror::Error;
22
23use crate::canonical::canonical;
24
25// ---------- event schema version ----------
26
27/// Schema version tag stamped on every signed event by 0.5.11+. Pull-side
28/// verification rejects events whose schema_version's *major* component
29/// disagrees with this — see `pull::process_events`.
30///
31/// Legacy events without this field are accepted (we can't retroactively
32/// stamp 0.5.10 traffic), so the field's *absence* is fine; only its
33/// *presence with a wrong major* is a hard reject.
34///
35/// RFC-006 (v0.15): a future `enc`-bearing (encrypted-body) event stays
36/// major `v3` — encryption rides inside `body` additively and does NOT bump
37/// the major (see `docs/PROTOCOL.md` §2.4). Only a body-shape change a v3
38/// reader would mis-decode warrants `v4`.
39pub const EVENT_SCHEMA_VERSION: &str = "v3.1";
40
41/// Major component of a `v<major>.<minor>` schema_version. Used to decide
42/// whether a received event is wire-compatible.
43///
44/// Today: every Wire schema is v3.x; major == "v3". A 0.5.12 binary might
45/// start emitting v4.0 events; older 0.5.x binaries see major=v4 and bail
46/// instead of attempting to decode an incompatible shape.
47pub fn schema_major(schema_version: &str) -> &str {
48    schema_version.split('.').next().unwrap_or(schema_version)
49}
50
51// ---------- kind ranges ----------
52
53/// Disjoint kind-id ranges. Mirrors v3 protocol; v0.1 ships a strict subset.
54///
55/// v0.2+ kinds (file_share=1900, file_revoke=1901, registry_revocation=10500)
56/// are deliberately ABSENT — see ANTI_FEATURES.md.
57pub static KIND_RANGES: &[(KindClass, Range<u32>)] = &[
58    (KindClass::Regular, 1000..10000),
59    (KindClass::Replaceable, 10000..20000),
60    (KindClass::Ephemeral, 20000..30000),
61    (KindClass::Addressable, 30000..40000),
62];
63
64/// v0.1 named kinds. Anything not here is unknown to this version.
65pub fn kinds() -> &'static [(u32, &'static str)] {
66    &[
67        (1, "decision"),    // Nostr-compat short text — special-cased to Regular
68        (100, "heartbeat"), // ephemeral liveness ping — special-cased to Ephemeral
69        (1000, "decision"),
70        (1001, "claim"),
71        (1002, "ack"),
72        (1100, "agent_card"),
73        (1101, "trust_add_key"),
74        (1102, "trust_revoke_key"),
75        (1200, "wire_open"),
76        (1201, "wire_close"),
77    ]
78}
79
80/// `kinds()` as a `BTreeMap` for membership tests. Allocated per call —
81/// callers that need it hot should cache.
82pub fn kinds_map() -> BTreeMap<u32, &'static str> {
83    kinds().iter().copied().collect()
84}
85
86#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
87pub enum KindClass {
88    Regular,
89    Replaceable,
90    Ephemeral,
91    Addressable,
92}
93
94impl KindClass {
95    pub fn as_str(self) -> &'static str {
96        match self {
97            KindClass::Regular => "regular",
98            KindClass::Replaceable => "replaceable",
99            KindClass::Ephemeral => "ephemeral",
100            KindClass::Addressable => "addressable",
101        }
102    }
103}
104
105/// Classify a kind id. `None` means unknown — caller decides how to handle.
106pub fn kind_class(kind: u32) -> Option<KindClass> {
107    // Documented out-of-range special cases (Nostr NIP-01 compatibility +
108    // v3 heartbeat carve-out). Keep these explicit, not a hidden lookup.
109    match kind {
110        1 => return Some(KindClass::Regular),
111        100 => return Some(KindClass::Ephemeral),
112        _ => {}
113    }
114    for (cls, range) in KIND_RANGES {
115        if range.contains(&kind) {
116            return Some(*cls);
117        }
118    }
119    None
120}
121
122// ---------- canonical re-export (keeps call sites symmetric with Python) ----------
123
124pub fn canonical_event(value: &Value, strict: bool) -> Vec<u8> {
125    canonical(value, strict)
126}
127
128// Public alias matching Python `signing.canonical(...)` import path.
129pub use crate::canonical::canonical as canonical_value;
130
131// ---------- event_id ----------
132
133pub fn compute_event_id(msg: &Value) -> String {
134    let bytes = canonical(msg, true);
135    let digest = Sha256::digest(&bytes);
136    hex::encode(digest)
137}
138
139// ---------- key id + fingerprint ----------
140
141pub fn fingerprint(public_key: &[u8]) -> String {
142    let digest = Sha256::digest(public_key);
143    hex::encode(&digest[..4])
144}
145
146pub fn make_key_id(handle: &str, public_key: &[u8]) -> String {
147    format!("{handle}:{}", fingerprint(public_key))
148}
149
150// ---------- base64 helpers ----------
151
152pub fn b64encode(bytes: &[u8]) -> String {
153    B64.encode(bytes)
154}
155
156pub fn b64decode(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
157    B64.decode(s)
158}
159
160// ---------- key generation ----------
161
162/// Returns `(private_key_bytes, public_key_bytes)` — both 32 bytes, raw.
163pub fn generate_keypair() -> ([u8; 32], [u8; 32]) {
164    let sk = SigningKey::generate(&mut OsRng);
165    let pk = sk.verifying_key();
166    (sk.to_bytes(), pk.to_bytes())
167}
168
169// ---------- sign / verify ----------
170
171#[derive(Debug, Error)]
172pub enum SignError {
173    #[error("private key must be 32 bytes, got {0}")]
174    BadPrivateLen(usize),
175    #[error("public key must be 32 bytes, got {0}")]
176    BadPublicLen(usize),
177}
178
179#[derive(Debug, Error)]
180pub enum VerifyError {
181    #[error("missing field: {0}")]
182    MissingField(&'static str),
183    #[error("event_id mismatch — body was tampered after signing")]
184    EventIdMismatch,
185    #[error("signer {0:?} not in trust")]
186    UnknownAgent(String),
187    #[error("key {0:?} not found for agent {1:?}")]
188    UnknownKey(String, String),
189    #[error("key {0:?} for agent {1:?} is deactivated")]
190    DeactivatedKey(String, String),
191    #[error("signature decode failed")]
192    BadSignature,
193    #[error("signature did not verify")]
194    SignatureRejected,
195}
196
197/// Sign a message. Returns the canonical wire form: original fields + the
198/// computed `event_id`, `public_key_id`, `signature`.
199pub fn sign_message_v31(
200    msg: &Value,
201    private_key: &[u8],
202    public_key: &[u8],
203    agent: &str,
204) -> Result<Value, SignError> {
205    if private_key.len() != 32 {
206        return Err(SignError::BadPrivateLen(private_key.len()));
207    }
208    if public_key.len() != 32 {
209        return Err(SignError::BadPublicLen(public_key.len()));
210    }
211    let mut sk_bytes = [0u8; 32];
212    sk_bytes.copy_from_slice(private_key);
213    let sk = SigningKey::from_bytes(&sk_bytes);
214
215    let event_id = compute_event_id(msg);
216    let raw = hex::decode(&event_id).expect("compute_event_id always returns valid hex");
217    let sig = sk.sign(&raw);
218
219    let mut out = msg.as_object().cloned().unwrap_or_default();
220    out.insert("event_id".into(), Value::String(event_id));
221    out.insert(
222        "public_key_id".into(),
223        Value::String(make_key_id(agent, public_key)),
224    );
225    out.insert(
226        "signature".into(),
227        Value::String(b64encode(&sig.to_bytes())),
228    );
229    Ok(Value::Object(out))
230}
231
232/// Verify a signed message against a trust dict (see `trust` module).
233///
234/// Returns `Ok(())` iff: event_id matches recomputed, signer's key is in
235/// trust + active, and the Ed25519 signature validates over the event_id.
236pub fn verify_message_v31(msg: &Value, trust: &Value) -> Result<(), VerifyError> {
237    let from = msg
238        .get("from")
239        .and_then(Value::as_str)
240        .ok_or(VerifyError::MissingField("from"))?;
241    // v0.5.7+: DID may include a `-<8-hex>` pubkey suffix
242    // (`did:wire:paul-abc12345`). Trust map is keyed by the bare handle,
243    // so strip both the `did:wire:` prefix AND the optional pubkey suffix.
244    let handle = crate::agent_card::display_handle_from_did(from);
245
246    let public_key_id = msg
247        .get("public_key_id")
248        .and_then(Value::as_str)
249        .ok_or(VerifyError::MissingField("public_key_id"))?;
250
251    let signature_b64 = msg
252        .get("signature")
253        .and_then(Value::as_str)
254        .ok_or(VerifyError::MissingField("signature"))?;
255
256    let event_id = msg
257        .get("event_id")
258        .and_then(Value::as_str)
259        .ok_or(VerifyError::MissingField("event_id"))?;
260
261    let recomputed = compute_event_id(msg);
262    if recomputed != event_id {
263        return Err(VerifyError::EventIdMismatch);
264    }
265
266    let agent = trust
267        .get("agents")
268        .and_then(|a| a.get(handle))
269        .ok_or_else(|| VerifyError::UnknownAgent(handle.to_string()))?;
270
271    let public_keys = agent
272        .get("public_keys")
273        .and_then(Value::as_array)
274        .ok_or_else(|| VerifyError::UnknownKey(public_key_id.to_string(), handle.to_string()))?;
275
276    let key_record = public_keys
277        .iter()
278        .find(|k| k.get("key_id").and_then(Value::as_str) == Some(public_key_id))
279        .ok_or_else(|| VerifyError::UnknownKey(public_key_id.to_string(), handle.to_string()))?;
280
281    let active = key_record
282        .get("active")
283        .and_then(Value::as_bool)
284        .unwrap_or(true);
285    if !active {
286        return Err(VerifyError::DeactivatedKey(
287            public_key_id.to_string(),
288            handle.to_string(),
289        ));
290    }
291
292    let pk_b64 = key_record
293        .get("key")
294        .and_then(Value::as_str)
295        .ok_or(VerifyError::MissingField("key"))?;
296    let pk_bytes = b64decode(pk_b64).map_err(|_| VerifyError::BadSignature)?;
297    if pk_bytes.len() != 32 {
298        return Err(VerifyError::BadSignature);
299    }
300    let mut pk_arr = [0u8; 32];
301    pk_arr.copy_from_slice(&pk_bytes);
302    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| VerifyError::BadSignature)?;
303
304    let sig_bytes = b64decode(signature_b64).map_err(|_| VerifyError::BadSignature)?;
305    if sig_bytes.len() != 64 {
306        return Err(VerifyError::BadSignature);
307    }
308    let mut sig_arr = [0u8; 64];
309    sig_arr.copy_from_slice(&sig_bytes);
310    let sig = Signature::from_bytes(&sig_arr);
311
312    let raw = hex::decode(event_id).map_err(|_| VerifyError::BadSignature)?;
313    vk.verify(&raw, &sig)
314        .map_err(|_| VerifyError::SignatureRejected)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use serde_json::json;
321
322    fn trust_for(handle: &str, pub_key: &[u8]) -> Value {
323        let kid = make_key_id(handle, pub_key);
324        json!({
325            "agents": {
326                handle: {
327                    "public_keys": [
328                        {"key_id": kid, "key": b64encode(pub_key), "active": true}
329                    ]
330                }
331            }
332        })
333    }
334
335    #[test]
336    fn kind_ranges_disjoint() {
337        let mut seen = std::collections::HashSet::new();
338        for (_, rng) in KIND_RANGES {
339            for k in rng.clone() {
340                assert!(seen.insert(k), "kind {k} in multiple ranges");
341            }
342        }
343    }
344
345    #[test]
346    fn kind_class_known_ranges() {
347        assert_eq!(kind_class(20000), Some(KindClass::Ephemeral));
348        assert_eq!(kind_class(29999), Some(KindClass::Ephemeral));
349        assert_eq!(kind_class(1000), Some(KindClass::Regular));
350        assert_eq!(kind_class(9999), Some(KindClass::Regular));
351        assert_eq!(kind_class(10000), Some(KindClass::Replaceable));
352        assert_eq!(kind_class(19999), Some(KindClass::Replaceable));
353        assert_eq!(kind_class(30000), Some(KindClass::Addressable));
354    }
355
356    #[test]
357    fn kind_class_special_cases() {
358        assert_eq!(kind_class(1), Some(KindClass::Regular));
359        assert_eq!(kind_class(100), Some(KindClass::Ephemeral));
360    }
361
362    #[test]
363    fn kind_class_unknown_returns_none() {
364        assert_eq!(kind_class(99999), None);
365        assert_eq!(kind_class(7), None);
366    }
367
368    #[test]
369    fn v01_does_not_ship_v02_kinds() {
370        let names = kinds_map();
371        for deferred in [1900, 1901, 10500] {
372            assert!(
373                !names.contains_key(&deferred),
374                "v0.2+ kind {deferred} leaked into v0.1"
375            );
376        }
377    }
378
379    #[test]
380    fn fingerprint_is_8_hex() {
381        let fp = fingerprint(&[0u8; 32]);
382        assert_eq!(fp.len(), 8);
383        u32::from_str_radix(&fp, 16).expect("hex");
384    }
385
386    #[test]
387    fn make_key_id_format() {
388        let (_, pk) = generate_keypair();
389        let kid = make_key_id("paul", &pk);
390        assert!(kid.starts_with("paul:"));
391        assert_eq!(kid.split(':').nth(1).unwrap().len(), 8);
392    }
393
394    #[test]
395    fn generate_keypair_returns_32_byte_pair() {
396        let (sk, pk) = generate_keypair();
397        assert_eq!(sk.len(), 32);
398        assert_eq!(pk.len(), 32);
399    }
400
401    #[test]
402    fn sign_verify_roundtrip() {
403        let (sk, pk) = generate_keypair();
404        let msg = json!({
405            "timestamp": "2026-05-09T00:00:00Z",
406            "from": "paul",
407            "type": "decision",
408            "kind": 1,
409            "subject": "test",
410            "body": {"content": "hello"},
411        });
412        let signed = sign_message_v31(&msg, &sk, &pk, "paul").unwrap();
413        assert!(signed.get("event_id").is_some());
414        assert!(signed.get("public_key_id").is_some());
415        assert!(signed.get("signature").is_some());
416        verify_message_v31(&signed, &trust_for("paul", &pk)).unwrap();
417    }
418
419    #[test]
420    fn verify_rejects_tampered_body() {
421        let (sk, pk) = generate_keypair();
422        let msg = json!({"from": "paul", "type": "decision", "body": {"content": "original"}});
423        let mut signed = sign_message_v31(&msg, &sk, &pk, "paul").unwrap();
424        signed["body"]["content"] = json!("tampered");
425        let err = verify_message_v31(&signed, &trust_for("paul", &pk)).unwrap_err();
426        assert!(matches!(err, VerifyError::EventIdMismatch));
427    }
428
429    #[test]
430    fn verify_accepts_did_wire_prefix_in_from() {
431        let (sk, pk) = generate_keypair();
432        let msg = json!({"from": "did:wire:paul", "type": "decision", "body": {}});
433        let signed = sign_message_v31(&msg, &sk, &pk, "paul").unwrap();
434        verify_message_v31(&signed, &trust_for("paul", &pk)).unwrap();
435    }
436
437    #[test]
438    fn verify_rejects_unknown_agent() {
439        let (sk, pk) = generate_keypair();
440        let msg = json!({"from": "paul", "type": "decision", "body": {}});
441        let signed = sign_message_v31(&msg, &sk, &pk, "paul").unwrap();
442        let trust = json!({"agents": {"willard": {"public_keys": []}}});
443        let err = verify_message_v31(&signed, &trust).unwrap_err();
444        assert!(matches!(err, VerifyError::UnknownAgent(h) if h == "paul"));
445    }
446
447    #[test]
448    fn verify_rejects_inactive_key() {
449        let (sk, pk) = generate_keypair();
450        let msg = json!({"from": "paul", "type": "decision", "body": {}});
451        let signed = sign_message_v31(&msg, &sk, &pk, "paul").unwrap();
452        let mut trust = trust_for("paul", &pk);
453        trust["agents"]["paul"]["public_keys"][0]["active"] = json!(false);
454        let err = verify_message_v31(&signed, &trust).unwrap_err();
455        assert!(matches!(err, VerifyError::DeactivatedKey(_, _)));
456    }
457
458    #[test]
459    fn compute_event_id_is_64_hex() {
460        let v = json!({"from": "paul", "type": "test"});
461        let eid = compute_event_id(&v);
462        assert_eq!(eid.len(), 64);
463        for c in eid.chars() {
464            assert!(c.is_ascii_hexdigit());
465        }
466    }
467
468    #[test]
469    fn enc_bearing_event_verifies_additively_path_a() {
470        // RFC-006 path-A proof (PROTOCOL.md §2.4): an event whose body is an
471        // opaque ciphertext container plus an `enc` discriminator signs and
472        // verifies with ZERO encryption-aware code. A current (encryption-
473        // unaware) reader still verifies the signature + event_id — it just
474        // can't read the body. This is the evidence that NIP-44 can land
475        // additively with no schema-major bump, instead of a second wire-
476        // format break. (Test proposed by slate-lotus on PR #221.)
477        let (sk, pk) = generate_keypair();
478        let msg = json!({
479            "timestamp": "2026-06-05T00:00:00Z",
480            "from": "paul",
481            "type": "decision",
482            "kind": 1000,
483            "enc": "nip44.v2",
484            "body": { "ct": "c29tZS1vcGFxdWUtY2lwaGVydGV4dA==" },
485        });
486        let signed = sign_message_v31(&msg, &sk, &pk, "paul").unwrap();
487
488        // Encryption-unaware verifier accepts: event_id + signature are
489        // body-agnostic (verify recomputes event_id internally and checks it).
490        verify_message_v31(&signed, &trust_for("paul", &pk)).unwrap();
491
492        // Integrity holds over the ciphertext: tampering `ct` breaks the id.
493        let mut tampered = signed.clone();
494        tampered["body"]["ct"] = json!("dGFtcGVyZWQ=");
495        assert!(matches!(
496            verify_message_v31(&tampered, &trust_for("paul", &pk)).unwrap_err(),
497            VerifyError::EventIdMismatch
498        ));
499
500        // The `enc` discriminator + opaque body are preserved untouched — the
501        // signing path treats the body as opaque, never inspecting `ct`.
502        assert_eq!(signed.get("enc").and_then(Value::as_str), Some("nip44.v2"));
503        assert_eq!(
504            signed["body"]["ct"],
505            json!("c29tZS1vcGFxdWUtY2lwaGVydGV4dA==")
506        );
507    }
508}