filament-cli 0.7.0

P2P file transfer between terminals and browsers, no upload, no account. The terminal end of filament.autumated.com.
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
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
//! Shared SPAKE2 key-agreement ceremony, used by BOTH `pair` and the transfer
//! path (`send --code` / `recv <code>`).
//!
//! The pairing command historically inlined the whole L1-a SPAKE2 ceremony in
//! `pair_cmd`. The transfer path had NO PAKE at all (it used the legacy
//! server-minted v1 code, and `send --remember` handed a plaintext secret over
//! the DataChannel). This module factors the ceremony out so the two flows run
//! the IDENTICAL handshake and differ ONLY in what they do with the result:
//!
//!   * `pair`    : run the ceremony, then PERSIST the agreed secret (a known
//!                 device) via `devices_store_v2`.
//!   * transfer  : run the ceremony, VERIFY it, then DISCARD the secret. The
//!                 ceremony authenticates the link (mutual auth, MITM-detectable
//!                 via the DTLS-fingerprint-bound confirmation MAC); the transfer
//!                 keeps no lasting trust. "Link with mutual auth, then forget."
//!
//! SECURITY INVARIANTS (shared by both callers, enforced here):
//!   - The PAKE words (password) NEVER cross the signaling server. Only the
//!     numeric nameplate is sent (by the caller, via pair-create / pair-claim).
//!     This module only ever relays the opaque 33-byte SPAKE2 element and the
//!     32-byte confirmation MAC over the `signal` channel.
//!   - The confirmation MAC binds BOTH sides' DTLS fingerprints (and the agreed
//!     caps), so a server/relay that substitutes a DTLS cert is DETECTED and the
//!     ceremony ABORTS, agreeing nothing.
//!   - The agreed secret is HKDF(K): agreed on both sides, never transmitted.
//!     The transfer caller throws it away; only `pair` (or `--remember`) stores.
//!
//! This is a pure protocol state machine: it never touches the network or the
//! `Conn`. The caller drives it from its event loop, feeding in the peer's
//! signal payloads and the link's DTLS fingerprints, and relaying the opaque
//! payloads this module produces. That keeps it unit-testable end to end (two
//! in-process ceremonies, no sockets), see the tests at the bottom.

use crate::pake::{self, PakeState};
use serde_json::{json, Value};

/// The capability set v2 first-pairing / ephemeral transfer-auth agrees on.
/// "transfer" is the L0 baseline (always allowed); deny-by-default future caps
/// are NOT granted here. BOTH sides MAC the identical canonical string or
/// confirmation fails (spec ยง8 / gate 5), so this default is fixed.
pub fn pair_v2_caps() -> Vec<String> {
    vec!["transfer".to_string()]
}

/// Base64 (no external dep). Used only for the 33-byte SPAKE2 element / 32-byte
/// MAC opaque payloads on the signal relay. Mirrors the browser's b64.
pub fn b64_encode(data: &[u8]) -> String {
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
    for chunk in data.chunks(3) {
        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
        out.push(T[((n >> 18) & 63) as usize] as char);
        out.push(T[((n >> 12) & 63) as usize] as char);
        out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
        out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
    }
    out
}

pub fn b64_decode(s: &str) -> Option<Vec<u8>> {
    fn val(c: u8) -> Option<u32> {
        match c {
            b'A'..=b'Z' => Some((c - b'A') as u32),
            b'a'..=b'z' => Some((c - b'a' + 26) as u32),
            b'0'..=b'9' => Some((c - b'0' + 52) as u32),
            b'+' => Some(62),
            b'/' => Some(63),
            _ => None,
        }
    }
    let s: Vec<u8> = s.bytes().filter(|&b| b != b'=' && !b.is_ascii_whitespace()).collect();
    let mut out = Vec::with_capacity(s.len() / 4 * 3);
    for chunk in s.chunks(4) {
        let mut n = 0u32;
        let mut bits = 0;
        for &c in chunk {
            n = (n << 6) | val(c)?;
            bits += 6;
        }
        n <<= 24 - bits;
        out.push((n >> 16) as u8);
        if chunk.len() > 2 {
            out.push((n >> 8) as u8);
        }
        if chunk.len() > 3 {
            out.push(n as u8);
        }
    }
    Some(out)
}

