git-ents.gitmain
⌘K
foforge
auth.rs340 lines · 13.0 KB · rusthistorycomment on this file
1//! Hosted web sign-in (`roots.web-signin`): prove control of an enrolled,
2//! active member key by signing a server-issued one-time challenge.
3//!
4//! The protocol is the pre-redo forge's key-proof sign-in with the paste
5//! step replaced by `git ents login`: the `/login` page mints a short code
6//! bound to the browser's own session, the CLI fetches the full challenge,
7//! signs it under [`LOGIN_NAMESPACE`] — deliberately distinct from git's
8//! `git` commit namespace, so a sign-in signature can never double as a
9//! push signature or vice versa — and posts the signature back. The
10//! server verifies it in pure Rust against the member's stored OpenSSH
11//! public key, the same `ssh_key` technique `ents_gate::signature` uses
12//! for commit signatures.
13//!
14//! Replay is bound off at every joint: the signed payload names the
15//! serving host, the code, and the nonce ([`challenge_payload`]), so a
16//! signature minted for one deployment or one browser session verifies
17//! nowhere else; a challenge is single-use ([`ChallengeStore::take`]) and
18//! expires after [`CHALLENGE_TTL`], measured on the monotonic clock.
19// @relation(roots.web-signin, scope=file)
20
21use std::collections::HashMap;
22use std::sync::Mutex;
23use std::time::{Duration, Instant};
24
25use ents_model::MemberState;
26use gix_object::Find;
27use ssh_key::{PublicKey, SshSig};
28
29use crate::state::AppState;
30
31/// The SSHSIG namespace a sign-in signature is made under — never `git`,
32/// so a login signature and a push signature are unexchangeable
33/// (`roots.web-signin`).
34pub const LOGIN_NAMESPACE: &str = "git-ents-login";
35
36/// How long an issued sign-in challenge stays valid. Measured with
37/// [`Instant`], so a wall-clock step never extends or shortens a
38/// challenge's life (a suspended machine's paused monotonic clock can
39/// honor one slightly longer than this in wall time — accepted).
40pub const CHALLENGE_TTL: Duration = Duration::from_secs(600);
41
42/// The exact bytes both sides sign and verify: version-tagged, binding
43/// the serving host, the browser code, and the one-time nonce. The CLI
44/// MUST rebuild this locally from the host the member addressed rather
45/// than signing server-supplied bytes (`roots.web-signin`).
46#[must_use]
47pub fn challenge_payload(host: &str, code: &str, nonce: &str) -> String {
48 format!("git-ents-login-v1\nhost={host}\ncode={code}\nnonce={nonce}\n")
49}
50
51/// Normalize a user-facing code: the `/login` page displays `XXXX-XXXX`
52/// and a human may retype it in either case, with or without the dash.
53#[must_use]
54pub fn normalize_code(code: &str) -> String {
55 code.chars()
56 .filter(|c| *c != '-')
57 .map(|c| c.to_ascii_uppercase())
58 .collect()
59}
60
61/// One outstanding sign-in challenge: which browser session it will
62/// authenticate, the nonce the signature must cover, and when it was
63/// issued.
64#[derive(Debug, Clone)]
65pub struct Challenge {
66 /// The session id the `/login` page bound this challenge to — the
67 /// only session [`ChallengeStore::take`]'s caller may authenticate.
68 pub session_id: String,
69 /// The one-time nonce bound into [`challenge_payload`].
70 pub nonce: String,
71 /// When this challenge was issued, for [`CHALLENGE_TTL`].
72 issued: Instant,
73}
74
75/// Outstanding sign-in challenges, keyed by their user-facing code —
76/// memory-only, like [`crate::session::SessionStore`], and pruned of
77/// expired entries on every issue.
78#[derive(Default)]
79pub struct ChallengeStore {
80 table: Mutex<HashMap<String, Challenge>>,
81}
82
83impl ChallengeStore {
84 /// Mint a fresh challenge bound to `session_id`, returning its
85 /// `(code, nonce)`. Re-issuing for the same session replaces that
86 /// session's outstanding challenge, so one browser holds at most one
87 /// live code.
88 #[must_use]
89 pub fn issue(&self, session_id: &str) -> (String, String) {
90 let code = random_code();
91 let nonce = random_nonce();
92 let challenge = Challenge {
93 session_id: session_id.to_owned(),
94 nonce: nonce.clone(),
95 issued: Instant::now(),
96 };
97 let mut table = self.lock();
98 let now = Instant::now();
99 table.retain(|_code, held| {
100 now.duration_since(held.issued) < CHALLENGE_TTL && held.session_id != session_id
101 });
102 table.insert(code.clone(), challenge);
103 (code, nonce)
104 }
105
106 /// Read `code`'s live challenge without consuming it — the CLI's
107 /// initial fetch. Expired entries read as absent.
108 #[must_use]
109 pub fn peek(&self, code: &str) -> Option<Challenge> {
110 let code = normalize_code(code);
111 let table = self.lock();
112 let held = table.get(&code)?;
113 (Instant::now().duration_since(held.issued) < CHALLENGE_TTL).then(|| held.clone())
114 }
115
116 /// Consume `code`, returning its challenge iff it was live and
117 /// unexpired — single-use by construction: a second take of the same
118 /// code is `None` whatever the first returned.
119 #[must_use]
120 pub fn take(&self, code: &str) -> Option<Challenge> {
121 let code = normalize_code(code);
122 let held = self.lock().remove(&code)?;
123 (Instant::now().duration_since(held.issued) < CHALLENGE_TTL).then_some(held)
124 }
125
126 /// Age `code`'s challenge by `by`, as if it had been issued that much
127 /// earlier — tests cannot construct an [`Instant`] in the past any
128 /// other way.
129 #[cfg(test)]
130 fn backdate(&self, code: &str, by: Duration) {
131 if let Some(held) = self.lock().get_mut(&normalize_code(code))
132 && let Some(issued) = held.issued.checked_sub(by)
133 {
134 held.issued = issued;
135 }
136 }
137
138 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Challenge>> {
139 self.table
140 .lock()
141 .unwrap_or_else(std::sync::PoisonError::into_inner)
142 }
143}
144
145/// Whether `signature` (an armored SSHSIG) over `payload` verifies
146/// against `public_key` (an OpenSSH single-line key, as stored on an
147/// [`ents_model::Member`]) under [`LOGIN_NAMESPACE`]. Any malformed key,
148/// malformed signature, wrong namespace, or failed cryptographic check is
149/// `false` — mirrors `ents_gate::signature`'s identical posture for
150/// commit signatures.
151#[must_use]
152pub fn verify_login(public_key: &str, payload: &[u8], signature: &str) -> bool {
153 let Ok(key) = PublicKey::from_openssh(public_key) else {
154 return false;
155 };
156 let Ok(sig) = SshSig::from_pem(signature) else {
157 return false;
158 };
159 key.verify(LOGIN_NAMESPACE, payload, &sig).is_ok()
160}
161
162/// Resolve `pubkey` to the enrolled, *active* member that stores it, if
163/// any — the sign-in completion's membership check, and the auth
164/// middleware's per-mutation re-check (`roots.web-signin`: a session
165/// whose member is no longer enrolled and active is refused at the time
166/// of the mutation, not only at sign-in).
167///
168/// # Errors
169///
170/// Propagates a ref-store read failure; an individual member ref this
171/// build cannot decode is skipped, exactly as the members page skips it.
172pub(crate) fn active_member_by_key<O: Find>(
173 state: &AppState<O>,
174 pubkey: &str,
175) -> crate::Result<Option<String>> {
176 for (username, member) in crate::pages::members::read_all(state)? {
177 if let Ok(member) = member
178 && member.key == pubkey
179 && member.state == MemberState::Active
180 {
181 return Ok(Some(username));
182 }
183 }
184 Ok(None)
185}
186
187/// A user-facing code: eight characters of Crockford-style base32 (no
188/// `I`, `L`, `O`, `U`, no lowercase), ~40 bits — plenty for a single-use
189/// secret that lives ten minutes, and short enough to retype.
190fn random_code() -> String {
191 const ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
192 let mut bytes = [0u8; 8];
193 fill_random(&mut bytes);
194 bytes
195 .iter()
196 .map(|b| {
197 // The low five bits index exactly the 32-entry alphabet, so
198 // the draw is uniform and the index cannot overrun.
199 let index = usize::from(*b & 0x1f);
200 #[expect(
201 clippy::indexing_slicing,
202 reason = "a five-bit index cannot overrun the 32-entry alphabet"
203 )]
204 char::from(ALPHABET[index])
205 })
206 .collect()
207}
208
209/// A nonce: 32 hex characters from 16 random bytes, the same shape as a
210/// session id.
211fn random_nonce() -> String {
212 let mut bytes = [0u8; 16];
213 fill_random(&mut bytes);
214 bytes.iter().map(|b| format!("{b:02x}")).collect()
215}
216
217fn fill_random(bytes: &mut [u8]) {
218 #[expect(
219 clippy::expect_used,
220 reason = "getrandom only fails when the platform has no randomness source at all, which \
221 every target this crate ships to provides"
222 )]
223 getrandom::fill(bytes).expect("platform randomness source is available");
224}
225
226#[cfg(test)]
227mod tests {
228 #![allow(clippy::expect_used, reason = "unit test")]
229
230 use rstest::rstest;
231 use ssh_key::private::{Ed25519Keypair, KeypairData};
232 use ssh_key::{HashAlg, LineEnding, PrivateKey};
233
234 use super::*;
235
236 fn keypair(seed: u8) -> PrivateKey {
237 let pair = Ed25519Keypair::from_seed(&[seed; 32]);
238 PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed")
239 }
240
241 fn sign_in_namespace(key: &PrivateKey, namespace: &str, payload: &[u8]) -> String {
242 key.sign(namespace, HashAlg::Sha512, payload)
243 .expect("signing is infallible for a loaded key")
244 .to_pem(LineEnding::LF)
245 .expect("an SSHSIG always renders as PEM")
246 }
247
248 fn public_line(key: &PrivateKey) -> String {
249 key.public_key().to_openssh().expect("renders")
250 }
251
252 #[rstest]
253 // @relation(roots.web-signin, scope=function, role=Verifies)
254 fn payload_is_the_exact_versioned_bytes_both_sides_build() {
255 assert_eq!(
256 challenge_payload("git.ents.cloud", "ABCD2345", "0f" /* nonce */),
257 "git-ents-login-v1\nhost=git.ents.cloud\ncode=ABCD2345\nnonce=0f\n"
258 );
259 }
260
261 #[rstest]
262 // @relation(roots.web-signin, scope=function, role=Verifies)
263 fn a_challenge_is_single_use() {
264 let store = ChallengeStore::default();
265 let (code, nonce) = store.issue("session-1");
266 let taken = store.take(&code).expect("first take");
267 assert_eq!(taken.session_id, "session-1");
268 assert_eq!(taken.nonce, nonce);
269 assert!(store.take(&code).is_none(), "second take must fail");
270 }
271
272 #[rstest]
273 // @relation(roots.web-signin, scope=function, role=Verifies)
274 fn peek_reads_without_consuming_and_codes_normalize() {
275 let store = ChallengeStore::default();
276 let (code, nonce) = store.issue("session-1");
277 let (head, tail) = code.split_at(4);
278 let dashed = format!("{head}-{}", tail.to_lowercase());
279 assert_eq!(store.peek(&dashed).expect("live").nonce, nonce);
280 assert!(store.take(&dashed).is_some(), "peek must not consume");
281 }
282
283 #[rstest]
284 // @relation(roots.web-signin, scope=function, role=Verifies)
285 fn an_expired_challenge_reads_as_absent() {
286 let store = ChallengeStore::default();
287 let (code, _nonce) = store.issue("session-1");
288 store.backdate(&code, CHALLENGE_TTL.saturating_add(Duration::from_secs(1)));
289 assert!(store.peek(&code).is_none());
290 assert!(store.take(&code).is_none());
291 }
292
293 #[rstest]
294 // @relation(roots.web-signin, scope=function, role=Verifies)
295 fn reissuing_replaces_the_sessions_outstanding_challenge() {
296 let store = ChallengeStore::default();
297 let (first, _) = store.issue("session-1");
298 let (second, _) = store.issue("session-1");
299 assert!(store.take(&first).is_none(), "replaced by the re-issue");
300 assert!(store.take(&second).is_some());
301 }
302
303 #[rstest]
304 // @relation(roots.web-signin, scope=function, role=Verifies)
305 fn verify_accepts_only_the_login_namespace_over_the_exact_payload() {
306 let key = keypair(1);
307 let payload = challenge_payload("git.ents.cloud", "ABCD2345", "0011");
308 let good = sign_in_namespace(&key, LOGIN_NAMESPACE, payload.as_bytes());
309 assert!(verify_login(&public_line(&key), payload.as_bytes(), &good));
310
311 // A real signature under git's own commit namespace over the very
312 // same bytes must not double as a login (`roots.web-signin`).
313 let push_shaped = sign_in_namespace(&key, "git", payload.as_bytes());
314 assert!(!verify_login(
315 &public_line(&key),
316 payload.as_bytes(),
317 &push_shaped
318 ));
319
320 // Any drift in the signed payload — another host, another code,
321 // another nonce — fails verification.
322 let other = challenge_payload("evil.example", "ABCD2345", "0011");
323 assert!(!verify_login(&public_line(&key), other.as_bytes(), &good));
324
325 // Another member's key does not verify this member's signature.
326 assert!(!verify_login(
327 &public_line(&keypair(2)),
328 payload.as_bytes(),
329 &good
330 ));
331
332 // Garbage inputs are false, never a panic.
333 assert!(!verify_login("not a key", payload.as_bytes(), &good));
334 assert!(!verify_login(
335 &public_line(&key),
336 payload.as_bytes(),
337 "not a signature"
338 ));
339 }
340}