slancha-wire 0.5.13

Magic-wormhole for AI agents — bilateral signed-message bus over a mailbox relay
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! SPAKE2 PAKE + Short Authentication String (SAS).
//!
//! Pairing flow (the magic-wormhole pattern, applied to agent identity):
//!
//!   1. Operator A runs `wire init paul`. We generate a *low-entropy* code
//!      phrase like `73-2QXC4P` (~36 bits) and print it.
//!   2. Operator A says the code aloud to Operator B.
//!   3. Operator B runs `wire join 73-2QXC4P`.
//!   4. Both sides run SPAKE2 with the code phrase as the shared password.
//!      SPAKE2 elevates the low-entropy code into a *high-entropy* shared key
//!      without leaking anything brute-force-able to a passive eavesdropper
//!      OR to the relay we route messages through.
//!   5. Both sides derive a 6-digit SAS from the SPAKE2 transcript. Each
//!      operator's terminal shows the same digits ("384-217") iff they
//!      truly negotiated with each other. They read the digits aloud and
//!      both type `y` to confirm.
//!   6. After confirm: bootstrap payload (signed agent-card + relay slot
//!      coords) is exchanged authenticated-encrypted via ChaCha20-Poly1305
//!      under a key HKDF-derived from the SPAKE2 secret.
//!
//! SAS confirmation is the trust-establishment moment. An MITM that sat
//! between A and B during SPAKE2 would derive a *different* shared key
//! from each side, so the SAS digits would not match. That's why this is
//! safe even though the code phrase has only ~36 bits — brute-forcing
//! requires *interactive* presence in the handshake, which the SAS catches.
//!
//! v0.1 ships the offline crypto in this module + a self-test suite.
//! Wiring it through the relay (`wire init` opens a pair-slot, `wire join`
//! talks SPAKE2 across it) lands in iter 9.

use anyhow::{Result, anyhow, bail};
use chacha20poly1305::{
    ChaCha20Poly1305, Key, Nonce,
    aead::{Aead, KeyInit},
};
use hkdf::Hkdf;
use rand::{Rng, RngCore, rngs::OsRng};
use sha2::{Digest, Sha256};
use spake2::{Ed25519Group, Identity, Password, Spake2};
use std::sync::Mutex;

/// Number of digits in a code phrase (e.g. `73-`).
const CODE_DIGIT_LEN: usize = 2;
/// Length of the base32 token after the digits (e.g. `-2QXC4P`).
const CODE_TOKEN_LEN: usize = 6;
/// RFC 4648 base32 alphabet — 32 chars, no lowercase, no 0/1 ambiguity.
const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

/// Generate a fresh code phrase like `73-2QXC4P` (~36 bits entropy).
///
/// Format: `NN-XXXXXX` where NN is two random decimal digits and XXXXXX is
/// six random RFC-4648 base32 characters. ~6.6 + 30 = ~36.6 bits entropy.
/// Easy to read aloud; trivially copied by ear.
pub fn generate_code_phrase() -> String {
    let mut rng = OsRng;
    let digits = rng.gen_range(0..100);
    let mut token = String::with_capacity(CODE_TOKEN_LEN);
    for _ in 0..CODE_TOKEN_LEN {
        let idx = rng.gen_range(0..BASE32_ALPHABET.len());
        token.push(BASE32_ALPHABET[idx] as char);
    }
    format!("{:02}-{}", digits, token)
}

/// Validate a code phrase has the expected shape.
pub fn parse_code_phrase(s: &str) -> Result<&str> {
    let s = s.trim();
    let (digits, rest) = s
        .split_once('-')
        .ok_or_else(|| anyhow!("code phrase missing '-' separator: {s:?}"))?;
    if digits.len() != CODE_DIGIT_LEN || !digits.chars().all(|c| c.is_ascii_digit()) {
        bail!("code phrase digits must be {CODE_DIGIT_LEN} ASCII digits, got {digits:?}");
    }
    if rest.len() != CODE_TOKEN_LEN {
        bail!(
            "code phrase token must be {CODE_TOKEN_LEN} chars, got {} ({rest:?})",
            rest.len()
        );
    }
    if !rest.bytes().all(|b| BASE32_ALPHABET.contains(&b)) {
        bail!("code phrase token has non-base32 char: {rest:?}");
    }
    Ok(s)
}

/// One side of a SPAKE2 handshake. Created with the shared code phrase + a
/// pairing identity (e.g. relay pair-slot id) so distinct pairings can't be
/// confused.
pub struct PakeSide {
    /// `Spake2::start_symmetric` returns `(state, msg)`. `state` is consumed
    /// by `finish`, so we hold it under a Mutex for ergonomic .take().
    state: Mutex<Option<Spake2<Ed25519Group>>>,
    pub msg_out: Vec<u8>,
}

