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. Local use is frictionless - a loopback listener asks for
8//! nothing. A listener reachable from elsewhere on the network asks for a
9//! token, and `server::decide_bind` refuses to start without one.
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 mut text = String::new();
74        for (index, byte) in entropy
75            .iter()
76            .take(GROUP_LEN.saturating_mul(GROUP_COUNT))
77            .enumerate()
78        {
79            if index != 0 && index.checked_rem(GROUP_LEN) == Some(0) {
80                text.push('-');
81            }
82            let slot = usize::from(*byte)
83                .checked_rem(ALPHABET.len())
84                .unwrap_or_default();
85            if let Some(symbol) = ALPHABET.get(slot) {
86                text.push(char::from(*symbol));
87            }
88        }
89        let token = Self(B3Hash::digest(text.as_bytes()));
90        (token, text)
91    }
92
93    /// Adopts a token the operator configured rather than one we minted.
94    ///
95    /// # Errors
96    /// Refuses anything too short to be worth comparing.
97    pub fn from_configured(raw: &str) -> Result<Self, AxError> {
98        if raw.chars().count() < MIN_CONFIGURED_LEN {
99            return Err(AxError::failure(
100                AxCode::ConfigInvalid,
101                "adopt a configured pairing token",
102                format!("a pairing token needs at least {MIN_CONFIGURED_LEN} characters"),
103            )
104            .with_recovery("let the host mint one, or configure a longer value"));
105        }
106        Ok(Self(B3Hash::digest(raw.as_bytes())))
107    }
108
109    /// What the server is given, and all it ever needs.
110    #[must_use]
111    pub fn digest(&self) -> B3Hash {
112        self.0
113    }
114}
115
116/// Compares a presented token against a stored digest in constant time.
117///
118/// `None` is not an empty string: a peer that sent no token at all is
119/// refused without ever entering the comparison, and a peer that sent an
120/// empty one still pays the full comparison so the two are indistinguishable
121/// from outside.
122#[must_use]
123pub fn verify(presented: Option<&str>, expected: &B3Hash) -> bool {
124    let Some(presented) = presented else {
125        return false;
126    };
127    let offered = B3Hash::digest(presented.as_bytes());
128    let mut difference = 0u8;
129    for (a, b) in offered.as_bytes().iter().zip(expected.as_bytes().iter()) {
130        difference |= a ^ b;
131    }
132    difference == 0
133}
134
135#[cfg(test)]
136#[allow(
137    clippy::unwrap_used,
138    clippy::expect_used,
139    clippy::panic,
140    clippy::indexing_slicing,
141    clippy::arithmetic_side_effects,
142    reason = "test code"
143)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn the_alphabet_omits_every_pair_a_person_would_confuse() {
149        for confusable in *b"0O1lI5S" {
150            assert!(
151                !ALPHABET.contains(&confusable),
152                "{} is transcribed wrong often enough to matter",
153                char::from(confusable)
154            );
155        }
156        let mut deduped = ALPHABET.to_vec();
157        deduped.sort_unstable();
158        deduped.dedup();
159        assert_eq!(deduped.len(), ALPHABET.len(), "no symbol appears twice");
160    }
161
162    #[test]
163    fn a_minted_code_has_the_shape_the_constants_promise() {
164        let (_token, shown) = PairingToken::mint([0x44u8; 32]);
165        let groups: Vec<&str> = shown.split('-').collect();
166        assert_eq!(groups.len(), GROUP_COUNT);
167        for group in groups {
168            assert_eq!(group.chars().count(), GROUP_LEN);
169        }
170    }
171
172    #[test]
173    fn verification_is_blind_to_how_long_the_wrong_guess_was() {
174        let (token, _shown) = PairingToken::mint([0x55u8; 32]);
175        let expected = token.digest();
176        assert!(!verify(Some(""), &expected));
177        assert!(!verify(Some("a"), &expected));
178        assert!(!verify(Some(&"a".repeat(4096)), &expected));
179    }
180}