/// Outcome of feeding an inbound `signal` payload into the ceremony.
#[derive(Debug)]
pub enum Inbound {
    /// The payload WAS a PAKE message (consumed). The caller must NOT route it
    /// into the WebRTC signal path. Drive the ceremony again afterwards.
    Consumed,
    /// The payload was malformed key-exchange material. The caller MUST abort
    /// the ceremony loudly (agree nothing) with this message.
    Abort(String),
    /// Not a PAKE payload (it is SDP/ICE); fall through to the WebRTC path.
    Ignored,
}

/// One SPAKE2 ceremony with one peer. Holds all the protocol state the loops
/// previously inlined (the live session, our element, derived K, the sent-flags)
/// so both `pair` and the transfer path drive it identically.
pub struct Ceremony {
    /// The agreed capability set (relayed in the confirm payload) and its
    /// canonical form (fed to the MAC).
    caps: Vec<String>,
    caps_canon: String,
    /// Introduction scope byte (0x00 User, 0x01 Device), MANDATORY, bound into
    /// the confirmation transcript alongside caps + sorted DTLS fingerprints.
    /// Derived from the authenticated introduction token; no default.
    scope: u8,
    /// Live SPAKE2 session (consumed by `finish`). `None` after K is derived.
    state: Option<PakeState>,
    /// Our outbound 33-byte SPAKE2 element.
    msg: Vec<u8>,
    /// Derived shared key K (after finishing on the peer's element). Held until
    /// confirmation, then HKDF'd to the secret.
    k: Option<Vec<u8>>,
    /// The agreed pinned secret (HKDF(K)); set ONLY after confirmation passes.
    secret: Option<String>,
    /// Abort reason if the ceremony was refused (wrong code / tampering / etc.).
    aborted: Option<String>,
    sent_msg: bool,
    sent_confirm: bool,
}

impl Ceremony {
    /// Begin a symmetric SPAKE2 ceremony. `password` is the spoken words,
    /// `nameplate` the numeric routing suffix. Both sides MUST pass identical
    /// password AND nameplate (the SPAKE2 identity) or they derive different K.
    /// `scope` is the introduction scope byte (0x00 User-scoped "reach me",
    /// 0x01 Device-scoped), MANDATORY, derived from the authenticated
    /// introduction token (or the fixed convention for plain pair). Both sides
    /// must pass the SAME scope byte or confirmation fails (confused-deputy
    /// prevention). There is no default: every production call site must
    /// explicitly pass the scope it derived from the token.
    pub fn new(password: &str, nameplate: &str, caps: Vec<String>, scope: u8) -> Self {
        let caps_canon = crate::pake::canonical_caps(&caps);
        let (state, msg) = crate::pake::start(password.as_bytes(), nameplate.as_bytes());
        Ceremony {
            caps,
            caps_canon,
            scope,
            state: Some(state),
            msg,
            k: None,
            secret: None,
            aborted: None,
            sent_msg: false,
            sent_confirm: false,
        }
    }

    /// Re-mint with a FRESH nameplate (and optionally fresh words) after a
    /// server `taken` collision. Resets the session and the sent-flag so the new
    /// element goes out. The caller re-emits `pair-create {nameplate}`.
    /// Scope is preserved (same introduction).
    pub fn restart(&mut self, password: &str, nameplate: &str) {
        let (state, msg) = crate::pake::start(password.as_bytes(), nameplate.as_bytes());
        self.state = Some(state);
        self.msg = msg;
        self.k = None;
        self.secret = None;
        self.aborted = None;
        self.sent_msg = false;
        self.sent_confirm = false;
    }

