Repository navigation
Expand file tree
/
Copy pathauth.rs
More file actions
378 lines (334 loc) · 14 KB
/
Copy pathauth.rs
File metadata and controls
378 lines (334 loc) · 14 KB
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
//! Room admission, and the identity of a room opened by name.
//!
//! This layer is **not encryption** — iroh already encrypts every connection end to end
//! with QUIC/TLS. What the QUIC handshake proves about the other side depends on how the
//! room was reached:
//!
//! * **By invite code.** The code is the coordinator's public key, so QUIC verifies the
//! joiner reached exactly the machine that printed it. The password's only job is to say
//! "not everyone who gets hold of this code may come in".
//! * **By room name and passphrase.** The coordinator's secret key is *derived* from the
//! two, so QUIC proves much less: only that the other side knows the passphrase, not
//! which machine it is. Anyone holding the passphrase can open a rival room under the
//! same name, or publish their own addresses under its key while the real host is
//! running. That is the price of an invite that can be said out loud.
//!
//! Either way the password never travels over the wire. Each side stretches it once with
//! Argon2id into an admission key; the coordinator sends a random nonce and the joiner
//! answers with a keyed BLAKE2b MAC of it. The nonce is fresh on every connection, so a
//! captured proof cannot be replayed — and the coordinator does no Argon2 work per
//! connection attempt, which would otherwise be a cheap way to burn its CPU.
use anyhow::{Result, anyhow, ensure};
use argon2::{Algorithm, Argon2, Params, Version};
use blake2::Blake2bMac;
use blake2::digest::Mac;
use blake2::digest::consts::U32;
use iroh::SecretKey;
use rand::Rng;
use subtle::{Choice, ConstantTimeEq};
use crate::proto::PeerId;
pub type Nonce = [u8; 16];
pub type Proof = [u8; 32];
/// The longest room name, in characters.
///
/// Kept under the invite code's 52 so that `tincan join` can never mistake a room name
/// for a code.
pub const MAX_ROOM_CHARS: usize = 48;
/// Argon2id cost `(memory KiB, passes, lanes)` for a room opened by name.
///
/// Pinned rather than taken from `Params::default()`: these numbers are part of every
/// room's address, and a library changing its defaults would silently move them all.
/// Heavier than the invite path's, because anyone can compute the key for a guessed
/// ("lobby", "123456") and look it up — this cost is the only thing in the way.
const ROOM_COST: (u32, u32, u32) = (64 * 1024, 3, 1);
/// Argon2id cost for the invite path, where only the coordinator ever sees a proof.
const INVITE_COST: (u32, u32, u32) = (19 * 1024, 2, 1);
/// Salt prefixes. They keep the two derivations apart, and give Argon2 the eight bytes of
/// salt it insists on even for a room called "lan".
const ROOM_SALT: &[u8] = b"tincan/room/v1\0";
const INVITE_SALT: &[u8] = b"tincan/invite/v1\0";
/// BLAKE2b personalisations, one per thing a key is used for (16 bytes at most).
const IDENTITY: &[u8] = b"tincan/identity";
const ADMIT: &[u8] = b"tincan/admit";
const PROOF: &[u8] = b"tincan/proof";
/// The key a password is stretched into. Proofs are MACs under it.
#[derive(Clone)]
pub struct Key([u8; 32]);
impl Key {
/// The admission key for a room reached by its invite code.
///
/// Salted with the coordinator's public key: the joiner already has it from the code,
/// and it keeps a key stretched for one room from being any use against another.
/// Passwordless rooms use the empty string, so that there is a single path through
/// the handshake and no "is there a password" branch leaks into the protocol.
pub fn for_invite(password: &str, coordinator: &PeerId) -> Result<Self> {
let master = stretch(&fold(password), INVITE_SALT, &coordinator.0, INVITE_COST)?;
Ok(Self(mac(&master, ADMIT, &[])))
}
}
/// Everything a room name and passphrase derive to.
pub struct RoomSecret {
identity: SecretKey,
key: Key,
}
impl RoomSecret {
/// Derives the room's identity and admission key.
///
/// The Ed25519 secret and the admission key are separate BLAKE2b subkeys of one
/// Argon2id master, so the key proofs are made with is never the key the endpoint
/// signs with.
pub fn derive(room: &str, passphrase: &str) -> Result<Self> {
let room = normalize_room(room)?;
let passphrase = fold(passphrase);
ensure!(
!passphrase.is_empty(),
"a room opened by name needs a passphrase"
);
let master = stretch(&passphrase, ROOM_SALT, room.as_bytes(), ROOM_COST)?;
Ok(Self {
identity: SecretKey::from_bytes(&mac(&master, IDENTITY, &[])),
key: Key(mac(&master, ADMIT, &[])),
})
}
/// The coordinator's secret key: the host binds with it.
pub fn identity(&self) -> SecretKey {
self.identity.clone()
}
/// The coordinator's public identity: the joiner connects to it.
pub fn coordinator(&self) -> PeerId {
PeerId(*self.identity.public().as_bytes())
}
/// The key a joiner proves itself with.
pub fn key(&self) -> &Key {
&self.key
}
}
/// What the coordinator checks proofs against.
pub struct Admission {
keys: Vec<Key>,
}
impl Admission {
/// A room reached only by its invite code.
pub fn invite(password: &str, coordinator: &PeerId) -> Result<Self> {
Ok(Self {
keys: vec![Key::for_invite(password, coordinator)?],
})
}
/// A room opened by name. It admits the invite-code key for the same passphrase too:
/// the code still reaches this room, and whoever uses it knows the coordinator's key
/// but not necessarily the room name.
pub fn room(secret: &RoomSecret, passphrase: &str) -> Result<Self> {
Ok(Self {
keys: vec![
secret.key.clone(),
Key::for_invite(passphrase, &secret.coordinator())?,
],
})
}
/// Verifies a proof in constant time. Every key is tried even after one matches, so
/// the timing does not say which way in was used.
pub fn admits(&self, nonce: &Nonce, presented: &Proof) -> bool {
self.keys
.iter()
.fold(Choice::from(0), |matched, key| {
matched | proof(key, nonce).ct_eq(presented)
})
.into()
}
}
pub fn random_nonce() -> Nonce {
let mut nonce = [0u8; 16];
rand::rng().fill_bytes(&mut nonce);
nonce
}
/// The joiner's answer to a challenge.
pub fn proof(key: &Key, nonce: &Nonce) -> Proof {
mac(&key.0, PROOF, nonce)
}
/// Normalizes a room name the way the derivation sees it.
pub fn normalize_room(name: &str) -> Result<String> {
let name = fold(name);
ensure!(!name.is_empty(), "a room name cannot be empty");
ensure!(
name.chars().count() <= MAX_ROOM_CHARS,
"a room name can be at most {MAX_ROOM_CHARS} characters"
);
Ok(name)
}
/// Forgives what cannot be heard: case, surrounding space, and the choice of separator.
///
/// "Chestnut Ferry" and "chestnut-ferry" are one passphrase, because someone reading it
/// down the phone has no way to say which of the two they meant.
fn fold(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut gap = false;
for c in text.trim().chars() {
if c.is_whitespace() || c == '-' || c == '_' {
gap = true;
continue;
}
if gap && !out.is_empty() {
out.push('-');
}
gap = false;
out.extend(c.to_lowercase());
}
out
}
fn stretch(secret: &str, domain: &[u8], context: &[u8], cost: (u32, u32, u32)) -> Result<[u8; 32]> {
let (memory, passes, lanes) = cost;
let params = Params::new(memory, passes, lanes, Some(32))
.map_err(|e| anyhow!("invalid key derivation parameters: {e}"))?;
let mut out = [0u8; 32];
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
.hash_password_into(secret.as_bytes(), &[domain, context].concat(), &mut out)
.map_err(|e| anyhow!("key derivation failed: {e}"))?;
Ok(out)
}
fn mac(key: &[u8; 32], persona: &[u8], message: &[u8]) -> [u8; 32] {
let mut mac = Blake2bMac::<U32>::new_with_salt_and_personal(key, &[], persona)
.expect("a 32-byte key and a short persona are within BLAKE2b's limits");
mac.update(message);
let mut out = [0u8; 32];
out.copy_from_slice(&mac.finalize().into_bytes());
out
}
#[cfg(test)]
mod tests {
use super::*;
fn peer(seed: u8) -> PeerId {
PeerId([seed; 32])
}
fn invite(password: &str) -> Admission {
Admission::invite(password, &peer(1)).unwrap()
}
fn answer(password: &str, nonce: &Nonce) -> Proof {
proof(&Key::for_invite(password, &peer(1)).unwrap(), nonce)
}
#[test]
fn correct_password_verifies() {
let nonce = random_nonce();
assert!(invite("secret123").admits(&nonce, &answer("secret123", &nonce)));
}
#[test]
fn wrong_password_is_rejected() {
let nonce = random_nonce();
let p = answer("secret123", &nonce);
assert!(!invite("secret124").admits(&nonce, &p));
assert!(!invite("").admits(&nonce, &p));
}
/// A captured proof must be useless on another connection.
#[test]
fn proof_is_bound_to_its_nonce() {
let first = random_nonce();
let second = random_nonce();
assert_ne!(first, second, "the nonce must be fresh every time");
let room = invite("same-password");
let p = answer("same-password", &first);
assert!(room.admits(&first, &p));
assert!(!room.admits(&second, &p), "replay must be blocked");
}
#[test]
fn passwordless_rooms_work_through_the_same_path() {
let nonce = random_nonce();
let p = answer("", &nonce);
assert!(invite("").admits(&nonce, &p));
assert!(!invite("a-password").admits(&nonce, &p));
}
/// A key stretched for one coordinator must not open another.
#[test]
fn invite_keys_are_bound_to_their_coordinator() {
let nonce = [3u8; 16];
let p = proof(&Key::for_invite("abc", &peer(1)).unwrap(), &nonce);
assert!(
Admission::invite("abc", &peer(1))
.unwrap()
.admits(&nonce, &p)
);
assert!(
!Admission::invite("abc", &peer(2))
.unwrap()
.admits(&nonce, &p)
);
}
/// The point of this test is the multi-byte password — keep it non-ASCII.
#[test]
fn non_ascii_passwords_are_supported() {
let nonce = random_nonce();
assert!(invite("パスワードäöü").admits(&nonce, &answer("パスワードäöü", &nonce)));
assert!(invite("ÄÖÜ").admits(&nonce, &answer("äöü", &nonce)));
}
#[test]
fn what_cannot_be_heard_is_forgiven() {
assert_eq!(
fold(" Chestnut Ferry\tLens__moss "),
"chestnut-ferry-lens-moss"
);
assert_eq!(fold("chestnut--ferry"), "chestnut-ferry");
assert_eq!(fold("-lobby-"), "lobby");
assert_eq!(fold(""), "");
}
/// Both sides must land on the same address from the same two words, however they
/// were typed.
#[test]
fn room_derivation_is_deterministic_and_forgiving() {
let host = RoomSecret::derive("lobby", "chestnut-ferry-lens-moss").unwrap();
let joiner = RoomSecret::derive(" Lobby", "Chestnut Ferry Lens Moss").unwrap();
assert_eq!(host.coordinator(), joiner.coordinator());
assert_eq!(host.key().0, joiner.key().0);
}
#[test]
fn room_and_passphrase_both_move_the_address() {
let base = RoomSecret::derive("lobby", "chestnut-ferry-lens-moss").unwrap();
let other_room = RoomSecret::derive("lobby2", "chestnut-ferry-lens-moss").unwrap();
let other_pass = RoomSecret::derive("lobby", "chestnut-ferry-lens-mess").unwrap();
assert_ne!(base.coordinator(), other_room.coordinator());
assert_ne!(base.coordinator(), other_pass.coordinator());
assert_ne!(base.key().0, other_pass.key().0);
}
/// The signing key and the proof key come from one master but must not be the same
/// bytes, and neither may be the invite path's key.
#[test]
fn room_keys_are_separated_by_purpose() {
let room = RoomSecret::derive("lan", "chestnut-ferry-lens-moss").unwrap();
assert_ne!(room.identity().to_bytes(), room.key().0);
let invite = Key::for_invite("chestnut-ferry-lens-moss", &room.coordinator()).unwrap();
assert_ne!(invite.0, room.key().0);
}
/// These bytes are the address of every room called "lobby" with this passphrase. If
/// this test fails, rooms opened by an older tincan can no longer be found.
#[test]
fn room_identity_is_pinned() {
let room = RoomSecret::derive("lobby", "chestnut-ferry-lens-moss").unwrap();
assert_eq!(room.coordinator().to_string(), PINNED_LOBBY);
}
const PINNED_LOBBY: &str = "e644ff027b6d37039875fa117a32ca09f077e5e823b2bb03f29c2b2e869d2abc";
#[test]
fn a_named_room_admits_both_ways_in_and_nothing_else() {
let room = RoomSecret::derive("lobby", "chestnut-ferry-lens-moss").unwrap();
let admission = Admission::room(&room, "chestnut-ferry-lens-moss").unwrap();
let nonce = random_nonce();
assert!(
admission.admits(&nonce, &proof(room.key(), &nonce)),
"by name"
);
let by_code = Key::for_invite("chestnut-ferry-lens-moss", &room.coordinator()).unwrap();
assert!(
admission.admits(&nonce, &proof(&by_code, &nonce)),
"by code"
);
let wrong = Key::for_invite("chestnut-ferry-lens-mess", &room.coordinator()).unwrap();
assert!(
!admission.admits(&nonce, &proof(&wrong, &nonce)),
"wrong passphrase"
);
}
#[test]
fn empty_or_oversized_input_is_refused() {
assert!(RoomSecret::derive("", "chestnut-ferry-lens-moss").is_err());
assert!(RoomSecret::derive(" - ", "chestnut-ferry-lens-moss").is_err());
assert!(RoomSecret::derive("lobby", " ").is_err());
assert!(normalize_room(&"a".repeat(MAX_ROOM_CHARS)).is_ok());
assert!(normalize_room(&"a".repeat(MAX_ROOM_CHARS + 1)).is_err());
}
}