Skip to main content

tuwunel_core/utils/hash/
argon.rs

1use std::fmt::Display;
2
3use argon2::{
4	Algorithm, Argon2, Error as Argon2Error, Params, PasswordHasher, PasswordVerifier, Version,
5	password_hash::phc::Salt,
6};
7use rand::random;
8use smallstr::SmallString;
9
10use crate::{Error, Result, err, format_small_string, implement};
11
12/// A PHC-formatted Argon2id password hash.
13///
14/// The inline budget fits the 97 bytes a hash takes at any OWASP recommended
15/// cost, so a hash spills to the heap only past an unusually large `m_cost`.
16pub type PhcString = SmallString<[u8; 112]>;
17
18/// Argon2id cost parameters for hashing a new password.
19///
20/// Every hash records the values it was produced with, so a change takes
21/// effect on new hashes only. Each hashing operation holds `m_cost` KiB for
22/// its duration, which dominates the server's cost when several run at once.
23#[derive(Clone, Copy, Debug)]
24pub struct Cost {
25	/// Size of the working buffer, in 1 KiB blocks.
26	pub m_cost: u32,
27
28	/// Number of passes made over the working buffer.
29	pub t_cost: u32,
30
31	/// Number of lanes the working buffer is divided into.
32	pub p_cost: u32,
33}
34
35/// Hashes a plaintext password with Argon2id at the given cost.
36///
37/// A fresh random salt is generated for every call. The result is a
38/// PHC-formatted string containing the salt and parameters needed for
39/// verification.
40pub fn password(password: &str, cost: Cost) -> Result<PhcString> {
41	let salt: [u8; Salt::RECOMMENDED_LENGTH] = random();
42
43	hasher(cost)
44		.map_err(map_err)?
45		.hash_password_with_salt(password.as_bytes(), &salt)
46		.map(|hash| format_small_string!("{hash}"))
47		.map_err(map_err)
48}
49
50/// Verifies a plaintext password against an encoded Argon2 password hash.
51///
52/// Cost parameters come from the encoded hash, so a hash written under any
53/// cost still verifies. Malformed hashes and password mismatches return an
54/// error.
55pub fn verify_password(password: &str, password_hash: &str) -> Result {
56	Argon2::default()
57		.verify_password(password.as_bytes(), password_hash)
58		.map_err(map_err)
59}
60
61/// Rejects a cost Argon2id cannot accept.
62///
63/// The parameters are interdependent: `m_cost` has a floor of eight blocks and
64/// must be at least eight times `p_cost`, and `t_cost` has a floor of one. The
65/// crate's error names the parameter at fault.
66#[implement(Cost)]
67pub fn check(self) -> Result<(), Argon2Error> { hasher(self).map(|_| ()) }
68
69/// Whether the cost is at least as strong as the weakest OWASP Argon2id
70/// recommendation.
71///
72/// The recommended pairs of memory and passes run from (47104, 1) down to
73/// (7168, 5) at a roughly constant product, so a cost qualifies when its
74/// memory is at least 7168 blocks and its memory times its passes at least
75/// matches that last pair. Lanes are left out, since `p_cost` divides that
76/// work rather than adding to it.
77#[implement(Cost)]
78#[must_use]
79pub fn is_recommended(self) -> bool {
80	const MIN_M_COST: u64 = 7168;
81	const MIN_PRODUCT: u64 = MIN_M_COST * 5;
82
83	let m_cost = u64::from(self.m_cost);
84	let product = m_cost.saturating_mul(u64::from(self.t_cost));
85
86	m_cost >= MIN_M_COST && product >= MIN_PRODUCT
87}
88
89fn hasher(Cost { m_cost, t_cost, p_cost }: Cost) -> Result<Argon2<'static>, Argon2Error> {
90	let out_len: Option<usize> = None;
91
92	Params::new(m_cost, t_cost, p_cost, out_len)
93		.map(|params| Argon2::new(Algorithm::Argon2id, Version::default(), params))
94}
95
96fn map_err<E: Display>(e: E) -> Error { err!("{e}") }
97
98#[cfg(test)]
99mod tests {
100	use argon2::Params;
101
102	use super::{Cost, password, verify_password};
103
104	// The OWASP cost the shipped configuration also defaults to.
105	const COST: Cost = Cost {
106		m_cost: Params::DEFAULT_M_COST,
107		t_cost: Params::DEFAULT_T_COST,
108		p_cost: Params::DEFAULT_P_COST,
109	};
110
111	#[test]
112	fn password_hash_and_verify() {
113		let preimage = "temp123";
114		let digest = password(preimage, COST).expect("digest");
115
116		verify_password(preimage, &digest).expect("verified");
117	}
118
119	#[test]
120	fn the_owasp_pairs_are_recommended_and_weaker_costs_are_not() {
121		let recommended = [(47104, 1), (19456, 2), (12288, 3), (9216, 4), (7168, 5)];
122		let weaker = [(19456, 1), (7168, 4), (4096, 9), (64, 1)];
123		let cost = |(m_cost, t_cost)| Cost { m_cost, t_cost, p_cost: 1 };
124
125		for pair in recommended {
126			assert!(cost(pair).is_recommended(), "{pair:?}");
127		}
128
129		for pair in weaker {
130			assert!(!cost(pair).is_recommended(), "{pair:?}");
131		}
132	}
133
134	#[test]
135	#[should_panic(expected = "unverified")]
136	fn password_hash_and_verify_fail() {
137		let preimage = "temp123";
138		let fakeimage = "temp321";
139		let digest = password(preimage, COST).expect("digest");
140
141		verify_password(fakeimage, &digest).expect("unverified");
142	}
143}