Skip to main content

spg_engine/
users.rs

1//! User table + RBAC types for v4.1.
2//!
3//! Three roles, narrow on purpose:
4//!
5//! - `Admin` — full read+write + can manage other users
6//! - `ReadWrite` — full read+write, no user-mgmt
7//! - `ReadOnly` — SELECT / SHOW only
8//!
9//! Passwords stored as BLAKE3(salt || password) — the salt is a
10//! random 16-byte value per user, kept inline with the record so we
11//! never need to hash twice. The hash is not designed to resist a
12//! determined offline attack on the snapshot file (that's what file
13//! perms are for in the docker-compose deployment shape); it's
14//! enough that the snapshot itself doesn't leak plaintext, and that
15//! an in-memory dump can't trivially reverse a typed password.
16
17use alloc::collections::{BTreeMap, BTreeSet};
18use alloc::string::{String, ToString};
19use alloc::vec::Vec;
20
21use spg_storage::{ColumnSchema, DataType, Row, Value};
22
23use crate::{Engine, QueryResult};
24
25const SALT_LEN: usize = 16;
26const HASH_LEN: usize = 32;
27/// v7.17.0 Phase 3.P0-71 — length of SHA1(SHA1(password)) stored
28/// per user for `mysql_native_password` auth verification.
29pub const MYSQL_NATIVE_HASH_LEN: usize = 20;
30/// v7.17.0 Phase 3.P0-72 — length of SHA256(SHA256(password))
31/// stored per user for `caching_sha2_password` auth
32/// verification (the MySQL 8.0 default plugin).
33pub const CACHING_SHA2_HASH_LEN: usize = 32;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Role {
37    Admin,
38    ReadWrite,
39    ReadOnly,
40}
41
42impl Role {
43    pub const fn as_str(self) -> &'static str {
44        match self {
45            Self::Admin => "admin",
46            Self::ReadWrite => "readwrite",
47            Self::ReadOnly => "readonly",
48        }
49    }
50
51    pub fn parse(s: &str) -> Option<Self> {
52        match s.to_ascii_lowercase().as_str() {
53            "admin" => Some(Self::Admin),
54            "readwrite" | "rw" => Some(Self::ReadWrite),
55            "readonly" | "ro" => Some(Self::ReadOnly),
56            _ => None,
57        }
58    }
59
60    /// Read access — every role qualifies.
61    pub const fn can_read(self) -> bool {
62        true
63    }
64
65    /// Write access (INSERT / DDL on user tables).
66    pub const fn can_write(self) -> bool {
67        matches!(self, Self::Admin | Self::ReadWrite)
68    }
69
70    /// User-management DDL (`CREATE USER`, `DROP USER`).
71    pub const fn can_manage_users(self) -> bool {
72        matches!(self, Self::Admin)
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct UserRecord {
78    pub role: Role,
79    salt: [u8; SALT_LEN],
80    hash: [u8; HASH_LEN],
81    /// v4.8: SCRAM-SHA-256 verifier. Computed alongside the
82    /// BLAKE3 hash at user creation so PG-wire SASL auth can
83    /// verify without re-running PBKDF2 per attempt. `None`
84    /// means the user predates v4.8 (loaded from an older
85    /// snapshot); the PG-wire layer falls back to
86    /// `CleartextPassword` for those users.
87    scram: Option<ScramSecrets>,
88    /// v7.17.0 Phase 3.P0-71: SHA1(SHA1(password)) — the
89    /// `mysql.user.authentication_string` shape for the
90    /// `mysql_native_password` plugin. Computed at create /
91    /// set_password time alongside the BLAKE3 hash and the
92    /// SCRAM verifier so the MySQL-wire shim doesn't need
93    /// plaintext to verify. `None` for users loaded from a
94    /// pre-v7.17.0 snapshot — the MySQL-wire shim rejects
95    /// those with Access Denied until the password is reset.
96    mysql_native: Option<[u8; MYSQL_NATIVE_HASH_LEN]>,
97    /// v7.17.0 Phase 3.P0-72: SHA256(SHA256(password)) — the
98    /// `mysql.user.authentication_string` shape for MySQL 8.0+'s
99    /// default `caching_sha2_password` plugin. Computed at the
100    /// same time as `mysql_native`. The MySQL-wire shim's fast
101    /// path uses this for the SHA256-XOR proof verification —
102    /// the public-key-RSA full-auth path is a v7.18 carve-out.
103    caching_sha2: Option<[u8; CACHING_SHA2_HASH_LEN]>,
104    /// v7.39 (read01 round 58) — PG role attributes. `CREATE USER` is PG's
105    /// `CREATE ROLE … LOGIN`; `CREATE ROLE` alone cannot log in but can hold
106    /// privileges and have members. Attributes are NEVER inherited through
107    /// membership (that is PG's rule: only privileges flow, not attributes) —
108    /// a member of a superuser role must `SET ROLE` to it to act as one.
109    pub can_login: bool,
110    /// v7.39 (round 548) — was a password actually DECLARED for this
111    /// role?
112    ///
113    /// A bare `CREATE ROLE devs` gets an unguessable credential derived
114    /// from its own salt (so no record ever carries an empty password),
115    /// which made "has a hash" useless for telling a real account from
116    /// a group role. The wire's open-vs-authenticated decision needs
117    /// that distinction: it used to arm on ANY role existing, so
118    /// creating a NOLOGIN group role locked the operator out of their
119    /// own database — `postgres` has no password, so nothing could
120    /// connect afterwards and there was no way back through SQL.
121    pub password_declared: bool,
122    /// `INHERIT` (the default): this role automatically holds the privileges of
123    /// every role it is a member of. `NOINHERIT` means it must `SET ROLE` to
124    /// them explicitly.
125    pub inherit: bool,
126    /// `SUPERUSER`: bypasses every privilege check.
127    pub superuser: bool,
128}
129
130/// SCRAM-SHA-256 stored credentials per RFC 5802 §5.
131/// `salt` and `iters` are sent to the client in server-first;
132/// `stored_key` and `server_key` are kept secret and used in the
133/// final-message verification.
134#[derive(Debug, Clone)]
135pub struct ScramSecrets {
136    pub iters: u32,
137    pub salt: [u8; SCRAM_SALT_LEN],
138    pub stored_key: [u8; HASH_LEN],
139    pub server_key: [u8; HASH_LEN],
140}
141
142pub const SCRAM_SALT_LEN: usize = 16;
143pub const SCRAM_DEFAULT_ITERS: u32 = 4096;
144
145impl UserRecord {
146    pub fn verify(&self, password: &str) -> bool {
147        let candidate = derive_hash(&self.salt, password);
148        constant_time_eq(&candidate, &self.hash)
149    }
150
151    /// v7.39 (round 548) — can this role be authenticated at all?
152    ///
153    /// A `CREATE ROLE devs NOLOGIN` records no password and cannot log
154    /// in. It used to count toward "this server has users, so demand a
155    /// password from everybody", which locked the operator out of their
156    /// own database: `postgres` has no password, so after creating a
157    /// group role nothing could connect and there was no way back
158    /// through SQL.
159    #[must_use]
160    pub fn has_credentials(&self) -> bool {
161        self.can_login && self.password_declared
162    }
163
164    pub const fn scram(&self) -> Option<&ScramSecrets> {
165        self.scram.as_ref()
166    }
167
168    /// v7.17.0 Phase 3.P0-71: borrow the stored
169    /// `mysql_native_password` verifier (SHA1(SHA1(password)))
170    /// for the MySQL-wire shim.
171    pub const fn mysql_native(&self) -> Option<&[u8; MYSQL_NATIVE_HASH_LEN]> {
172        self.mysql_native.as_ref()
173    }
174
175    /// v7.17.0 Phase 3.P0-72: borrow the stored
176    /// `caching_sha2_password` verifier (SHA256(SHA256(password)))
177    /// for the MySQL-wire shim's fast-path auth.
178    pub const fn caching_sha2(&self) -> Option<&[u8; CACHING_SHA2_HASH_LEN]> {
179        self.caching_sha2.as_ref()
180    }
181
182    /// v7.17.0 Phase 3.P0-72 — verify a client
183    /// `caching_sha2_password` fast-path response.
184    ///
185    /// Protocol: same XOR shape as `mysql_native_password` but
186    /// with SHA-256 instead of SHA-1:
187    /// `client_response = SHA256(password) XOR SHA256(scramble
188    /// || SHA256(SHA256(password)))`. Server reconstructs and
189    /// checks `SHA256(reconstructed) == stored_hash`.
190    ///
191    /// Full-auth RSA fallback (when the cache misses) is a
192    /// v7.18 carve-out — clients connecting over plaintext
193    /// without a cached entry will see Access Denied from the
194    /// shim until that lands.
195    pub fn verify_caching_sha2_password(&self, scramble: &[u8], client_response: &[u8]) -> bool {
196        let Some(stored) = self.caching_sha2 else {
197            return false;
198        };
199        if client_response.len() != CACHING_SHA2_HASH_LEN {
200            return false;
201        }
202        if scramble.len() != 20 {
203            return false;
204        }
205        let mut buf = [0u8; 20 + CACHING_SHA2_HASH_LEN];
206        buf[..20].copy_from_slice(scramble);
207        buf[20..].copy_from_slice(&stored);
208        let mask = sha256_bytes(&buf);
209        let mut recovered = [0u8; CACHING_SHA2_HASH_LEN];
210        for i in 0..CACHING_SHA2_HASH_LEN {
211            recovered[i] = client_response[i] ^ mask[i];
212        }
213        let candidate = sha256_bytes(&recovered);
214        constant_time_eq(&candidate, &stored)
215    }
216
217    /// v7.17.0 Phase 3.P0-71 — verify a client
218    /// `mysql_native_password` auth response.
219    ///
220    /// Protocol: the client sends a 20-byte response
221    /// `client_proof = SHA1(password) XOR SHA1(scramble ||
222    /// SHA1(SHA1(password)))`. The server reconstructs
223    /// `SHA1(password) = client_proof XOR SHA1(scramble ||
224    /// stored_hash)` and verifies `SHA1(reconstructed) ==
225    /// stored_hash`. Returns false if the user has no stored
226    /// hash (loaded from a pre-v7.17 snapshot — the operator
227    /// has to reset the password to re-populate it).
228    pub fn verify_mysql_native_password(&self, scramble: &[u8], client_response: &[u8]) -> bool {
229        let Some(stored) = self.mysql_native else {
230            return false;
231        };
232        if client_response.len() != MYSQL_NATIVE_HASH_LEN {
233            return false;
234        }
235        if scramble.len() != 20 {
236            return false;
237        }
238        let mut buf = [0u8; 40];
239        buf[..20].copy_from_slice(scramble);
240        buf[20..].copy_from_slice(&stored);
241        let mask = sha1_bytes(&buf);
242        let mut recovered = [0u8; MYSQL_NATIVE_HASH_LEN];
243        for i in 0..MYSQL_NATIVE_HASH_LEN {
244            recovered[i] = client_response[i] ^ mask[i];
245        }
246        let candidate = sha1_bytes(&recovered);
247        constant_time_eq_sha1(&candidate, &stored)
248    }
249}
250
251/// Compute the `mysql_native_password` stored hash =
252/// SHA1(SHA1(password)). Public so user-creation paths can
253/// populate the field at the same moment they have cleartext.
254#[must_use]
255pub fn compute_mysql_native_hash(password: &str) -> [u8; MYSQL_NATIVE_HASH_LEN] {
256    let inner = sha1_bytes(password.as_bytes());
257    sha1_bytes(&inner)
258}
259
260/// v7.17.0 Phase 3.P0-72 — compute the `caching_sha2_password`
261/// stored hash = SHA256(SHA256(password)). Public for the same
262/// reason as the mysql_native variant.
263#[must_use]
264pub fn compute_caching_sha2_hash(password: &str) -> [u8; CACHING_SHA2_HASH_LEN] {
265    let inner = sha256_bytes(password.as_bytes());
266    sha256_bytes(&inner)
267}
268
269fn sha1_bytes(input: &[u8]) -> [u8; MYSQL_NATIVE_HASH_LEN] {
270    use sha1::Digest;
271    let digest = sha1::Sha1::digest(input);
272    let mut out = [0u8; MYSQL_NATIVE_HASH_LEN];
273    out.copy_from_slice(&digest);
274    out
275}
276
277fn sha256_bytes(input: &[u8]) -> [u8; CACHING_SHA2_HASH_LEN] {
278    use sha2::Digest;
279    let digest = sha2::Sha256::digest(input);
280    let mut out = [0u8; CACHING_SHA2_HASH_LEN];
281    out.copy_from_slice(&digest);
282    out
283}
284
285#[derive(Debug, Clone, Default)]
286pub struct UserStore {
287    users: BTreeMap<String, UserRecord>,
288    /// v7.39 (read01 round 58) — role membership (PG `pg_auth_members`):
289    /// member name → the roles it belongs to. `GRANT devs TO alice` records
290    /// `alice → {devs}`.
291    memberships: BTreeMap<String, BTreeSet<String>>,
292}
293
294#[derive(Debug, PartialEq, Eq)]
295pub enum UserError {
296    Exists,
297    NotFound,
298    InvalidRole,
299    EmptyName,
300    EmptyPassword,
301}
302
303impl core::fmt::Display for UserError {
304    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
305        match self {
306            Self::Exists => f.write_str("user already exists"),
307            Self::NotFound => f.write_str("user not found"),
308            Self::InvalidRole => {
309                f.write_str("invalid role (expected admin / readwrite / readonly)")
310            }
311            Self::EmptyName => f.write_str("username must be non-empty"),
312            Self::EmptyPassword => f.write_str("password must be non-empty"),
313        }
314    }
315}
316
317impl UserStore {
318    pub fn new() -> Self {
319        Self::default()
320    }
321
322    pub fn len(&self) -> usize {
323        self.users.len()
324    }
325
326    pub fn is_empty(&self) -> bool {
327        self.users.is_empty()
328    }
329
330    pub fn contains(&self, name: &str) -> bool {
331        self.users.contains_key(name)
332    }
333
334    /// Stable iteration in name order — used by SHOW USERS and the
335    /// snapshot writer.
336    /// v7.17.0 Phase 3.P0-71: look up a user by name. Returns
337    /// `None` for unknown names; the caller decides whether to
338    /// surface "Access Denied" or "User not found" (the
339    /// MySQL-wire shim picks the former to avoid leaking user
340    /// existence to unauthenticated clients).
341    #[must_use]
342    pub fn get(&self, name: &str) -> Option<&UserRecord> {
343        self.users.get(name)
344    }
345
346    pub fn iter(&self) -> impl Iterator<Item = (&str, &UserRecord)> {
347        self.users.iter().map(|(k, v)| (k.as_str(), v))
348    }
349
350    pub fn create(
351        &mut self,
352        name: &str,
353        password: &str,
354        role: Role,
355        salt: [u8; SALT_LEN],
356    ) -> Result<(), UserError> {
357        if name.is_empty() {
358            return Err(UserError::EmptyName);
359        }
360        if password.is_empty() {
361            return Err(UserError::EmptyPassword);
362        }
363        if self.users.contains_key(name) {
364            return Err(UserError::Exists);
365        }
366        let hash = derive_hash(&salt, password);
367        let mysql_native = Some(compute_mysql_native_hash(password));
368        let caching_sha2 = Some(compute_caching_sha2_hash(password));
369        self.users.insert(
370            name.to_string(),
371            UserRecord {
372                role,
373                salt,
374                hash,
375                scram: None,
376                mysql_native,
377                caching_sha2,
378                // CREATE USER is PG's `CREATE ROLE … LOGIN`; the caller
379                // overrides these for a bare `CREATE ROLE`.
380                can_login: true,
381                // v7.39 (round 548) — a password reached this far, so
382                // one was declared. The bare `CREATE ROLE` path
383                // substitutes an unguessable one and clears this after.
384                password_declared: true,
385                inherit: true,
386                superuser: matches!(role, Role::Admin),
387            },
388        );
389        Ok(())
390    }
391
392    /// v7.39 (read01 round 58) — set the PG role attributes on a freshly
393    /// created role. `CREATE ROLE` (no LOGIN) lands here right after `create`.
394    pub fn set_attributes(&mut self, name: &str, can_login: bool, inherit: bool, superuser: bool) {
395        if let Some(r) = self.users.get_mut(name) {
396            r.can_login = can_login;
397            r.inherit = inherit;
398            r.superuser = superuser;
399        }
400    }
401
402    /// v7.39 (round 548) — record whether the caller actually declared
403    /// a password (see [`UserRecord::password_declared`]).
404    pub fn set_password_declared(&mut self, name: &str, declared: bool) {
405        if let Some(r) = self.users.get_mut(name) {
406            r.password_declared = declared;
407        }
408    }
409
410    /// v7.39 (read01 round 58) — `GRANT <role> TO <member>`.
411    pub fn add_member(&mut self, role: &str, member: &str) {
412        self.memberships
413            .entry(member.to_string())
414            .or_default()
415            .insert(role.to_string());
416    }
417
418    /// v7.39 (read01 round 58) — `REVOKE <role> FROM <member>`.
419    pub fn drop_member(&mut self, role: &str, member: &str) {
420        if let Some(set) = self.memberships.get_mut(member) {
421            set.remove(role);
422            if set.is_empty() {
423                self.memberships.remove(member);
424            }
425        }
426    }
427
428    /// The roles `member` directly belongs to.
429    #[must_use]
430    /// v7.39 (round 202) — transitive role membership closure (PG
431    /// role inheritance: a policy `TO grp` applies to a member of
432    /// `grp`, including through nested grants). BFS with a seen-set
433    /// so a grant cycle can't loop. Names are stored as given; the
434    /// caller compares case-insensitively.
435    pub fn memberships_of_transitive(&self, member: &str) -> alloc::collections::BTreeSet<String> {
436        let mut seen: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
437        let mut queue: alloc::vec::Vec<String> = alloc::vec![String::from(member)];
438        while let Some(cur) = queue.pop() {
439            for (m, roles) in &self.memberships {
440                if m.eq_ignore_ascii_case(&cur) {
441                    for r in roles {
442                        if seen.insert(r.to_ascii_lowercase()) {
443                            queue.push(r.clone());
444                        }
445                    }
446                }
447            }
448        }
449        seen
450    }
451
452    pub fn memberships_of(&self, member: &str) -> Vec<String> {
453        self.memberships
454            .get(member)
455            .map(|s| s.iter().cloned().collect())
456            .unwrap_or_default()
457    }
458
459    /// Every (member, role) pair — `pg_auth_members`.
460    pub fn all_memberships(&self) -> impl Iterator<Item = (&str, &str)> {
461        self.memberships
462            .iter()
463            .flat_map(|(m, roles)| roles.iter().map(move |r| (m.as_str(), r.as_str())))
464    }
465
466    /// v7.39 (read01 round 58) — every role whose privileges `role` effectively
467    /// holds: itself, plus (transitively) every role it INHERITs from. A
468    /// NOINHERIT role holds only its own — it must `SET ROLE` to the others.
469    /// Cycles cannot happen (PG rejects them) but the visited set guards anyway.
470    #[must_use]
471    pub fn effective_roles(&self, role: &str) -> BTreeSet<String> {
472        let mut out = BTreeSet::new();
473        out.insert(role.to_string());
474        // The INHERIT attribute of the ROLE ITSELF decides whether its
475        // memberships flow into it. An unknown role (the built-in login) is
476        // treated as inheriting — it has no memberships anyway.
477        let inherits = self.users.get(role).is_none_or(|r| r.inherit);
478        if !inherits {
479            return out;
480        }
481        let mut queue: Vec<String> = self.memberships_of(role);
482        while let Some(r) = queue.pop() {
483            if !out.insert(r.clone()) {
484                continue;
485            }
486            // A role reached through membership contributes its OWN memberships
487            // only when it, too, inherits.
488            if self.users.get(&r).is_none_or(|rec| rec.inherit) {
489                queue.extend(self.memberships_of(&r));
490            }
491        }
492        out
493    }
494
495    pub fn drop(&mut self, name: &str) -> Result<(), UserError> {
496        self.memberships.remove(name);
497        for set in self.memberships.values_mut() {
498            set.remove(name);
499        }
500        self.users
501            .remove(name)
502            .map(|_| ())
503            .ok_or(UserError::NotFound)
504    }
505
506    /// v4.8: attach SCRAM-SHA-256 verifier to an existing user.
507    /// Called by the engine right after `create` so new users have
508    /// both auth paths (legacy BLAKE3 + SCRAM) available. The salt
509    /// here is independent of the BLAKE3 hash salt — they serve
510    /// different purposes.
511    /// v7.39 (round 750) — rotate a role's credential in place: every
512    /// derived form (legacy hash, both MySQL hashes) re-derives from
513    /// the new password; the caller re-derives SCRAM separately (it
514    /// owns the salt source). `None` = PASSWORD NULL: the credential
515    /// clears — the record keeps existing but nothing verifies.
516    pub fn set_password(
517        &mut self,
518        name: &str,
519        password: Option<&str>,
520        salt: [u8; SALT_LEN],
521    ) -> Result<(), UserError> {
522        let rec = self.users.get_mut(name).ok_or(UserError::NotFound)?;
523        match password {
524            Some(p) => {
525                if p.is_empty() {
526                    return Err(UserError::EmptyPassword);
527                }
528                rec.salt = salt;
529                rec.hash = derive_hash(&salt, p);
530                rec.mysql_native = Some(compute_mysql_native_hash(p));
531                rec.caching_sha2 = Some(compute_caching_sha2_hash(p));
532                rec.password_declared = true;
533            }
534            None => {
535                // An unguessable value: derived from the fresh salt, so
536                // no input can ever hash to it.
537                let digest = spg_crypto::hash(&salt);
538                let mut anti = [0u8; SALT_LEN];
539                anti.copy_from_slice(&digest[..SALT_LEN]);
540                rec.salt = salt;
541                rec.hash = derive_hash(&anti, "\u{0}unreachable");
542                rec.mysql_native = None;
543                rec.caching_sha2 = None;
544                rec.scram = None;
545                rec.password_declared = false;
546            }
547        }
548        Ok(())
549    }
550
551    pub fn enable_scram(
552        &mut self,
553        name: &str,
554        password: &str,
555        salt: [u8; SCRAM_SALT_LEN],
556        iters: u32,
557    ) -> Result<(), UserError> {
558        let rec = self.users.get_mut(name).ok_or(UserError::NotFound)?;
559        rec.scram = Some(compute_scram_secrets(password, salt, iters));
560        Ok(())
561    }
562
563    pub fn verify(&self, name: &str, password: &str) -> Option<Role> {
564        let rec = self.users.get(name)?;
565        if rec.verify(password) {
566            Some(rec.role)
567        } else {
568            None
569        }
570    }
571}
572
573fn derive_hash(salt: &[u8; SALT_LEN], password: &str) -> [u8; HASH_LEN] {
574    let mut buf = Vec::with_capacity(SALT_LEN + password.len());
575    buf.extend_from_slice(salt);
576    buf.extend_from_slice(password.as_bytes());
577    spg_crypto::hash(&buf)
578}
579
580/// v4.8: derive SCRAM-SHA-256 stored credentials per RFC 5802 §3.
581///
582/// `SaltedPassword` = `PBKDF2(password, salt, iters)`
583/// `ClientKey`      = `HMAC(SaltedPassword, "Client Key")`
584/// `StoredKey`      = `SHA-256(ClientKey)`
585/// `ServerKey`      = `HMAC(SaltedPassword, "Server Key")`
586///
587/// PG-wire keeps the `StoredKey` + `ServerKey` on disk; verifying a
588/// client SCRAM proof needs only the `StoredKey` (no plaintext
589/// password ever stored).
590pub fn compute_scram_secrets(
591    password: &str,
592    salt: [u8; SCRAM_SALT_LEN],
593    iters: u32,
594) -> ScramSecrets {
595    let salted = spg_crypto::pbkdf2::pbkdf2_sha256_32(password.as_bytes(), &salt, iters);
596    let client_key = spg_crypto::hmac::hmac_sha256(&salted, b"Client Key");
597    let stored_key = spg_crypto::sha256::hash(&client_key);
598    let server_key = spg_crypto::hmac::hmac_sha256(&salted, b"Server Key");
599    ScramSecrets {
600        iters,
601        salt,
602        stored_key,
603        server_key,
604    }
605}
606
607/// Branch-free byte compare so verify timing doesn't leak whether
608/// a prefix matched.
609fn constant_time_eq(a: &[u8; HASH_LEN], b: &[u8; HASH_LEN]) -> bool {
610    let mut diff: u8 = 0;
611    for i in 0..HASH_LEN {
612        diff |= a[i] ^ b[i];
613    }
614    diff == 0
615}
616
617/// v7.17.0 Phase 3.P0-71 — same idea, sized for the SHA-1 digest
618/// used by `mysql_native_password`.
619fn constant_time_eq_sha1(a: &[u8; MYSQL_NATIVE_HASH_LEN], b: &[u8; MYSQL_NATIVE_HASH_LEN]) -> bool {
620    let mut diff: u8 = 0;
621    for i in 0..MYSQL_NATIVE_HASH_LEN {
622        diff |= a[i] ^ b[i];
623    }
624    diff == 0
625}
626
627// ---- snapshot encoding ----
628//
629// Layout (after a magic + version envelope handled at Engine level):
630//
631// v1 (v4.1.0 — original):
632//   [u32 user_count]
633//   for each user:
634//     [u16 name_len][name][u8 role][16 salt][32 hash]
635//
636// v2 (v4.8.0 — adds SCRAM):
637//   [u8 format_version = 2]    // distinguishes from v1 (where the
638//                                 first byte is the LO of user_count
639//                                 u32, never 0xff)
640//   [u32 user_count]
641//   for each user:
642//     [u16 name_len][name][u8 role][16 salt][32 hash]
643//     [u8 scram_present]       // 0 or 1
644//     if scram_present:
645//       [u32 iters][16 scram_salt][32 stored_key][32 server_key]
646//
647// We use byte 0xff as the v2 marker — v1 would have to have ≥
648// 4 billion users for its first byte to be 0xff, so the version
649// switch is unambiguous.
650
651const SCRAM_FORMAT_MARKER: u8 = 0xff;
652/// v7.17.0 Phase 3.P0-71 — v3 format marker. v3 extends v2 by
653/// appending an optional `mysql_native_password` SHA1(SHA1(pwd))
654/// per user.
655const MYSQL_NATIVE_FORMAT_MARKER: u8 = 0xfe;
656/// v7.17.0 Phase 3.P0-72 — v4 format marker. v4 extends v3 by
657/// also appending an optional `caching_sha2_password`
658/// SHA256(SHA256(pwd)) per user. Writer always emits v4;
659/// reader understands v1 / v2 / v3 / v4.
660const CACHING_SHA2_FORMAT_MARKER: u8 = 0xfd;
661/// v7.39 (read01 round 58) — v5 format marker. v5 extends v4 with the PG role
662/// attributes (login / inherit / superuser) per user and a trailing role
663/// MEMBERSHIP block. Writer always emits v5; reader understands v1 … v5.
664const ROLE_ATTRS_FORMAT_MARKER: u8 = 0xfc;
665/// v7.39 (round 548) — v6: v5 plus a `password_declared` byte per user.
666const PASSWORD_DECLARED_FORMAT_MARKER: u8 = 0xfb;
667
668pub(crate) fn serialize_users(store: &UserStore) -> Vec<u8> {
669    let per_user_floor = 2 + 16 + 1 + SALT_LEN + HASH_LEN + 1 + 1;
670    let mut out = Vec::with_capacity(1 + 4 + store.len() * per_user_floor);
671    // v7.17.0 Phase 3.P0-72 — bump on-disk format to v4 so the
672    // per-user `caching_sha2_password` hash trails the
673    // mysql_native block.
674    out.push(PASSWORD_DECLARED_FORMAT_MARKER);
675    out.extend_from_slice(
676        &u32::try_from(store.users.len())
677            .expect("≤ 4G users")
678            .to_le_bytes(),
679    );
680    for (name, rec) in &store.users {
681        let nl = u16::try_from(name.len()).expect("≤ 65k name");
682        out.extend_from_slice(&nl.to_le_bytes());
683        out.extend_from_slice(name.as_bytes());
684        out.push(match rec.role {
685            Role::Admin => 0,
686            Role::ReadWrite => 1,
687            Role::ReadOnly => 2,
688        });
689        out.extend_from_slice(&rec.salt);
690        out.extend_from_slice(&rec.hash);
691        match &rec.scram {
692            None => out.push(0),
693            Some(s) => {
694                out.push(1);
695                out.extend_from_slice(&s.iters.to_le_bytes());
696                out.extend_from_slice(&s.salt);
697                out.extend_from_slice(&s.stored_key);
698                out.extend_from_slice(&s.server_key);
699            }
700        }
701        match &rec.mysql_native {
702            None => out.push(0),
703            Some(h) => {
704                out.push(1);
705                out.extend_from_slice(h);
706            }
707        }
708        match &rec.caching_sha2 {
709            None => out.push(0),
710            Some(h) => {
711                out.push(1);
712                out.extend_from_slice(h);
713            }
714        }
715        // v5 — the three role attributes.
716        out.push(u8::from(rec.can_login));
717        out.push(u8::from(rec.inherit));
718        out.push(u8::from(rec.superuser));
719        // v6 — round 548.
720        out.push(u8::from(rec.password_declared));
721    }
722    // v5 — the membership block, at the tail so a v4 reader stops before it.
723    let pairs: Vec<(&str, &str)> = store.all_memberships().collect();
724    out.extend_from_slice(
725        &u32::try_from(pairs.len())
726            .expect("≤ 4G memberships")
727            .to_le_bytes(),
728    );
729    for (member, role) in pairs {
730        let ml = u16::try_from(member.len()).expect("≤ 65k name");
731        out.extend_from_slice(&ml.to_le_bytes());
732        out.extend_from_slice(member.as_bytes());
733        let rl = u16::try_from(role.len()).expect("≤ 65k name");
734        out.extend_from_slice(&rl.to_le_bytes());
735        out.extend_from_slice(role.as_bytes());
736    }
737    out
738}
739
740#[derive(Debug)]
741pub enum UserDeserializeError {
742    Truncated,
743    BadRole(u8),
744    InvalidUtf8,
745}
746
747impl core::fmt::Display for UserDeserializeError {
748    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
749        match self {
750            Self::Truncated => f.write_str("user blob truncated"),
751            Self::BadRole(b) => write!(f, "unknown role byte: {b}"),
752            Self::InvalidUtf8 => f.write_str("username not valid UTF-8"),
753        }
754    }
755}
756
757fn take<'a>(p: &mut usize, n: usize, buf: &'a [u8]) -> Result<&'a [u8], UserDeserializeError> {
758    if *p + n > buf.len() {
759        return Err(UserDeserializeError::Truncated);
760    }
761    let s = &buf[*p..*p + n];
762    *p += n;
763    Ok(s)
764}
765
766pub(crate) fn deserialize_users(buf: &[u8]) -> Result<UserStore, UserDeserializeError> {
767    let mut p = 0usize;
768    // v1 → starts with a u32 user_count (LO byte rarely 0xfd /
769    // 0xfe / 0xff in practice).
770    // v2 → 0xff marker (SCRAM_FORMAT_MARKER) then u32 count.
771    // v3 → 0xfe marker (MYSQL_NATIVE_FORMAT_MARKER) then u32
772    //      count; per-user payload adds a 1-byte flag + 20-byte
773    //      SHA1(SHA1(pwd)) tail for `mysql_native_password`.
774    // v4 → 0xfd marker (CACHING_SHA2_FORMAT_MARKER) then u32
775    //      count; per-user payload further adds 1-byte flag +
776    //      32-byte SHA256(SHA256(pwd)) tail for
777    //      `caching_sha2_password`.
778    // v5 → 0xfc marker: v4 plus three attribute bytes per user and a trailing
779    //      membership block.
780    let (
781        scram_present_inline,
782        mysql_native_present_inline,
783        caching_sha2_present_inline,
784        role_attrs_inline,
785        password_declared_inline,
786    ) = if !buf.is_empty() && buf[0] == PASSWORD_DECLARED_FORMAT_MARKER {
787        p += 1;
788        (true, true, true, true, true)
789    } else if !buf.is_empty() && buf[0] == ROLE_ATTRS_FORMAT_MARKER {
790        p += 1;
791        (true, true, true, true, false)
792    } else if !buf.is_empty() && buf[0] == CACHING_SHA2_FORMAT_MARKER {
793        p += 1;
794        (true, true, true, false, false)
795    } else if !buf.is_empty() && buf[0] == MYSQL_NATIVE_FORMAT_MARKER {
796        p += 1;
797        (true, true, false, false, false)
798    } else if !buf.is_empty() && buf[0] == SCRAM_FORMAT_MARKER {
799        p += 1;
800        (true, false, false, false, false)
801    } else {
802        (false, false, false, false, false)
803    };
804    let count_bytes = take(&mut p, 4, buf)?;
805    let count = u32::from_le_bytes(count_bytes.try_into().unwrap()) as usize;
806    let mut store = UserStore::new();
807    for _ in 0..count {
808        let nl_bytes = take(&mut p, 2, buf)?;
809        let nl = u16::from_le_bytes(nl_bytes.try_into().unwrap()) as usize;
810        let name_bytes = take(&mut p, nl, buf)?;
811        let name = core::str::from_utf8(name_bytes)
812            .map_err(|_| UserDeserializeError::InvalidUtf8)?
813            .to_string();
814        let role_byte = take(&mut p, 1, buf)?[0];
815        let role = match role_byte {
816            0 => Role::Admin,
817            1 => Role::ReadWrite,
818            2 => Role::ReadOnly,
819            b => return Err(UserDeserializeError::BadRole(b)),
820        };
821        let mut salt = [0u8; SALT_LEN];
822        salt.copy_from_slice(take(&mut p, SALT_LEN, buf)?);
823        let mut hash = [0u8; HASH_LEN];
824        hash.copy_from_slice(take(&mut p, HASH_LEN, buf)?);
825        let scram = if scram_present_inline {
826            let flag = take(&mut p, 1, buf)?[0];
827            if flag == 1 {
828                let iters_bytes = take(&mut p, 4, buf)?;
829                let iters = u32::from_le_bytes(iters_bytes.try_into().unwrap());
830                let mut s_salt = [0u8; SCRAM_SALT_LEN];
831                s_salt.copy_from_slice(take(&mut p, SCRAM_SALT_LEN, buf)?);
832                let mut stored_key = [0u8; HASH_LEN];
833                stored_key.copy_from_slice(take(&mut p, HASH_LEN, buf)?);
834                let mut server_key = [0u8; HASH_LEN];
835                server_key.copy_from_slice(take(&mut p, HASH_LEN, buf)?);
836                Some(ScramSecrets {
837                    iters,
838                    salt: s_salt,
839                    stored_key,
840                    server_key,
841                })
842            } else {
843                None
844            }
845        } else {
846            None
847        };
848        let mysql_native = if mysql_native_present_inline {
849            let flag = take(&mut p, 1, buf)?[0];
850            if flag == 1 {
851                let mut h = [0u8; MYSQL_NATIVE_HASH_LEN];
852                h.copy_from_slice(take(&mut p, MYSQL_NATIVE_HASH_LEN, buf)?);
853                Some(h)
854            } else {
855                None
856            }
857        } else {
858            None
859        };
860        let caching_sha2 = if caching_sha2_present_inline {
861            let flag = take(&mut p, 1, buf)?[0];
862            if flag == 1 {
863                let mut h = [0u8; CACHING_SHA2_HASH_LEN];
864                h.copy_from_slice(take(&mut p, CACHING_SHA2_HASH_LEN, buf)?);
865                Some(h)
866            } else {
867                None
868            }
869        } else {
870            None
871        };
872        // v7.39 (read01 round 58) — a pre-v5 blob predates role attributes:
873        // every user in it was a login user that inherits, and Admin was the
874        // superuser role. v5 carries the three bytes explicitly.
875        let (can_login, inherit, superuser) = if role_attrs_inline {
876            let b = take(&mut p, 3, buf)?;
877            (b[0] == 1, b[1] == 1, b[2] == 1)
878        } else {
879            (true, true, matches!(role, Role::Admin))
880        };
881        // v6 (round 548). An older image did not record it; infer from
882        // what it DID record — a role that can log in was created with a
883        // password, a NOLOGIN group role was not. That is the reading
884        // that unlocks an operator already stuck behind the old rule
885        // while leaving a real account guarded.
886        let password_declared = if password_declared_inline {
887            take(&mut p, 1, buf)?[0] == 1
888        } else {
889            can_login
890        };
891        store.users.insert(
892            name,
893            UserRecord {
894                role,
895                salt,
896                hash,
897                scram,
898                mysql_native,
899                caching_sha2,
900                can_login,
901                password_declared,
902                inherit,
903                superuser,
904            },
905        );
906    }
907    if role_attrs_inline {
908        let count_bytes = take(&mut p, 4, buf)?;
909        let mcount = u32::from_le_bytes(count_bytes.try_into().unwrap()) as usize;
910        for _ in 0..mcount {
911            let ml = u16::from_le_bytes(take(&mut p, 2, buf)?.try_into().unwrap()) as usize;
912            let member = core::str::from_utf8(take(&mut p, ml, buf)?)
913                .map_err(|_| UserDeserializeError::InvalidUtf8)?
914                .to_string();
915            let rl = u16::from_le_bytes(take(&mut p, 2, buf)?.try_into().unwrap()) as usize;
916            let role = core::str::from_utf8(take(&mut p, rl, buf)?)
917                .map_err(|_| UserDeserializeError::InvalidUtf8)?
918                .to_string();
919            store.add_member(&role, &member);
920        }
921    }
922    if p != buf.len() {
923        return Err(UserDeserializeError::Truncated);
924    }
925    Ok(store)
926}
927
928impl Engine {
929    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
930    pub(crate) fn exec_show_users(&self) -> QueryResult {
931        let columns = alloc::vec![
932            ColumnSchema::new("name", DataType::Text, false),
933            ColumnSchema::new("role", DataType::Text, false),
934        ];
935        let rows: Vec<Row<'static>> = self
936            .users
937            .iter()
938            .map(|(name, rec)| {
939                Row::new(alloc::vec![
940                    Value::text(name.to_string()),
941                    Value::text(rec.role.as_str().to_string()),
942                ])
943            })
944            .collect();
945        QueryResult::Rows { columns, rows }
946    }
947    /// `salt` is supplied by the caller (the host has a random
948    /// source; the engine is `no_std`). Caller should pass a fresh
949    /// 16-byte random value per user.
950    pub fn create_user(
951        &mut self,
952        name: &str,
953        password: &str,
954        role: Role,
955        salt: [u8; 16],
956    ) -> Result<(), UserError> {
957        // v7.37 (round 828) — through the role router: inside a
958        // transaction this writes the TX's shadow store, so ROLLBACK
959        // undoes it and COMMIT publishes it, exactly like the catalog.
960        self.role_ddl_users_mut()
961            .create(name, password, role, salt)?;
962        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
963        // auth can verify without re-running PBKDF2 per attempt.
964        // Uses a fresh salt from the host RNG (falls back to a
965        // deterministic per-username salt when no RNG is wired, same
966        // as the legacy hash path).
967        let scram_salt = self.salt_fn.map_or_else(
968            || {
969                let mut s = [0u8; SCRAM_SALT_LEN];
970                let digest = spg_crypto::hash(name.as_bytes());
971                // Use bytes 16..32 of BLAKE3 so we don't reuse the
972                // exact same fallback salt as the BLAKE3 hash path.
973                s.copy_from_slice(&digest[16..32]);
974                s
975            },
976            |f| f(),
977        );
978        self.role_ddl_users_mut()
979            .enable_scram(name, password, scram_salt, SCRAM_DEFAULT_ITERS)?;
980        Ok(())
981    }
982
983    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
984        self.role_ddl_users_mut().drop(name)
985    }
986
987    /// v7.39 (round 750) — the engine half of `ALTER ROLE … PASSWORD`:
988    /// rotate every derived credential form, then re-derive the
989    /// SCRAM-SHA-256 verifier with a fresh salt (the same source
990    /// `create_user` uses). `None` clears the credential entirely.
991    pub fn alter_user_password(
992        &mut self,
993        name: &str,
994        password: Option<&str>,
995    ) -> Result<(), UserError> {
996        let salt = self.salt_fn.map_or_else(
997            || {
998                let mut s_bytes = [0u8; 16];
999                let digest = spg_crypto::hash(name.as_bytes());
1000                s_bytes.copy_from_slice(&digest[..16]);
1001                s_bytes
1002            },
1003            |f| f(),
1004        );
1005        self.role_ddl_users_mut()
1006            .set_password(name, password, salt)?;
1007        if let Some(p) = password {
1008            let scram_salt = self.salt_fn.map_or_else(
1009                || {
1010                    let mut s = [0u8; SCRAM_SALT_LEN];
1011                    let digest = spg_crypto::hash(name.as_bytes());
1012                    s.copy_from_slice(&digest[16..32]);
1013                    s
1014                },
1015                |f| f(),
1016            );
1017            self.role_ddl_users_mut()
1018                .enable_scram(name, p, scram_salt, SCRAM_DEFAULT_ITERS)?;
1019        }
1020        Ok(())
1021    }
1022
1023    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1024        self.users.verify(name, password)
1025    }
1026
1027    /// v7.39 (round 750) — whether a role currently carries a SCRAM
1028    /// verifier (the rotation pins read it; pgwire uses richer paths).
1029    #[must_use]
1030    pub fn user_scram(&self, name: &str) -> Option<()> {
1031        self.users.get(name).and_then(|r| r.scram().map(|_| ()))
1032    }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038
1039    /// v7.39 (TLS/SCRAM) — SPG's SCRAM-SHA-256 verifier derivation is
1040    /// byte-identical to PostgreSQL. Vector captured from live PG18.4:
1041    /// `CREATE ROLE scr PASSWORD 'secret'` with password_encryption=scram-sha-256
1042    /// stored `SCRAM-SHA-256$4096:<salt>$<StoredKey>:<ServerKey>`.
1043    #[test]
1044    fn scram_secrets_match_live_pg_vector() {
1045        let salt: [u8; SCRAM_SALT_LEN] = [
1046            0xbb, 0x70, 0xc5, 0x3e, 0x8d, 0x2b, 0x56, 0x64, 0x12, 0xc0, 0xae, 0xd1, 0x4e, 0x19,
1047            0x8b, 0xfe,
1048        ];
1049        let want_stored: [u8; HASH_LEN] = [
1050            0xbb, 0x80, 0x16, 0x91, 0x1b, 0xc4, 0x49, 0x6f, 0x9d, 0x95, 0x79, 0xcd, 0xb8, 0x57,
1051            0x12, 0x01, 0x58, 0x2b, 0x52, 0x9a, 0x9e, 0x80, 0xe8, 0x06, 0x32, 0x3e, 0x76, 0x5f,
1052            0x38, 0xd1, 0x51, 0xa9,
1053        ];
1054        let want_server: [u8; HASH_LEN] = [
1055            0x21, 0xe4, 0x04, 0x40, 0x68, 0x5f, 0x80, 0x5c, 0x6d, 0x52, 0xc0, 0x47, 0x4d, 0xa3,
1056            0x5b, 0x96, 0xc0, 0x61, 0x10, 0x25, 0x2c, 0xf3, 0x31, 0x30, 0x00, 0x88, 0x5b, 0x08,
1057            0x8c, 0xe3, 0x0b, 0x84,
1058        ];
1059        let secrets = compute_scram_secrets("secret", salt, 4096);
1060        assert_eq!(secrets.iters, 4096);
1061        assert_eq!(secrets.salt, salt);
1062        assert_eq!(
1063            secrets.stored_key, want_stored,
1064            "StoredKey diverges from PG"
1065        );
1066        assert_eq!(
1067            secrets.server_key, want_server,
1068            "ServerKey diverges from PG"
1069        );
1070    }
1071
1072    /// v7.39 (TLS/SCRAM) Gap A — a user made via SQL `CREATE USER … PASSWORD`
1073    /// must get a SCRAM verifier (else pgwire silently downgrades it to
1074    /// cleartext auth). Before the fix, `exec_create_user` called
1075    /// `users.create` directly (scram = None).
1076    #[test]
1077    fn sql_create_user_derives_scram() {
1078        let mut e = crate::Engine::new();
1079        e.execute("CREATE USER app WITH PASSWORD 'pw' ROLE 'readwrite'")
1080            .expect("CREATE USER");
1081        let rec = e.users.get("app").expect("user created");
1082        assert!(
1083            rec.scram().is_some(),
1084            "SQL CREATE USER must derive a SCRAM-SHA-256 verifier"
1085        );
1086    }
1087
1088    #[test]
1089    fn create_then_verify_succeeds_with_right_password_only() {
1090        let mut s = UserStore::new();
1091        s.create("alice", "hunter2", Role::Admin, [1; SALT_LEN])
1092            .unwrap();
1093        assert_eq!(s.verify("alice", "hunter2"), Some(Role::Admin));
1094        assert_eq!(s.verify("alice", "wrong"), None);
1095        assert_eq!(s.verify("bob", "hunter2"), None);
1096    }
1097
1098    #[test]
1099    fn create_duplicate_user_is_rejected() {
1100        let mut s = UserStore::new();
1101        s.create("a", "p", Role::ReadOnly, [0; SALT_LEN]).unwrap();
1102        assert_eq!(
1103            s.create("a", "p2", Role::Admin, [0; SALT_LEN]),
1104            Err(UserError::Exists)
1105        );
1106    }
1107
1108    #[test]
1109    fn drop_user_removes_them() {
1110        let mut s = UserStore::new();
1111        s.create("a", "p", Role::Admin, [0; SALT_LEN]).unwrap();
1112        s.drop("a").unwrap();
1113        assert!(s.is_empty());
1114        assert_eq!(s.drop("a"), Err(UserError::NotFound));
1115    }
1116
1117    #[test]
1118    fn role_parse_accepts_aliases() {
1119        assert_eq!(Role::parse("ADMIN"), Some(Role::Admin));
1120        assert_eq!(Role::parse("rw"), Some(Role::ReadWrite));
1121        assert_eq!(Role::parse("ro"), Some(Role::ReadOnly));
1122        assert_eq!(Role::parse("god"), None);
1123    }
1124
1125    #[test]
1126    fn snapshot_round_trip_preserves_users_and_verify() {
1127        let mut s = UserStore::new();
1128        s.create("alice", "pw1", Role::Admin, [7; SALT_LEN])
1129            .unwrap();
1130        s.create("bob", "pw2", Role::ReadOnly, [13; SALT_LEN])
1131            .unwrap();
1132        let bytes = serialize_users(&s);
1133        let s2 = deserialize_users(&bytes).unwrap();
1134        assert_eq!(s2.len(), 2);
1135        assert_eq!(s2.verify("alice", "pw1"), Some(Role::Admin));
1136        assert_eq!(s2.verify("bob", "pw2"), Some(Role::ReadOnly));
1137        assert_eq!(s2.verify("bob", "wrong"), None);
1138    }
1139
1140    #[test]
1141    fn empty_store_round_trip() {
1142        // v7.39 (read01 round 58): writer flipped to the v5 marker (0xfc) —
1143        // per-user role attributes plus a trailing membership block, which for
1144        // an empty store is a zero u32 count.
1145        // v7.39 (round 548): and to the v6 marker (0xfb), which adds a
1146        // per-user `password_declared` byte. An empty store's bytes are
1147        // unchanged apart from the marker.
1148        let s = UserStore::new();
1149        let bytes = serialize_users(&s);
1150        assert_eq!(bytes, [0xfb, 0, 0, 0, 0, 0, 0, 0, 0]);
1151        let s2 = deserialize_users(&bytes).unwrap();
1152        assert!(s2.is_empty());
1153    }
1154
1155    #[test]
1156    fn v2_blob_still_loads_with_mysql_native_none() {
1157        // v7.17.0 Phase 3.P0-71: cross-version compat — readers
1158        // must still parse v2-shaped blobs written before the v3
1159        // bump and surface `mysql_native = None` for those
1160        // users so the operator knows to reset the password.
1161        let mut buf = Vec::new();
1162        buf.push(0xff); // v2 marker
1163        buf.extend_from_slice(&1u32.to_le_bytes());
1164        buf.extend_from_slice(&3u16.to_le_bytes());
1165        buf.extend_from_slice(b"old");
1166        buf.push(0); // role = admin
1167        buf.extend_from_slice(&[1u8; SALT_LEN]);
1168        buf.extend_from_slice(&[2u8; HASH_LEN]);
1169        buf.push(0); // no SCRAM
1170        let s = deserialize_users(&buf).unwrap();
1171        let rec = s.get("old").expect("v2 user loads");
1172        assert!(rec.mysql_native().is_none());
1173    }
1174}
1175
1176#[cfg(test)]
1177mod p0_71_tests {
1178    use super::*;
1179
1180    #[test]
1181    fn create_populates_mysql_native_hash() {
1182        let mut s = UserStore::new();
1183        s.create("alice", "wonderland", Role::Admin, [9u8; SALT_LEN])
1184            .unwrap();
1185        let rec = s.get("alice").unwrap();
1186        let expected = compute_mysql_native_hash("wonderland");
1187        assert_eq!(rec.mysql_native(), Some(&expected));
1188    }
1189
1190    #[test]
1191    fn verify_mysql_native_password_accepts_correct_response() {
1192        // Build a fake scramble + the canonical client response.
1193        let mut s = UserStore::new();
1194        s.create("bob", "secret", Role::Admin, [3u8; SALT_LEN])
1195            .unwrap();
1196        let rec = s.get("bob").unwrap();
1197        let scramble: [u8; 20] = core::array::from_fn(|i| (i as u8).wrapping_mul(7));
1198        // Compute the same response the client would.
1199        let sha1_pwd = sha1_bytes(b"secret");
1200        let sha1_sha1_pwd = sha1_bytes(&sha1_pwd);
1201        let mut concat = [0u8; 40];
1202        concat[..20].copy_from_slice(&scramble);
1203        concat[20..].copy_from_slice(&sha1_sha1_pwd);
1204        let mask = sha1_bytes(&concat);
1205        let response: [u8; MYSQL_NATIVE_HASH_LEN] = core::array::from_fn(|i| sha1_pwd[i] ^ mask[i]);
1206        assert!(rec.verify_mysql_native_password(&scramble, &response));
1207        // Tamper with one byte → rejected.
1208        let mut bad = response;
1209        bad[0] ^= 1;
1210        assert!(!rec.verify_mysql_native_password(&scramble, &bad));
1211    }
1212
1213    #[test]
1214    fn v4_serialise_round_trips_both_mysql_native_and_caching_sha2() {
1215        let mut s = UserStore::new();
1216        s.create("alice", "wonderland", Role::Admin, [4u8; SALT_LEN])
1217            .unwrap();
1218        let bytes = serialize_users(&s);
1219        assert_eq!(bytes[0], 0xfb, "v6 marker advertised (round 548)");
1220        let s2 = deserialize_users(&bytes).unwrap();
1221        let r1 = s.get("alice").unwrap();
1222        let r2 = s2.get("alice").unwrap();
1223        assert_eq!(r1.mysql_native(), r2.mysql_native());
1224        assert_eq!(r1.caching_sha2(), r2.caching_sha2());
1225        // Sanity: both verifiers are populated for a fresh user.
1226        assert!(r1.mysql_native().is_some());
1227        assert!(r1.caching_sha2().is_some());
1228    }
1229
1230    #[test]
1231    fn v3_blob_still_loads_with_caching_sha2_none() {
1232        // v7.17.0 Phase 3.P0-72: backward compat — readers must
1233        // still parse v3-shaped blobs (mysql_native present,
1234        // caching_sha2 missing) written before the v4 bump.
1235        let mut buf = Vec::new();
1236        buf.push(0xfe); // v3 marker
1237        buf.extend_from_slice(&1u32.to_le_bytes());
1238        buf.extend_from_slice(&5u16.to_le_bytes());
1239        buf.extend_from_slice(b"older");
1240        buf.push(0); // role = admin
1241        buf.extend_from_slice(&[1u8; SALT_LEN]);
1242        buf.extend_from_slice(&[2u8; HASH_LEN]);
1243        buf.push(0); // no SCRAM
1244        buf.push(1); // mysql_native flag = present
1245        buf.extend_from_slice(&[3u8; MYSQL_NATIVE_HASH_LEN]);
1246        let s = deserialize_users(&buf).unwrap();
1247        let rec = s.get("older").unwrap();
1248        assert!(rec.mysql_native().is_some());
1249        assert!(rec.caching_sha2().is_none());
1250    }
1251
1252    #[test]
1253    fn verify_caching_sha2_password_accepts_correct_response() {
1254        let mut s = UserStore::new();
1255        s.create("bob", "secret", Role::Admin, [3u8; SALT_LEN])
1256            .unwrap();
1257        let rec = s.get("bob").unwrap();
1258        let scramble: [u8; 20] = core::array::from_fn(|i| (i as u8).wrapping_mul(11));
1259        let sha_pwd = sha256_bytes(b"secret");
1260        let sha_sha_pwd = sha256_bytes(&sha_pwd);
1261        let mut concat = [0u8; 20 + CACHING_SHA2_HASH_LEN];
1262        concat[..20].copy_from_slice(&scramble);
1263        concat[20..].copy_from_slice(&sha_sha_pwd);
1264        let mask = sha256_bytes(&concat);
1265        let response: [u8; CACHING_SHA2_HASH_LEN] = core::array::from_fn(|i| sha_pwd[i] ^ mask[i]);
1266        assert!(rec.verify_caching_sha2_password(&scramble, &response));
1267        let mut bad = response;
1268        bad[0] ^= 1;
1269        assert!(!rec.verify_caching_sha2_password(&scramble, &bad));
1270    }
1271
1272    #[test]
1273    fn old_v1_user_blob_still_loads() {
1274        // Hand-constructed v1 blob: 1 user, no SCRAM byte.
1275        // [u32 count=1][u16 name_len=3]["bob"][u8 role=0][16 salt][32 hash]
1276        let mut buf = Vec::new();
1277        buf.extend_from_slice(&1u32.to_le_bytes());
1278        buf.extend_from_slice(&3u16.to_le_bytes());
1279        buf.extend_from_slice(b"bob");
1280        buf.push(0); // role = admin
1281        buf.extend_from_slice(&[7u8; SALT_LEN]);
1282        buf.extend_from_slice(&[42u8; HASH_LEN]);
1283        let s = deserialize_users(&buf).expect("v1 blob must still load");
1284        assert_eq!(s.len(), 1);
1285        let (n, rec) = s.iter().next().unwrap();
1286        assert_eq!(n, "bob");
1287        assert_eq!(rec.role, Role::Admin);
1288        assert!(rec.scram().is_none(), "v1 users have no SCRAM secrets");
1289    }
1290
1291    #[test]
1292    fn scram_round_trip_preserves_iters_salt_keys() {
1293        let mut s = UserStore::new();
1294        s.create("alice", "pw", Role::Admin, [3; SALT_LEN]).unwrap();
1295        s.enable_scram("alice", "pw", [9; SCRAM_SALT_LEN], 4096)
1296            .unwrap();
1297        let bytes = serialize_users(&s);
1298        let s2 = deserialize_users(&bytes).unwrap();
1299        let (_, rec) = s2.iter().next().unwrap();
1300        let scram = rec.scram().expect("scram must round-trip");
1301        assert_eq!(scram.iters, 4096);
1302        assert_eq!(scram.salt, [9u8; SCRAM_SALT_LEN]);
1303        // StoredKey and ServerKey are deterministic given (password,
1304        // salt, iters); verify by recomputing.
1305        let expected = compute_scram_secrets("pw", [9; SCRAM_SALT_LEN], 4096);
1306        assert_eq!(scram.stored_key, expected.stored_key);
1307        assert_eq!(scram.server_key, expected.server_key);
1308    }
1309
1310    #[test]
1311    fn deserialize_truncation_is_caught() {
1312        assert!(deserialize_users(&[]).is_err());
1313        assert!(deserialize_users(&[0, 0, 0]).is_err());
1314    }
1315}