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}