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}