impl PakeSide {
    /// Create our side. `code_phrase` is the human-typed string; `pair_id`
    /// is a per-pairing identity (e.g. relay pair-slot id) to prevent
    /// crosstalk between concurrent pairings.
    pub fn new(code_phrase: &str, pair_id: &[u8]) -> Self {
        let parsed = parse_code_phrase(code_phrase).expect("invalid code phrase");
        let (state, msg_out) = Spake2::<Ed25519Group>::start_symmetric(
            &Password::new(parsed.as_bytes()),
            &Identity::new(pair_id),
        );
        Self {
            state: Mutex::new(Some(state)),
            msg_out,
        }
    }

    /// Create with a deterministic seeded RNG. Same `(code_phrase, pair_id,
    /// seed)` triple produces the same `state` + `msg_out`, so a process can
    /// persist `seed` to disk and reconstruct an equivalent PakeSide after
    /// restart. SECURITY: the seed combined with the code phrase reconstructs
    /// the SPAKE2 secret scalar; treat the seed as sensitive (caller is
    /// responsible for storing it in a directory with user-only file
    /// permissions, like `$WIRE_HOME/state/wire/pending-pair/`).
    pub fn from_seed(code_phrase: &str, pair_id: &[u8], seed: [u8; 32]) -> Self {
        use rand_chacha::ChaCha20Rng;
        use rand_chacha::rand_core::SeedableRng;
        let parsed = parse_code_phrase(code_phrase).expect("invalid code phrase");
        let rng = ChaCha20Rng::from_seed(seed);
        let (state, msg_out) = Spake2::<Ed25519Group>::start_symmetric_with_rng(
            &Password::new(parsed.as_bytes()),
            &Identity::new(pair_id),
            rng,
        );
        Self {
            state: Mutex::new(Some(state)),
            msg_out,
        }
    }

    /// Combine our state with the peer's `msg` to derive the shared SPAKE2 key.
    /// Returns 32 bytes of high-entropy shared secret.
    pub fn finish(&self, peer_msg: &[u8]) -> Result<[u8; 32]> {
        let state = self
            .state
            .lock()
            .expect("PakeSide mutex poisoned")
            .take()
            .ok_or_else(|| anyhow!("PakeSide.finish called twice"))?;
        let key = state
            .finish(peer_msg)
            .map_err(|e| anyhow!("SPAKE2 finish failed: {e:?}"))?;
        let mut out = [0u8; 32];
        let n = key.len().min(32);
        out[..n].copy_from_slice(&key[..n]);
        Ok(out)
    }
}

/// 6-digit SAS over the SPAKE2 shared key + the canonical (sorted) pair of
/// public keys. Symmetric: either side computes the same digits.
///
/// Why include the public keys: we want the SAS to also commit to the actual
/// agent identities being paired, not just the SPAKE2 result. An MITM who
/// somehow guessed the code phrase (~1 in 2^36 per attempt) would still fail
/// the SAS because they couldn't make us see the right Ed25519 public keys.
pub fn compute_sas_pake(spake_key: &[u8], pub_a: &[u8], pub_b: &[u8]) -> String {
    let (lo, hi) = if pub_a <= pub_b {
        (pub_a, pub_b)
    } else {
        (pub_b, pub_a)
    };
    let mut h = Sha256::new();
    h.update(b"wire/v1 sas");
    h.update(spake_key);
    h.update(lo);
    h.update(hi);
    let digest = h.finalize();
    let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
    format!("{:06}", n % 1_000_000)
}

/// HKDF-SHA256 derive a 32-byte ChaCha20-Poly1305 key from the SPAKE2 secret.
pub fn derive_aead_key(spake_key: &[u8], pair_id: &[u8]) -> [u8; 32] {
    let hk = Hkdf::<Sha256>::new(Some(pair_id), spake_key);
    let mut out = [0u8; 32];
    hk.expand(b"wire/v1 bootstrap-aead", &mut out)
        .expect("HKDF expand 32 bytes is infallible");
    out
}

/// Encrypt the bootstrap payload (signed agent-card + slot coords) under the
/// AEAD key. Returns `nonce || ciphertext` — caller transmits the whole blob
/// and recipient splits at byte 12.
pub fn seal_bootstrap(aead_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
    let cipher = ChaCha20Poly1305::new(Key::from_slice(aead_key));
    let mut nonce_bytes = [0u8; 12];
    OsRng.fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::from_slice(&nonce_bytes);
    let ct = cipher
        .encrypt(nonce, plaintext)
        .map_err(|e| anyhow!("seal failed: {e:?}"))?;
    let mut out = Vec::with_capacity(12 + ct.len());
    out.extend_from_slice(&nonce_bytes);
    out.extend_from_slice(&ct);
    Ok(out)
}

