Skip to main content

mbr_core/
edit_auth.rs

1//! Authentication and hashing helpers for the in-browser editing endpoints.
2//!
3//! Editing is gated by a pre-shared bearer token. The token is never stored;
4//! only an Argon2 PHC hash of it lives in `.mbr/config.toml`
5//! (`edit_token_hash`). `verify_token` compares a presented token against that
6//! hash in constant time (Argon2's verifier).
7//!
8//! [`content_hash`] provides the SHA-256 used for optimistic-concurrency
9//! checks on the raw-markdown / save endpoints.
10
11use argon2::{
12    Argon2,
13    password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
14};
15use sha2::{Digest, Sha256};
16
17/// Hashes a token/password into an Argon2 PHC string (includes a random salt).
18///
19/// The returned string is what belongs in `edit_token_hash`.
20pub fn hash_token(token: &str) -> Result<String, argon2::password_hash::Error> {
21    // `hash_password` draws a recommended-length random salt from the OS RNG.
22    let hash = Argon2::default().hash_password(token.as_bytes())?;
23    Ok(hash.to_string())
24}
25
26/// Verifies a presented token against a stored Argon2 PHC hash.
27///
28/// Returns `false` for any parse or verification failure (never panics), so a
29/// malformed configured hash simply denies access rather than crashing.
30pub fn verify_token(hash: &str, token: &str) -> bool {
31    match PasswordHash::new(hash) {
32        Ok(parsed) => Argon2::default()
33            .verify_password(token.as_bytes(), &parsed)
34            .is_ok(),
35        Err(_) => false,
36    }
37}
38
39/// Generates a random 32-byte token, hex-encoded (64 chars).
40///
41/// Used by `--generate-edit-token` when the user does not supply a password.
42pub fn generate_token() -> String {
43    let mut bytes = [0u8; 32];
44    // Same failure mode as the `OsRng::fill_bytes` this replaced: an OS RNG
45    // that cannot produce bytes is unrecoverable for a CLI token generator.
46    getrandom::fill(&mut bytes).expect("OS random number generator failed");
47    to_hex(&bytes)
48}
49
50/// SHA-256 of the given bytes, hex-encoded. Used for optimistic-concurrency
51/// hashes exchanged with the editor client (`X-MBR-Content-Hash`).
52pub fn content_hash(bytes: &[u8]) -> String {
53    let digest = Sha256::digest(bytes);
54    to_hex(&digest)
55}
56
57fn to_hex(bytes: &[u8]) -> String {
58    use std::fmt::Write;
59    bytes
60        .iter()
61        .fold(String::with_capacity(bytes.len() * 2), |mut acc, b| {
62            let _ = write!(acc, "{b:02x}");
63            acc
64        })
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn hash_and_verify_roundtrip() {
73        let hash = hash_token("correct horse battery staple").unwrap();
74        assert!(verify_token(&hash, "correct horse battery staple"));
75    }
76
77    #[test]
78    fn verify_rejects_wrong_token() {
79        let hash = hash_token("s3cret").unwrap();
80        assert!(!verify_token(&hash, "not-the-token"));
81    }
82
83    /// An `edit_token_hash` already sitting in someone's `.mbr/config.toml` must
84    /// keep verifying across argon2 upgrades. This one was produced by the
85    /// reference C implementation (`argon2 mbrsaltmbrsalt -id -t 2 -k 19456 -p 1
86    /// -l 32 -e`), with the same default parameters `hash_token` has always used.
87    #[test]
88    fn verify_accepts_existing_reference_hash() {
89        let hash = "$argon2id$v=19$m=19456,t=2,p=1$bWJyc2FsdG1icnNhbHQ$qZe0wJsZ+QggSV7lpWfHIqHwB1yscboSULscxsCHiCQ";
90        assert!(verify_token(hash, "correct horse battery staple"));
91        assert!(!verify_token(hash, "correct horse battery stapler"));
92    }
93
94    #[test]
95    fn hash_token_uses_default_argon2id_params() {
96        let hash = hash_token("s3cret").unwrap();
97        assert!(
98            hash.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"),
99            "unexpected PHC prefix: {hash}"
100        );
101    }
102
103    #[test]
104    fn verify_rejects_malformed_hash() {
105        assert!(!verify_token("not-a-phc-string", "whatever"));
106        assert!(!verify_token("", "whatever"));
107    }
108
109    #[test]
110    fn generated_tokens_are_hex_and_unique() {
111        let a = generate_token();
112        let b = generate_token();
113        assert_eq!(a.len(), 64);
114        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
115        assert_ne!(a, b);
116    }
117
118    #[test]
119    fn content_hash_is_stable_and_sensitive() {
120        assert_eq!(content_hash(b"hello"), content_hash(b"hello"));
121        assert_ne!(content_hash(b"hello"), content_hash(b"hello!"));
122        // Known SHA-256 of the empty input.
123        assert_eq!(
124            content_hash(b""),
125            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
126        );
127    }
128}