Skip to main content

krabka_security/scram/
pg_verifier.rs

1//! `PostgreSQL` `pg_authid` SCRAM-SHA-256 verifier codec.
2
3use std::{fmt, str::FromStr};
4
5use base64::{Engine, engine::general_purpose::STANDARD as B64};
6use thiserror::Error;
7
8use super::{ScramCredential, hash_scram_password, hash_scram_password_with_salt};
9use crate::SaslMechanism;
10
11const POSTGRES_SCRAM_SHA256_PREFIX: &str = "SCRAM-SHA-256";
12const SHA256_KEY_LEN: usize = 32;
13
14/// A `PostgreSQL` `pg_authid.rolpassword` SCRAM-SHA-256 verifier.
15///
16/// The textual form is
17/// `SCRAM-SHA-256$<iterations>:<salt_b64>$<stored_key_b64>:<server_key_b64>`.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PgScramVerifier {
20    /// PBKDF2 iteration count.
21    pub iterations: u32,
22    /// SCRAM salt bytes.
23    pub salt: Vec<u8>,
24    /// SHA-256 stored key bytes.
25    pub stored_key: Vec<u8>,
26    /// SHA-256 server key bytes.
27    pub server_key: Vec<u8>,
28}
29
30/// Errors returned by the `PostgreSQL` SCRAM verifier codec.
31#[derive(Debug, Error, Clone, PartialEq, Eq)]
32pub enum ScramError {
33    /// The verifier did not match `PostgreSQL`'s SCRAM-SHA-256 shape.
34    #[error("malformed PostgreSQL SCRAM verifier")]
35    MalformedVerifier,
36    /// The verifier names anything other than SCRAM-SHA-256.
37    #[error("unsupported SCRAM verifier mechanism")]
38    UnsupportedMechanism,
39    /// The verifier has a zero iteration count.
40    #[error("SCRAM iteration count must be greater than zero")]
41    InvalidIterations,
42    /// The verifier's salt decoded to an empty byte string.
43    #[error("SCRAM salt must not be empty")]
44    EmptySalt,
45    /// One of the verifier's base64 fields is not valid standard base64.
46    #[error("invalid base64 in SCRAM verifier")]
47    InvalidBase64,
48    /// One of the verifier's SHA-256 keys decoded to the wrong length.
49    #[error("invalid {field} length: expected {expected} bytes, got {actual}")]
50    InvalidKeyLength {
51        /// Field with the wrong length.
52        field: &'static str,
53        /// Required byte length.
54        expected: usize,
55        /// Actual decoded byte length.
56        actual: usize,
57    },
58    /// The system random number generator could not produce a salt.
59    #[error("system random number generator unavailable")]
60    RandomUnavailable,
61}
62
63impl PgScramVerifier {
64    /// Generate a fresh `PostgreSQL` SCRAM-SHA-256 verifier for `password`.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`ScramError::InvalidIterations`] when `iterations` is zero,
69    /// and [`ScramError::RandomUnavailable`] when the system RNG cannot
70    /// produce a salt.
71    pub fn generate(password: &str, iterations: u32) -> Result<Self, ScramError> {
72        if iterations == 0 {
73            return Err(ScramError::InvalidIterations);
74        }
75
76        let credential =
77            hash_scram_password(password.as_bytes(), SaslMechanism::ScramSha256, iterations)?;
78        Ok(Self::from_sha256_credential(credential))
79    }
80
81    /// Generate a deterministic `PostgreSQL` SCRAM-SHA-256 verifier with `salt`.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error when `iterations` is zero or `salt` is empty.
86    pub fn generate_with_salt(
87        password: &str,
88        iterations: u32,
89        salt: Vec<u8>,
90    ) -> Result<Self, ScramError> {
91        if iterations == 0 {
92            return Err(ScramError::InvalidIterations);
93        }
94        if salt.is_empty() {
95            return Err(ScramError::EmptySalt);
96        }
97
98        let credential = hash_scram_password_with_salt(
99            password.as_bytes(),
100            SaslMechanism::ScramSha256,
101            iterations,
102            salt,
103        );
104        Ok(Self::from_sha256_credential(credential))
105    }
106
107    /// Parse a `PostgreSQL` `pg_authid.rolpassword` SCRAM-SHA-256 verifier.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error when the verifier is malformed, uses an unsupported
112    /// mechanism, or contains invalid iteration, salt, or key fields.
113    pub fn parse(verifier: &str) -> Result<Self, ScramError> {
114        verifier.parse()
115    }
116
117    fn parse_fields(verifier: &str) -> Result<Self, ScramError> {
118        let (prefix, rest) = verifier
119            .split_once('$')
120            .ok_or(ScramError::MalformedVerifier)?;
121        if prefix != POSTGRES_SCRAM_SHA256_PREFIX {
122            return Err(ScramError::UnsupportedMechanism);
123        }
124
125        let (iterations_and_salt, keys) =
126            rest.split_once('$').ok_or(ScramError::MalformedVerifier)?;
127        if keys.contains('$') {
128            return Err(ScramError::MalformedVerifier);
129        }
130
131        let (iterations, salt) = parse_iterations_and_salt(iterations_and_salt)?;
132        let (stored_key, server_key) = parse_key_pair(keys)?;
133
134        Ok(Self {
135            iterations,
136            salt,
137            stored_key,
138            server_key,
139        })
140    }
141
142    fn from_sha256_credential(credential: ScramCredential) -> Self {
143        debug_assert_eq!(credential.mechanism, SaslMechanism::ScramSha256);
144        Self {
145            iterations: credential.iterations,
146            salt: credential.salt,
147            stored_key: credential.stored_key,
148            server_key: credential.server_key,
149        }
150    }
151}
152
153impl fmt::Display for PgScramVerifier {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(
156            f,
157            "{POSTGRES_SCRAM_SHA256_PREFIX}${}:{}${}:{}",
158            self.iterations,
159            B64.encode(&self.salt),
160            B64.encode(&self.stored_key),
161            B64.encode(&self.server_key)
162        )
163    }
164}
165
166impl FromStr for PgScramVerifier {
167    type Err = ScramError;
168
169    fn from_str(verifier: &str) -> Result<Self, Self::Err> {
170        Self::parse_fields(verifier)
171    }
172}
173
174impl From<PgScramVerifier> for ScramCredential {
175    fn from(verifier: PgScramVerifier) -> Self {
176        Self {
177            mechanism: SaslMechanism::ScramSha256,
178            salt: verifier.salt,
179            stored_key: verifier.stored_key,
180            server_key: verifier.server_key,
181            iterations: verifier.iterations,
182        }
183    }
184}
185
186impl From<&PgScramVerifier> for ScramCredential {
187    fn from(verifier: &PgScramVerifier) -> Self {
188        Self {
189            mechanism: SaslMechanism::ScramSha256,
190            salt: verifier.salt.clone(),
191            stored_key: verifier.stored_key.clone(),
192            server_key: verifier.server_key.clone(),
193            iterations: verifier.iterations,
194        }
195    }
196}
197
198fn parse_iterations_and_salt(input: &str) -> Result<(u32, Vec<u8>), ScramError> {
199    let (iterations, salt_b64) = input.split_once(':').ok_or(ScramError::MalformedVerifier)?;
200    if salt_b64.contains(':') {
201        return Err(ScramError::MalformedVerifier);
202    }
203
204    let iterations = iterations
205        .parse::<u32>()
206        .map_err(|_| ScramError::MalformedVerifier)?;
207    if iterations == 0 {
208        return Err(ScramError::InvalidIterations);
209    }
210
211    let salt = B64
212        .decode(salt_b64)
213        .map_err(|_| ScramError::InvalidBase64)?;
214    if salt.is_empty() {
215        return Err(ScramError::EmptySalt);
216    }
217
218    Ok((iterations, salt))
219}
220
221fn parse_key_pair(input: &str) -> Result<(Vec<u8>, Vec<u8>), ScramError> {
222    let (stored_key_b64, server_key_b64) =
223        input.split_once(':').ok_or(ScramError::MalformedVerifier)?;
224    if server_key_b64.contains(':') {
225        return Err(ScramError::MalformedVerifier);
226    }
227
228    let stored_key = B64
229        .decode(stored_key_b64)
230        .map_err(|_| ScramError::InvalidBase64)?;
231    let server_key = B64
232        .decode(server_key_b64)
233        .map_err(|_| ScramError::InvalidBase64)?;
234    require_key_len("stored_key", &stored_key)?;
235    require_key_len("server_key", &server_key)?;
236
237    Ok((stored_key, server_key))
238}
239
240fn require_key_len(field: &'static str, key: &[u8]) -> Result<(), ScramError> {
241    if key.len() == SHA256_KEY_LEN {
242        return Ok(());
243    }
244
245    Err(ScramError::InvalidKeyLength {
246        field,
247        expected: SHA256_KEY_LEN,
248        actual: key.len(),
249    })
250}
251
252#[cfg(test)]
253mod tests {
254    use std::process;
255
256    use assert2::{assert, check};
257
258    use super::*;
259    use crate::scram::{ScramClientExchange, ScramServerExchange, StepResult};
260
261    const FIXED_SALT: &[u8] = b"0123456789abcdef";
262
263    fn fixture_password() -> String {
264        process::id().to_string()
265    }
266
267    /// The password `PostgreSQL` 18 hashed to produce the golden verifier below.
268    ///
269    /// Embedded rather than read at run time: it is a constant, and a test
270    /// binary that resolves `CARGO_MANIFEST_DIR` at run time only works when it
271    /// is launched from the same absolute path it was compiled in.
272    fn pg_scram_golden_password() -> &'static str {
273        include_str!("../../tests/fixtures/pg_scram_password.txt").trim_end()
274    }
275
276    #[test]
277    fn parse_display_roundtrip_emits_canonical_verifier() {
278        let verifier =
279            PgScramVerifier::generate_with_salt(&fixture_password(), 4096, FIXED_SALT.to_vec())
280                .expect("fixed salt verifier");
281
282        let rendered = verifier.to_string();
283        let parsed = PgScramVerifier::parse(&rendered).expect("rendered verifier parses");
284
285        assert!(parsed == verifier);
286        assert!(parsed.to_string() == rendered);
287    }
288
289    #[test]
290    fn deterministic_generation_with_fixed_salt_matches_known_sha256_material() {
291        let verifier = PgScramVerifier::generate_with_salt(
292            pg_scram_golden_password(),
293            4096,
294            (0_u8..16).collect::<Vec<_>>(),
295        )
296        .expect("fixed salt verifier");
297
298        check!(verifier.iterations == 4096);
299        check!(verifier.salt == (0_u8..16).collect::<Vec<_>>());
300        check!(B64.encode(&verifier.stored_key) == "ijmdkBF6VaUuRpEzxe8P8NyZKzuHGkoluJBVZ5DF5+Q=");
301        check!(B64.encode(&verifier.server_key) == "naMMD2q/yKfDcTgfNhpZqsbkzoRQwOPfT/TgHEPWP7w=");
302        assert!(
303            verifier.to_string()
304                == "SCRAM-SHA-256$4096:AAECAwQFBgcICQoLDA0ODw==$ijmdkBF6VaUuRpEzxe8P8NyZKzuHGkoluJBVZ5DF5+Q=:naMMD2q/yKfDcTgfNhpZqsbkzoRQwOPfT/TgHEPWP7w="
305        );
306    }
307
308    #[test]
309    fn rejects_malformed_verifier_table() {
310        let wrong_stored_key = B64.encode([0_u8; SHA256_KEY_LEN - 1]);
311        let wrong_server_key = B64.encode([0_u8; SHA256_KEY_LEN + 1]);
312        let valid_server_key = B64.encode([1_u8; SHA256_KEY_LEN]);
313
314        for (input, expected) in [
315            ("", ScramError::MalformedVerifier),
316            (
317                "SCRAM-SHA-512$4096:c2FsdA==$a:b",
318                ScramError::UnsupportedMechanism,
319            ),
320            ("md5deadbeef", ScramError::MalformedVerifier),
321            ("SCRAM-SHA-256", ScramError::MalformedVerifier),
322            ("SCRAM-SHA-256$4096:c2FsdA==", ScramError::MalformedVerifier),
323            (
324                "SCRAM-SHA-256$4096:c2FsdA==$AAAA:AAAA$extra",
325                ScramError::MalformedVerifier,
326            ),
327            (
328                "SCRAM-SHA-256$4096:c2FsdA==:extra$AAAA:AAAA",
329                ScramError::MalformedVerifier,
330            ),
331            (
332                "SCRAM-SHA-256$4096:c2FsdA==$AAAA:AAAA:extra",
333                ScramError::MalformedVerifier,
334            ),
335            (
336                "SCRAM-SHA-256$not-a-number:c2FsdA==$AAAA:AAAA",
337                ScramError::MalformedVerifier,
338            ),
339            (
340                "SCRAM-SHA-256$0:c2FsdA==$AAAA:AAAA",
341                ScramError::InvalidIterations,
342            ),
343            ("SCRAM-SHA-256$4096:*$AAAA:AAAA", ScramError::InvalidBase64),
344            ("SCRAM-SHA-256$4096:$AAAA:AAAA", ScramError::EmptySalt),
345            (
346                "SCRAM-SHA-256$4096:c2FsdA==$*:AAAA",
347                ScramError::InvalidBase64,
348            ),
349            (
350                "SCRAM-SHA-256$4096:c2FsdA==$AAAA:*",
351                ScramError::InvalidBase64,
352            ),
353            (
354                &format!("SCRAM-SHA-256$4096:c2FsdA==${wrong_stored_key}:{valid_server_key}"),
355                ScramError::InvalidKeyLength {
356                    field: "stored_key",
357                    expected: SHA256_KEY_LEN,
358                    actual: SHA256_KEY_LEN - 1,
359                },
360            ),
361            (
362                &format!("SCRAM-SHA-256$4096:c2FsdA==${valid_server_key}:{wrong_server_key}"),
363                ScramError::InvalidKeyLength {
364                    field: "server_key",
365                    expected: SHA256_KEY_LEN,
366                    actual: SHA256_KEY_LEN + 1,
367                },
368            ),
369        ] {
370            assert!(
371                PgScramVerifier::parse(input) == Err(expected),
372                "case {input}"
373            );
374        }
375    }
376
377    #[test]
378    fn parses_postgresql_format_fixture() {
379        // PostgreSQL `pg_authid.rolpassword` shape for SCRAM-SHA-256 with
380        // password "hunter2", 4096 iterations, and a fixed 16-byte salt.
381        let fixture = "SCRAM-SHA-256$4096:AAECAwQFBgcICQoLDA0ODw==$ijmdkBF6VaUuRpEzxe8P8NyZKzuHGkoluJBVZ5DF5+Q=:naMMD2q/yKfDcTgfNhpZqsbkzoRQwOPfT/TgHEPWP7w=";
382
383        let verifier = PgScramVerifier::parse(fixture).expect("PostgreSQL verifier parses");
384
385        check!(verifier.iterations == 4096);
386        check!(verifier.salt == (0_u8..16).collect::<Vec<_>>());
387        check!(verifier.stored_key.len() == SHA256_KEY_LEN);
388        check!(verifier.server_key.len() == SHA256_KEY_LEN);
389        assert!(verifier.to_string() == fixture);
390    }
391
392    #[test]
393    fn generated_verifier_material_authenticates_scram_sha256_exchange() {
394        let password = fixture_password();
395        let verifier = PgScramVerifier::generate(&password, 4096).expect("fresh verifier");
396        let credential = ScramCredential::from(&verifier);
397        let server = ScramServerExchange::new("alice".to_owned(), credential);
398        let client = ScramClientExchange::new(
399            "alice".to_owned(),
400            password.into_bytes(),
401            SaslMechanism::ScramSha256,
402        );
403
404        let (client_first, client) = client.client_first().expect("client first");
405        let (server_first, server) = match server.step(&client_first) {
406            StepResult::Continue(bytes, exchange) => (bytes, exchange),
407            other => panic!("server first must continue, got {other:?}"),
408        };
409        let (client_final, client) = client.step(&server_first).expect("client final");
410        let (principal, server_final) = match server.step(&client_final) {
411            StepResult::Done(principal, bytes) => (principal, bytes),
412            other => panic!("server final must finish, got {other:?}"),
413        };
414
415        check!(principal.name == "alice");
416        check!(principal.auth_method == crate::AuthMethod::SaslScramSha256);
417        assert!(client.verify_server_final(&server_final).is_ok());
418    }
419}