Skip to main content

wire/
auth.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Pairing tokens: minting, the one form a person may read, and the
7//! comparison. Every listener asks for a key (`reception::decide_bind`
8//! refuses to start without one); a listener reachable from elsewhere on
9//! the network shows its key once as a pairing token.
10//!
11//! The token's whole life is here so that no other module needs its
12//! plaintext: callers hand [`PairingToken::digest`] to the server and keep
13//! the token itself, and comparison happens on digests.
14//!
15//! Entropy arrives as a parameter. This module samples nothing - the seeded
16//! RNG is handed down from the assembly layer,
17//! which is also what makes minting testable.
18
19use kernel::{AxCode, AxError, B3Hash};
20
21/// Whether the caller held this city's pairing token.
22///
23/// The verdict a door hands inward, so it is vocabulary rather than
24/// listener: the city's writer reads it without the `server` feature
25/// (wire D2).
26///
27/// An enum rather than a boolean so that neither a door nor an
28/// admission can pass the verdict the wrong way round and still
29/// compile.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Pairing {
32    Held,
33    Absent,
34}
35
36/// Digits and letters that survive being read aloud and typed back:
37/// `0/O`, `1/l/I` and `5/S` are absent because a pairing code is transcribed
38/// by a person, and a code that cannot be dictated fails in the one moment
39/// it exists for.
40const ALPHABET: &[u8; 29] = b"2346789abcdefghjkmnpqrtuvwxyz";
41
42/// Characters per readable group, and number of groups. Four groups of five
43/// over a 29-symbol alphabet is a little over 97 bits - far beyond what an
44/// attacker on the same network segment can exhaust, and still short enough
45/// to read to somebody in the next room.
46const GROUP_LEN: usize = 5;
47const GROUP_COUNT: usize = 4;
48
49/// The shortest configured token this module will accept. Below this a token
50/// is decoration; refusing it is cheaper than explaining later why the port
51/// was open.
52const MIN_CONFIGURED_LEN: usize = 16;
53
54/// A pairing token, held as the digest of the code and nothing else.
55///
56/// The plaintext never lives in this type. [`PairingToken::mint`] hands the
57/// readable code straight back to its caller, who shows it to a person once,
58/// and keeps only what verification needs. Sealing a value and then exposing
59/// it on the next line would be theatre; not holding it at all is the
60/// property we actually wanted.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct PairingToken(B3Hash);
63
64impl PairingToken {
65    /// Mints a token from caller-supplied entropy, returning the token and
66    /// the code to display exactly once.
67    ///
68    /// Deterministic in `entropy`: same bytes, same token. The randomness
69    /// lives in whoever fills `entropy`, which is the assembly layer's seeded
70    /// generator, never a sample taken here.
71    #[must_use]
72    pub fn mint(entropy: [u8; 32]) -> (Self, String) {
73        let text = spell(&entropy, GROUP_LEN, GROUP_COUNT);
74        let token = Self(B3Hash::digest(text.as_bytes()));
75        (token, text)
76    }
77
78    /// Adopts a token the operator configured rather than one we minted.
79    ///
80    /// # Errors
81    /// Refuses anything too short to be worth comparing.
82    pub fn from_configured(raw: &str) -> Result<Self, AxError> {
83        if raw.chars().count() < MIN_CONFIGURED_LEN {
84            return Err(AxError::failure(
85                AxCode::ConfigInvalid,
86                "adopt a configured pairing token",
87                format!("a pairing token needs at least {MIN_CONFIGURED_LEN} characters"),
88            )
89            .with_recovery("let the host mint one, or configure a longer value"));
90        }
91        Ok(Self(B3Hash::digest(raw.as_bytes())))
92    }
93
94    /// What the server is given, and all it ever needs.
95    #[must_use]
96    pub fn digest(&self) -> B3Hash {
97        self.0
98    }
99}
100
101/// Spells `entropy` in the alphabet a person reads aloud: `groups` groups
102/// of `group_len` symbols joined by `-`, one symbol a byte. The one
103/// spelling of every code a person types: the pairing token above and
104/// the pairing code of this machine's door (`reception::pairing`).
105pub(crate) fn spell(entropy: &[u8], group_len: usize, groups: usize) -> String {
106    let mut text = String::new();
107    for (index, byte) in entropy
108        .iter()
109        .take(group_len.saturating_mul(groups))
110        .enumerate()
111    {
112        if index != 0 && index.checked_rem(group_len) == Some(0) {
113            text.push('-');
114        }
115        let slot = usize::from(*byte)
116            .checked_rem(ALPHABET.len())
117            .unwrap_or_default();
118        if let Some(symbol) = ALPHABET.get(slot) {
119            text.push(char::from(*symbol));
120        }
121    }
122    text
123}
124
125/// Compares a presented token against a stored digest in constant time.
126///
127/// `None` is not an empty string: a peer that sent no token at all is
128/// refused without ever entering the comparison, and a peer that sent an
129/// empty one still pays the full comparison so the two are indistinguishable
130/// from outside.
131#[must_use]
132pub fn verify(presented: Option<&str>, expected: &B3Hash) -> bool {
133    let Some(presented) = presented else {
134        return false;
135    };
136    let offered = B3Hash::digest(presented.as_bytes());
137    let mut difference = 0u8;
138    for (a, b) in offered.as_bytes().iter().zip(expected.as_bytes().iter()) {
139        difference |= a ^ b;
140    }
141    difference == 0
142}
143
144#[cfg(test)]
145#[allow(
146    clippy::unwrap_used,
147    clippy::expect_used,
148    clippy::panic,
149    clippy::indexing_slicing,
150    clippy::arithmetic_side_effects,
151    reason = "test code"
152)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn the_alphabet_omits_every_pair_a_person_would_confuse() {
158        for confusable in *b"0O1lI5S" {
159            assert!(
160                !ALPHABET.contains(&confusable),
161                "{} is transcribed wrong often enough to matter",
162                char::from(confusable)
163            );
164        }
165        let mut deduped = ALPHABET.to_vec();
166        deduped.sort_unstable();
167        deduped.dedup();
168        assert_eq!(deduped.len(), ALPHABET.len(), "no symbol appears twice");
169    }
170
171    #[test]
172    fn a_minted_code_has_the_shape_the_constants_promise() {
173        let (_token, shown) = PairingToken::mint([0x44u8; 32]);
174        let groups: Vec<&str> = shown.split('-').collect();
175        assert_eq!(groups.len(), GROUP_COUNT);
176        for group in groups {
177            assert_eq!(group.chars().count(), GROUP_LEN);
178        }
179    }
180
181    #[test]
182    fn verification_is_blind_to_how_long_the_wrong_guess_was() {
183        let (token, _shown) = PairingToken::mint([0x55u8; 32]);
184        let expected = token.digest();
185        assert!(!verify(Some(""), &expected));
186        assert!(!verify(Some("a"), &expected));
187        assert!(!verify(Some(&"a".repeat(4096)), &expected));
188    }
189}