    /// Explicitly re-mint with a new scope (if the introduction token's scope
    /// changes, which should be rare). Used only in tests for scope-downgrade.
    #[cfg(test)]
    pub fn restart_with_scope(&mut self, password: &str, nameplate: &str, scope: u8) {
        let (state, msg) = crate::pake::start(password.as_bytes(), nameplate.as_bytes());
        self.state = Some(state);
        self.msg = msg;
        self.scope = scope;
        self.k = None;
        self.secret = None;
        self.aborted = None;
        self.sent_msg = false;
        self.sent_confirm = false;
    }

    pub fn secret(&self) -> Option<&String> {
        self.secret.as_ref()
    }
    /// The abort reason, if the ceremony was refused. Callers normally act on the
    /// `Inbound::Abort` returned by `on_signal`; this accessor is used by the
    /// module's own tests to assert the terminal state.
    #[cfg(test)]
    pub fn aborted(&self) -> Option<&String> {
        self.aborted.as_ref()
    }

    /// The opaque `signal` payload carrying our SPAKE2 element, IF it hasn't been
    /// sent yet. `None` once sent (idempotent). Marks it sent. The caller relays
    /// the returned JSON to the peer (`{to, data: <this>}`).
    pub fn take_msg_payload(&mut self) -> Option<Value> {
        if self.sent_msg {
            return None;
        }
        self.sent_msg = true;
        Some(json!({ "type": "pake-msg", "v": 2, "msg": b64_encode(&self.msg) }))
    }

    /// The opaque `signal` payload carrying our key-confirmation MAC, IF K is
    /// derived AND it hasn't been sent yet. Needs both DTLS fingerprints (so the
    /// MAC binds them) AND the introduction scope byte (so a scope downgrade is
    /// detected). `None` until ready / once sent.
    pub fn take_confirm_payload(&mut self, my_fp: &str, their_fp: &str) -> Option<Value> {
        if self.sent_confirm {
            return None;
        }
        let k = self.k.as_ref()?;
        // Scope byte is MANDATORY and is derived from the authenticated
        // introduction token (or the fixed convention for plain pair).
        let mac = crate::pake::our_confirm(k, my_fp, their_fp, &self.caps_canon, self.scope);
        self.sent_confirm = true;
        Some(json!({
            "type": "pake-confirm", "v": 2,
            "mac": b64_encode(&mac),
            "caps": self.caps.clone(),
            "scope": self.scope,
        }))
    }

    /// Whether K has been derived (the peer's element was consumed).
    pub fn has_k(&self) -> bool {
        self.k.is_some()
    }

    /// Borrow K if derived (for sealing identity-expose).
    pub fn k(&self) -> Option<&Vec<u8>> {
        self.k.as_ref()
    }

    /// Scope byte (mandatory, no default).
    pub fn scope(&self) -> u8 {
        self.scope
    }

    /// Canonical caps string (for possession binding).
    pub fn caps_canon(&self) -> &str {
        &self.caps_canon
    }