/// Decrypt a bootstrap payload produced by `seal_bootstrap`.
pub fn open_bootstrap(aead_key: &[u8; 32], blob: &[u8]) -> Result<Vec<u8>> {
    if blob.len() < 12 + 16 {
        bail!("bootstrap blob too short: {} bytes", blob.len());
    }
    let cipher = ChaCha20Poly1305::new(Key::from_slice(aead_key));
    let nonce = Nonce::from_slice(&blob[..12]);
    cipher
        .decrypt(nonce, &blob[12..])
        .map_err(|e| anyhow!("open failed (auth tag mismatch?): {e:?}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn seeded_pake_side_is_deterministic() {
        // Two PakeSide instances built from the same seed must produce
        // identical msg_out and (when paired with the other side) the same
        // shared SPAKE2 key. Underpins daemon-restart recovery: we persist
        // seed + reconstruct.
        let seed = [42u8; 32];
        let a = PakeSide::from_seed("12-ABCDEF", b"pair-id-x", seed);
        let b = PakeSide::from_seed("12-ABCDEF", b"pair-id-x", seed);
        assert_eq!(a.msg_out, b.msg_out, "msg_out diverges across same seed");

        // Bob's side, different (fresh) randomness — needed to drive the
        // SPAKE2 finish path so we can check key equivalence.
        let bob = PakeSide::new("12-ABCDEF", b"pair-id-x");
        let key_a = a.finish(&bob.msg_out).expect("a.finish");
        let key_b = b.finish(&bob.msg_out).expect("b.finish");
        assert_eq!(key_a, key_b, "shared key diverges across same seed");
    }

    #[test]
    fn seeded_pake_side_changes_with_seed() {
        let a = PakeSide::from_seed("12-ABCDEF", b"pair-id-x", [1u8; 32]);
        let b = PakeSide::from_seed("12-ABCDEF", b"pair-id-x", [2u8; 32]);
        assert_ne!(
            a.msg_out, b.msg_out,
            "msg_out collides across distinct seeds"
        );
    }

    #[test]
    fn code_phrase_has_expected_shape() {
        let code = generate_code_phrase();
        let parsed = parse_code_phrase(&code).unwrap();
        assert_eq!(parsed, code);
        assert_eq!(code.len(), CODE_DIGIT_LEN + 1 + CODE_TOKEN_LEN);
        assert!(code.chars().nth(CODE_DIGIT_LEN) == Some('-'));
    }

    #[test]
    fn many_code_phrases_are_distinct() {
        // 36 bits of entropy — collisions in 1000 samples should be near zero.
        let mut seen = std::collections::HashSet::new();
        for _ in 0..1000 {
            let c = generate_code_phrase();
            assert!(seen.insert(c));
        }
    }

    #[test]
    fn parse_rejects_malformed_codes() {
        assert!(parse_code_phrase("foo").is_err());
        assert!(parse_code_phrase("12345-ABCDEF").is_err()); // too many digits
        assert!(parse_code_phrase("12-ABC").is_err()); // token too short
        assert!(parse_code_phrase("12-ABCDEF1").is_err()); // 1 not in base32 alphabet
        assert!(parse_code_phrase("12-abcdef").is_err()); // lowercase rejected
    }

    #[test]
    fn pake_two_sides_derive_same_secret() {
        let code = generate_code_phrase();
        let pair_id = b"pair-id-shared";
        let alice = PakeSide::new(&code, pair_id);
        let bob = PakeSide::new(&code, pair_id);
        let alice_secret = alice.finish(&bob.msg_out).unwrap();
        let bob_secret = bob.finish(&alice.msg_out).unwrap();
        assert_eq!(alice_secret, bob_secret, "SPAKE2 secrets diverged");
    }

    #[test]
    fn pake_wrong_code_diverges() {
        // Two parties with DIFFERENT code phrases — finish() either errors
        // (Ed25519Group rejects) or returns a different secret. Either way,
        // the test passes if the two derived secrets disagree.
        let pair_id = b"pair-id-same";
        let alice = PakeSide::new("11-ABCDEF", pair_id);
        let bob = PakeSide::new("99-ZZZZZZ", pair_id);
        let alice_result = alice.finish(&bob.msg_out);
        let bob_result = bob.finish(&alice.msg_out);
        let mismatch = match (alice_result, bob_result) {
            (Ok(a), Ok(b)) => a != b,
            _ => true, // either side erroring is also a mismatch
        };
        assert!(
            mismatch,
            "wrong code phrase should not produce matching secrets"
        );
    }

    #[test]
    fn pake_different_pair_id_diverges() {
        // Same code phrase but different pair_id — should NOT collide. This
        // protects against cross-talk between concurrent pairings on the
        // same relay.
        let code = "42-WIRE45"; // base32 alphabet: A-Z2-7, no 0/1
        let alice = PakeSide::new(code, b"pair-A");
        let bob = PakeSide::new(code, b"pair-B");
        let a = alice.finish(&bob.msg_out);
        let b = bob.finish(&alice.msg_out);
        let mismatch = match (a, b) {
            (Ok(x), Ok(y)) => x != y,
            _ => true,
        };
        assert!(mismatch, "different pair_id must NOT yield same secret");
    }

    #[test]
    fn pake_finish_called_twice_errors() {
        let code = generate_code_phrase();
        let alice = PakeSide::new(&code, b"x");
        let bob = PakeSide::new(&code, b"x");
        alice.finish(&bob.msg_out).unwrap();
        let err = alice.finish(&bob.msg_out).unwrap_err();
        assert!(err.to_string().contains("twice"), "got: {err}");
    }

    #[test]
    fn sas_is_6_digits_and_symmetric() {
        let key = [42u8; 32];
        let pub_a = [1u8; 32];
        let pub_b = [2u8; 32];
        let sas_ab = compute_sas_pake(&key, &pub_a, &pub_b);
        let sas_ba = compute_sas_pake(&key, &pub_b, &pub_a);
        assert_eq!(sas_ab.len(), 6);
        assert!(sas_ab.chars().all(|c| c.is_ascii_digit()));
        assert_eq!(sas_ab, sas_ba, "SAS must be symmetric in (pub_a, pub_b)");
    }

    #[test]
    fn sas_changes_with_spake_key() {
        let pub_a = [1u8; 32];
        let pub_b = [2u8; 32];
        let sas1 = compute_sas_pake(&[1u8; 32], &pub_a, &pub_b);
        let sas2 = compute_sas_pake(&[2u8; 32], &pub_a, &pub_b);
        assert_ne!(sas1, sas2);
    }

    #[test]
    fn sas_changes_with_pubkeys() {
        let key = [42u8; 32];
        let pub_a = [1u8; 32];
        let pub_b = [2u8; 32];
        let pub_c = [3u8; 32];
        assert_ne!(
            compute_sas_pake(&key, &pub_a, &pub_b),
            compute_sas_pake(&key, &pub_a, &pub_c)
        );
    }

    #[test]
    fn aead_seal_open_round_trip() {
        let key = derive_aead_key(&[42u8; 32], b"pair-id");
        let plaintext = b"some bootstrap payload bytes";
        let sealed = seal_bootstrap(&key, plaintext).unwrap();
        let opened = open_bootstrap(&key, &sealed).unwrap();
        assert_eq!(opened, plaintext);
    }

    #[test]
    fn aead_open_with_wrong_key_fails() {
        let key1 = derive_aead_key(&[1u8; 32], b"x");
        let key2 = derive_aead_key(&[2u8; 32], b"x");
        let sealed = seal_bootstrap(&key1, b"secret").unwrap();
        let result = open_bootstrap(&key2, &sealed);
        assert!(result.is_err(), "wrong key must fail AEAD auth");
    }

    #[test]
    fn aead_open_with_truncated_blob_fails() {
        let key = derive_aead_key(&[42u8; 32], b"x");
        let result = open_bootstrap(&key, b"too short");
        assert!(result.is_err());
    }

    #[test]
    fn full_pake_to_sealed_payload_round_trip() {
        // Simulate the full handshake: paul + willard derive the same
        // SPAKE2 key, derive the same AEAD key, and successfully exchange
        // an encrypted bootstrap payload.
        let code = generate_code_phrase();
        let pair_id = b"e2e-pair";
        let paul = PakeSide::new(&code, pair_id);
        let willard = PakeSide::new(&code, pair_id);

        let paul_msg = paul.msg_out.clone();
        let willard_msg = willard.msg_out.clone();
        let paul_secret = paul.finish(&willard_msg).unwrap();
        let willard_secret = willard.finish(&paul_msg).unwrap();
        assert_eq!(paul_secret, willard_secret);

        let paul_aead_key = derive_aead_key(&paul_secret, pair_id);
        let willard_aead_key = derive_aead_key(&willard_secret, pair_id);
        assert_eq!(paul_aead_key, willard_aead_key);

        // Paul sends his signed agent-card to willard via AEAD.
        let paul_card_bytes = b"{\"did\":\"did:wire:paul\", ...}";
        let sealed = seal_bootstrap(&paul_aead_key, paul_card_bytes).unwrap();
        let opened = open_bootstrap(&willard_aead_key, &sealed).unwrap();
        assert_eq!(opened, paul_card_bytes);

        // Both compute the same 6-digit SAS over the SPAKE key + pubkeys.
        let pub_a = [9u8; 32];
        let pub_b = [10u8; 32];
        let sas_paul = compute_sas_pake(&paul_secret, &pub_a, &pub_b);
        let sas_willard = compute_sas_pake(&willard_secret, &pub_b, &pub_a);
        assert_eq!(sas_paul, sas_willard);
    }
}