    /// Feed an inbound `signal` payload. PAKE messages are consumed; SDP/ICE is
    /// ignored (falls through to the WebRTC path). A `pake-confirm` carries the
    /// fingerprints to verify against (the caller supplies the link's current
    /// fingerprints). On a verified confirm the secret is derived.
    pub fn on_signal(&mut self, data: &Value, fps: Option<(&str, &str)>) -> Inbound {
        match data["type"].as_str() {
            Some("pake-msg") => {
                if self.k.is_none() {
                    if let Some(state) = self.state.take() {
                        let peer_el = data["msg"].as_str().and_then(b64_decode).unwrap_or_default();
                        match crate::pake::finish(state, &peer_el) {
                            Some(k) => self.k = Some(k),
                            None => {
                                return Inbound::Abort(
                                    "malformed key-exchange message (abort)".to_string(),
                                )
                            }
                        }
                    }
                }
                Inbound::Consumed
            }
            Some("pake-confirm") => {
                let Some(k) = self.k.clone() else {
                    return Inbound::Abort(
                        "confirmation arrived before key exchange (abort)".to_string(),
                    );
                };
                let recv_mac = data["mac"].as_str().and_then(b64_decode).unwrap_or_default();
                let Some((my_fp, their_fp)) = fps else {
                    return Inbound::Abort(
                        "no DTLS fingerprints to bind confirmation (abort)".to_string(),
                    );
                };
                // We MAC against OUR fixed caps + scope, so a server that rewrites the
                // relayed `caps` or `scope` field cannot make the MAC verify. Scope
                // byte is read from the authenticated token, not a local guess.
                if crate::pake::verify_peer_confirm(
                    &k,
                    my_fp,
                    their_fp,
                    &self.caps_canon,
                    self.scope,
                    &recv_mac,
                ) {
                    self.secret = Some(crate::pake::secret_from_k(&k));
                    Inbound::Consumed
                } else {
                    self.aborted = Some("key confirmation failed".to_string());
                    Inbound::Abort(
                        "key confirmation failed: wrong code, or the connection is being tampered with (a server cannot forge this)".to_string(),
                    )
                }
            }
            _ => Inbound::Ignored,
        }
    }
}

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

    fn fps_for(a_is_lo: bool) -> ((&'static str, &'static str), (&'static str, &'static str)) {
        // Distinct fingerprints; each side passes (mine, theirs).
        let a = "SHA-256 AA:BB:CC";
        let b = "SHA-256 DD:EE:FF";
        if a_is_lo {
            ((a, b), (b, a))
        } else {
            ((a, b), (b, a))
        }
    }

    // Run a full two-party ceremony in process and return both outcomes.
    // Scope is MANDATORY: for plain pair (device-to-device) both sides use
    // Device-scoped (0x01), derived from the fixed convention for pair.
    // Scope byte is read from the authenticated token, not a local guess.
    fn run_pair(pw_a: &str, pw_b: &str, np: &str) -> (Ceremony, Ceremony) {
        // Fixed convention for plain pair: Device-scoped (device-to-device).
        // Both sides derive scope from the authenticated token, so honest ends match.
        let scope = crate::identity::IntroScope::Device.to_byte();
        let mut a = Ceremony::new(pw_a, np, pair_v2_caps(), scope);
        let mut b = Ceremony::new(pw_b, np, pair_v2_caps(), scope);
        let ((a_mine, a_theirs), (b_mine, b_theirs)) = fps_for(true);

        // Exchange elements.
        let a_msg = a.take_msg_payload().unwrap();
        let b_msg = b.take_msg_payload().unwrap();
        assert!(matches!(b.on_signal(&a_msg, None), Inbound::Consumed));
        assert!(matches!(a.on_signal(&b_msg, None), Inbound::Consumed));
        assert!(a.has_k() && b.has_k());

        // Exchange confirms (each binds its OWN fingerprint view).
        let a_conf = a.take_confirm_payload(a_mine, a_theirs).unwrap();
        let b_conf = b.take_confirm_payload(b_mine, b_theirs).unwrap();
        let _ = b.on_signal(&a_conf, Some((b_mine, b_theirs)));
        let _ = a.on_signal(&b_conf, Some((a_mine, a_theirs)));
        (a, b)
    }

    #[test]
    fn honest_ceremony_agrees_same_secret() {
        let (a, b) = run_pair("brave-otter", "brave-otter", "314");
        assert!(a.secret().is_some(), "A agreed a secret");
        assert!(b.secret().is_some(), "B agreed a secret");
        assert_eq!(a.secret(), b.secret(), "both sides agree the SAME secret");
        assert_eq!(a.secret().unwrap().len(), 64);
        assert!(a.aborted().is_none() && b.aborted().is_none());
    }

    #[test]
    fn wrong_password_aborts_no_secret() {
        let (a, b) = run_pair("brave-otter", "tidy-walrus", "314");
        // Different passwords -> different K -> confirmation fails on both sides.
        assert!(a.secret().is_none(), "A agrees nothing on a wrong code");
        assert!(b.secret().is_none(), "B agrees nothing on a wrong code");
        assert!(a.aborted().is_some() || b.aborted().is_some());
    }

    #[test]
    fn fingerprint_mismatch_aborts() {
        // A server-substituted DTLS cert => the two sides see different peer
        // fingerprints => confirmation MAC fails => abort, no secret.
        // Scope: fixed convention Device-scoped for plain pair, derived from token.
        let scope = crate::identity::IntroScope::Device.to_byte();
        let mut a = Ceremony::new("brave-otter", "314", pair_v2_caps(), scope);
        let mut b = Ceremony::new("brave-otter", "314", pair_v2_caps(), scope);
        let a_msg = a.take_msg_payload().unwrap();
        let b_msg = b.take_msg_payload().unwrap();
        b.on_signal(&a_msg, None);
        a.on_signal(&b_msg, None);
        // A's view: own=AA, peer=MITM_A. B's view: own=BB, peer=MITM_B.
        let a_conf = a.take_confirm_payload("SHA-256 AA", "SHA-256 MITM-A").unwrap();
        let r = b.on_signal(&a_conf, Some(("SHA-256 BB", "SHA-256 MITM-B")));
        assert!(matches!(r, Inbound::Abort(_)));
        assert!(b.secret().is_none());
    }

    #[test]
    fn confirm_before_key_exchange_aborts() {
        let scope = crate::identity::IntroScope::Device.to_byte();
        let mut a = Ceremony::new("brave-otter", "314", pair_v2_caps(), scope);
        let stray = json!({ "type": "pake-confirm", "v": 2, "mac": b64_encode(&[0u8; 32]), "caps": ["transfer"] });
        let r = a.on_signal(&stray, Some(("SHA-256 AA", "SHA-256 BB")));
        assert!(matches!(r, Inbound::Abort(_)));
    }

    #[test]
    fn non_pake_signal_is_ignored() {
        let scope = crate::identity::IntroScope::Device.to_byte();
        let mut a = Ceremony::new("brave-otter", "314", pair_v2_caps(), scope);
        let sdp = json!({ "type": "description", "description": { "type": "offer" } });
        assert!(matches!(a.on_signal(&sdp, None), Inbound::Ignored));
    }


    #[test]
    fn b64_roundtrips() {
        for n in 0..40usize {
            let data: Vec<u8> = (0..n).map(|i| (i * 7 + 3) as u8).collect();
            assert_eq!(b64_decode(&b64_encode(&data)).unwrap(), data);
        }
    }

    // ---- Gate #2 (server-can't-derive): NEGATIVE security test ----
    // A relay/MITM that does NOT know the password cannot derive K and cannot
    // silently substitute its own key. It runs its own SPAKE2 under a WRONG
    // password, injects its element toward A, and the confirmation MAC MUST
    // fail on A's side, agreeing ZERO secret. Per the ledger rule, an auth
    // feature is VERIFIED only if this negative test exists.
    #[test]
    fn gate2_spake2_substitution_detected() {
        // Honest A and B with the same password. A fake relay (C) does NOT know
        // the password; it guesses wrong and substitutes ITS element for B's
        // when forwarding to A. A must detect the tampering and abort.
        let pw_a = "brave-otter";
        let np = "314";
        let caps = pair_v2_caps();
        let scope = crate::identity::IntroScope::Device.to_byte();
        let mut a = Ceremony::new(pw_a, np, caps.clone(), scope);
        let mut b = Ceremony::new(pw_a, np, caps.clone(), scope);
        // The fake relay C runs its own SPAKE2 with a GUESSED password.
        let relay_pw = "tidy-walrus"; // wrong password
        let mut relay = Ceremony::new(relay_pw, np, caps, scope);

        // A sends its element; relay receives it for forwarding.
        let a_msg = a.take_msg_payload().unwrap();
        // B sends its element; relay intercepts it.
        let b_msg = b.take_msg_payload().unwrap();
        // Relay sends its OWN element (under wrong password) to A.
        let relay_msg = relay.take_msg_payload().unwrap();

        // A receives the relay's (forged) element instead of B's.
        assert!(matches!(a.on_signal(&relay_msg, None), Inbound::Consumed));
        assert!(a.has_k(), "A derived K from the forged element (different K)");

        // B receives A's element honestly.
        assert!(matches!(b.on_signal(&a_msg, None), Inbound::Consumed));
        assert!(b.has_k(), "B derived K from A's element");

        // Now the confirmation step. Both sides have different K.
        let a_conf = a.take_confirm_payload("SHA-256 A", "SHA-256 MITM").unwrap();
        // B verifies A's confirm under B's OWN K. Since K differs, it MUST fail.
        let r = b.on_signal(&a_conf, Some(("SHA-256 B", "SHA-256 A")));
        // The MITM substitution must be DETECTED: confirmation fails.
        assert!(matches!(r, Inbound::Abort(_)), "Gate #2 BROKEN: MITM substitution undetected, {r:?}");
        assert!(b.secret().is_none(), "Gate #2 BROKEN: B agreed a secret despite MITM");
        assert!(b.aborted().is_some(), "Gate #2: confirmed abort flag is set");
        // A also has no secret (the relay never completed the ceremony).
        assert!(a.secret().is_none(), "Gate #2: A agreed nothing (ceremony incomplete)");
    }

    // ---- Gate #2b: relay cannot derive the password from relayed bytes ----
    // The password NEVER appears in any relayed payload (spec gate:relay-blind).
    #[test]
    fn gate2b_password_never_in_relayed_payloads() {
        let scope = crate::identity::IntroScope::Device.to_byte();
        let mut a = Ceremony::new("brave-otter", "314", pair_v2_caps(), scope);
        let mut b = Ceremony::new("brave-otter", "314", pair_v2_caps(), scope);
        let a_msg = a.take_msg_payload().unwrap();
        let b_msg = b.take_msg_payload().unwrap();
        // The password "brave-otter" must not appear in either payload.
        for payload in &[&a_msg, &b_msg] {
            let s = serde_json::to_string(payload).unwrap();
            assert!(!s.contains("brave-otter"), "Gate #2b BROKEN: password leaked in payload: {s}");
        }
        b.on_signal(&a_msg, None);
        a.on_signal(&b_msg, None);
        let a_conf = a.take_confirm_payload("SHA-256 A", "SHA-256 B").unwrap();
        let b_conf = b.take_confirm_payload("SHA-256 B", "SHA-256 A").unwrap();
        for payload in &[&a_conf, &b_conf] {
            let s = serde_json::to_string(payload).unwrap();
            assert!(!s.contains("brave-otter"), "Gate #2b BROKEN: password leaked in confirm payload: {s}");
        }
    }

    #[test]
    fn scope_downgrade_detected() {
        let pw = "brave-otter";
        let np = "314";
        let caps = pair_v2_caps();
        let scope_user = crate::identity::IntroScope::User.to_byte();
        let scope_device = crate::identity::IntroScope::Device.to_byte();
        let mut a = Ceremony::new(pw, np, caps.clone(), scope_user);
        let mut b = Ceremony::new(pw, np, caps.clone(), scope_device);
        let a_msg = a.take_msg_payload().unwrap();
        let b_msg = b.take_msg_payload().unwrap();
        b.on_signal(&a_msg, None);
        a.on_signal(&b_msg, None);
        let a_conf = a.take_confirm_payload("SHA-256 AA", "SHA-256 BB").unwrap();
        let r = b.on_signal(&a_conf, Some(("SHA-256 BB", "SHA-256 AA")));
        assert!(matches!(r, Inbound::Abort(_)), "scope downgrade must be detected");
        assert!(b.secret().is_none());
    }

}