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;
18use alloc::string::{String, ToString};
19use alloc::vec::Vec;
20
21const SALT_LEN: usize = 16;
22const HASH_LEN: usize = 32;
23/// v7.17.0 Phase 3.P0-71 — length of SHA1(SHA1(password)) stored
24/// per user for `mysql_native_password` auth verification.
25pub const MYSQL_NATIVE_HASH_LEN: usize = 20;
26/// v7.17.0 Phase 3.P0-72 — length of SHA256(SHA256(password))
27/// stored per user for `caching_sha2_password` auth
28/// verification (the MySQL 8.0 default plugin).
29pub const CACHING_SHA2_HASH_LEN: usize = 32;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Role {
33    Admin,
34    ReadWrite,
35    ReadOnly,
36}
37
38impl Role {
39    pub const fn as_str(self) -> &'static str {
40        match self {
41            Self::Admin => "admin",
42            Self::ReadWrite => "readwrite",
43            Self::ReadOnly => "readonly",
44        }
45    }
46
47    pub fn parse(s: &str) -> Option<Self> {
48        match s.to_ascii_lowercase().as_str() {
49            "admin" => Some(Self::Admin),
50            "readwrite" | "rw" => Some(Self::ReadWrite),
51            "readonly" | "ro" => Some(Self::ReadOnly),
52            _ => None,
53        }
54    }
55
56    /// Read access — every role qualifies.
57    pub const fn can_read(self) -> bool {
58        true
59    }
60
61    /// Write access (INSERT / DDL on user tables).
62    pub const fn can_write(self) -> bool {
63        matches!(self, Self::Admin | Self::ReadWrite)
64    }
65
66    /// User-management DDL (`CREATE USER`, `DROP USER`).
67    pub const fn can_manage_users(self) -> bool {
68        matches!(self, Self::Admin)
69    }
70}
71
72#[derive(Debug, Clone)]
73pub struct UserRecord {
74    pub role: Role,
75    salt: [u8; SALT_LEN],
76    hash: [u8; HASH_LEN],
77    /// v4.8: SCRAM-SHA-256 verifier. Computed alongside the
78    /// BLAKE3 hash at user creation so PG-wire SASL auth can
79    /// verify without re-running PBKDF2 per attempt. `None`
80    /// means the user predates v4.8 (loaded from an older
81    /// snapshot); the PG-wire layer falls back to
82    /// `CleartextPassword` for those users.
83    scram: Option<ScramSecrets>,
84    /// v7.17.0 Phase 3.P0-71: SHA1(SHA1(password)) — the
85    /// `mysql.user.authentication_string` shape for the
86    /// `mysql_native_password` plugin. Computed at create /
87    /// set_password time alongside the BLAKE3 hash and the
88    /// SCRAM verifier so the MySQL-wire shim doesn't need
89    /// plaintext to verify. `None` for users loaded from a
90    /// pre-v7.17.0 snapshot — the MySQL-wire shim rejects
91    /// those with Access Denied until the password is reset.
92    mysql_native: Option<[u8; MYSQL_NATIVE_HASH_LEN]>,
93    /// v7.17.0 Phase 3.P0-72: SHA256(SHA256(password)) — the
94    /// `mysql.user.authentication_string` shape for MySQL 8.0+'s
95    /// default `caching_sha2_password` plugin. Computed at the
96    /// same time as `mysql_native`. The MySQL-wire shim's fast
97    /// path uses this for the SHA256-XOR proof verification —
98    /// the public-key-RSA full-auth path is a v7.18 carve-out.
99    caching_sha2: Option<[u8; CACHING_SHA2_HASH_LEN]>,
100}
101
102/// SCRAM-SHA-256 stored credentials per RFC 5802 §5.
103/// `salt` and `iters` are sent to the client in server-first;
104/// `stored_key` and `server_key` are kept secret and used in the
105/// final-message verification.
106#[derive(Debug, Clone)]
107pub struct ScramSecrets {
108    pub iters: u32,
109    pub salt: [u8; SCRAM_SALT_LEN],
110    pub stored_key: [u8; HASH_LEN],
111    pub server_key: [u8; HASH_LEN],
112}
113
114pub const SCRAM_SALT_LEN: usize = 16;
115pub const SCRAM_DEFAULT_ITERS: u32 = 4096;
116
117impl UserRecord {
118    pub fn verify(&self, password: &str) -> bool {
119        let candidate = derive_hash(&self.salt, password);
120        constant_time_eq(&candidate, &self.hash)
121    }
122
123    pub const fn scram(&self) -> Option<&ScramSecrets> {
124        self.scram.as_ref()
125    }
126
127    /// v7.17.0 Phase 3.P0-71: borrow the stored
128    /// `mysql_native_password` verifier (SHA1(SHA1(password)))
129    /// for the MySQL-wire shim.
130    pub const fn mysql_native(&self) -> Option<&[u8; MYSQL_NATIVE_HASH_LEN]> {
131        self.mysql_native.as_ref()
132    }
133
134    /// v7.17.0 Phase 3.P0-72: borrow the stored
135    /// `caching_sha2_password` verifier (SHA256(SHA256(password)))
136    /// for the MySQL-wire shim's fast-path auth.
137    pub const fn caching_sha2(&self) -> Option<&[u8; CACHING_SHA2_HASH_LEN]> {
138        self.caching_sha2.as_ref()
139    }
140
141    /// v7.17.0 Phase 3.P0-72 — verify a client
142    /// `caching_sha2_password` fast-path response.
143    ///
144    /// Protocol: same XOR shape as `mysql_native_password` but
145    /// with SHA-256 instead of SHA-1:
146    /// `client_response = SHA256(password) XOR SHA256(scramble
147    /// || SHA256(SHA256(password)))`. Server reconstructs and
148    /// checks `SHA256(reconstructed) == stored_hash`.
149    ///
150    /// Full-auth RSA fallback (when the cache misses) is a
151    /// v7.18 carve-out — clients connecting over plaintext
152    /// without a cached entry will see Access Denied from the
153    /// shim until that lands.
154    pub fn verify_caching_sha2_password(&self, scramble: &[u8], client_response: &[u8]) -> bool {
155        let Some(stored) = self.caching_sha2 else {
156            return false;
157        };
158        if client_response.len() != CACHING_SHA2_HASH_LEN {
159            return false;
160        }
161        if scramble.len() != 20 {
162            return false;
163        }
164        let mut buf = [0u8; 20 + CACHING_SHA2_HASH_LEN];
165        buf[..20].copy_from_slice(scramble);
166        buf[20..].copy_from_slice(&stored);
167        let mask = sha256_bytes(&buf);
168        let mut recovered = [0u8; CACHING_SHA2_HASH_LEN];
169        for i in 0..CACHING_SHA2_HASH_LEN {
170            recovered[i] = client_response[i] ^ mask[i];
171        }
172        let candidate = sha256_bytes(&recovered);
173        constant_time_eq(&candidate, &stored)
174    }
175
176    /// v7.17.0 Phase 3.P0-71 — verify a client
177    /// `mysql_native_password` auth response.
178    ///
179    /// Protocol: the client sends a 20-byte response
180    /// `client_proof = SHA1(password) XOR SHA1(scramble ||
181    /// SHA1(SHA1(password)))`. The server reconstructs
182    /// `SHA1(password) = client_proof XOR SHA1(scramble ||
183    /// stored_hash)` and verifies `SHA1(reconstructed) ==
184    /// stored_hash`. Returns false if the user has no stored
185    /// hash (loaded from a pre-v7.17 snapshot — the operator
186    /// has to reset the password to re-populate it).
187    pub fn verify_mysql_native_password(&self, scramble: &[u8], client_response: &[u8]) -> bool {
188        let Some(stored) = self.mysql_native else {
189            return false;
190        };
191        if client_response.len() != MYSQL_NATIVE_HASH_LEN {
192            return false;
193        }
194        if scramble.len() != 20 {
195            return false;
196        }
197        let mut buf = [0u8; 40];
198        buf[..20].copy_from_slice(scramble);
199        buf[20..].copy_from_slice(&stored);
200        let mask = sha1_bytes(&buf);
201        let mut recovered = [0u8; MYSQL_NATIVE_HASH_LEN];
202        for i in 0..MYSQL_NATIVE_HASH_LEN {
203            recovered[i] = client_response[i] ^ mask[i];
204        }
205        let candidate = sha1_bytes(&recovered);
206        constant_time_eq_sha1(&candidate, &stored)
207    }
208}
209
210/// Compute the `mysql_native_password` stored hash =
211/// SHA1(SHA1(password)). Public so user-creation paths can
212/// populate the field at the same moment they have cleartext.
213#[must_use]
214pub fn compute_mysql_native_hash(password: &str) -> [u8; MYSQL_NATIVE_HASH_LEN] {
215    let inner = sha1_bytes(password.as_bytes());
216    sha1_bytes(&inner)
217}
218
219/// v7.17.0 Phase 3.P0-72 — compute the `caching_sha2_password`
220/// stored hash = SHA256(SHA256(password)). Public for the same
221/// reason as the mysql_native variant.
222#[must_use]
223pub fn compute_caching_sha2_hash(password: &str) -> [u8; CACHING_SHA2_HASH_LEN] {
224    let inner = sha256_bytes(password.as_bytes());
225    sha256_bytes(&inner)
226}
227
228fn sha1_bytes(input: &[u8]) -> [u8; MYSQL_NATIVE_HASH_LEN] {
229    use sha1::Digest;
230    let digest = sha1::Sha1::digest(input);
231    let mut out = [0u8; MYSQL_NATIVE_HASH_LEN];
232    out.copy_from_slice(&digest);
233    out
234}
235
236fn sha256_bytes(input: &[u8]) -> [u8; CACHING_SHA2_HASH_LEN] {
237    use sha2::Digest;
238    let digest = sha2::Sha256::digest(input);
239    let mut out = [0u8; CACHING_SHA2_HASH_LEN];
240    out.copy_from_slice(&digest);
241    out
242}
243
244#[derive(Debug, Clone, Default)]
245pub struct UserStore {
246    users: BTreeMap<String, UserRecord>,
247}
248
249#[derive(Debug, PartialEq, Eq)]
250pub enum UserError {
251    Exists,
252    NotFound,
253    InvalidRole,
254    EmptyName,
255    EmptyPassword,
256}
257
258impl core::fmt::Display for UserError {
259    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
260        match self {
261            Self::Exists => f.write_str("user already exists"),
262            Self::NotFound => f.write_str("user not found"),
263            Self::InvalidRole => {
264                f.write_str("invalid role (expected admin / readwrite / readonly)")
265            }
266            Self::EmptyName => f.write_str("username must be non-empty"),
267            Self::EmptyPassword => f.write_str("password must be non-empty"),
268        }
269    }
270}
271
272impl UserStore {
273    pub fn new() -> Self {
274        Self::default()
275    }
276
277    pub fn len(&self) -> usize {
278        self.users.len()
279    }
280
281    pub fn is_empty(&self) -> bool {
282        self.users.is_empty()
283    }
284
285    pub fn contains(&self, name: &str) -> bool {
286        self.users.contains_key(name)
287    }
288
289    /// Stable iteration in name order — used by SHOW USERS and the
290    /// snapshot writer.
291    /// v7.17.0 Phase 3.P0-71: look up a user by name. Returns
292    /// `None` for unknown names; the caller decides whether to
293    /// surface "Access Denied" or "User not found" (the
294    /// MySQL-wire shim picks the former to avoid leaking user
295    /// existence to unauthenticated clients).
296    #[must_use]
297    pub fn get(&self, name: &str) -> Option<&UserRecord> {
298        self.users.get(name)
299    }
300
301    pub fn iter(&self) -> impl Iterator<Item = (&str, &UserRecord)> {
302        self.users.iter().map(|(k, v)| (k.as_str(), v))
303    }
304
305    pub fn create(
306        &mut self,
307        name: &str,
308        password: &str,
309        role: Role,
310        salt: [u8; SALT_LEN],
311    ) -> Result<(), UserError> {
312        if name.is_empty() {
313            return Err(UserError::EmptyName);
314        }
315        if password.is_empty() {
316            return Err(UserError::EmptyPassword);
317        }
318        if self.users.contains_key(name) {
319            return Err(UserError::Exists);
320        }
321        let hash = derive_hash(&salt, password);
322        let mysql_native = Some(compute_mysql_native_hash(password));
323        let caching_sha2 = Some(compute_caching_sha2_hash(password));
324        self.users.insert(
325            name.to_string(),
326            UserRecord {
327                role,
328                salt,
329                hash,
330                scram: None,
331                mysql_native,
332                caching_sha2,
333            },
334        );
335        Ok(())
336    }
337
338    pub fn drop(&mut self, name: &str) -> Result<(), UserError> {
339        self.users
340            .remove(name)
341            .map(|_| ())
342            .ok_or(UserError::NotFound)
343    }
344
345    /// v4.8: attach SCRAM-SHA-256 verifier to an existing user.
346    /// Called by the engine right after `create` so new users have
347    /// both auth paths (legacy BLAKE3 + SCRAM) available. The salt
348    /// here is independent of the BLAKE3 hash salt — they serve
349    /// different purposes.
350    pub fn enable_scram(
351        &mut self,
352        name: &str,
353        password: &str,
354        salt: [u8; SCRAM_SALT_LEN],
355        iters: u32,
356    ) -> Result<(), UserError> {
357        let rec = self.users.get_mut(name).ok_or(UserError::NotFound)?;
358        rec.scram = Some(compute_scram_secrets(password, salt, iters));
359        Ok(())
360    }
361
362    pub fn verify(&self, name: &str, password: &str) -> Option<Role> {
363        let rec = self.users.get(name)?;
364        if rec.verify(password) {
365            Some(rec.role)
366        } else {
367            None
368        }
369    }
370}
371
372fn derive_hash(salt: &[u8; SALT_LEN], password: &str) -> [u8; HASH_LEN] {
373    let mut buf = Vec::with_capacity(SALT_LEN + password.len());
374    buf.extend_from_slice(salt);
375    buf.extend_from_slice(password.as_bytes());
376    spg_crypto::hash(&buf)
377}
378
379/// v4.8: derive SCRAM-SHA-256 stored credentials per RFC 5802 §3.
380///
381/// `SaltedPassword` = `PBKDF2(password, salt, iters)`
382/// `ClientKey`      = `HMAC(SaltedPassword, "Client Key")`
383/// `StoredKey`      = `SHA-256(ClientKey)`
384/// `ServerKey`      = `HMAC(SaltedPassword, "Server Key")`
385///
386/// PG-wire keeps the `StoredKey` + `ServerKey` on disk; verifying a
387/// client SCRAM proof needs only the `StoredKey` (no plaintext
388/// password ever stored).
389pub fn compute_scram_secrets(
390    password: &str,
391    salt: [u8; SCRAM_SALT_LEN],
392    iters: u32,
393) -> ScramSecrets {
394    let salted = spg_crypto::pbkdf2::pbkdf2_sha256_32(password.as_bytes(), &salt, iters);
395    let client_key = spg_crypto::hmac::hmac_sha256(&salted, b"Client Key");
396    let stored_key = spg_crypto::sha256::hash(&client_key);
397    let server_key = spg_crypto::hmac::hmac_sha256(&salted, b"Server Key");
398    ScramSecrets {
399        iters,
400        salt,
401        stored_key,
402        server_key,
403    }
404}
405
406/// Branch-free byte compare so verify timing doesn't leak whether
407/// a prefix matched.
408fn constant_time_eq(a: &[u8; HASH_LEN], b: &[u8; HASH_LEN]) -> bool {
409    let mut diff: u8 = 0;
410    for i in 0..HASH_LEN {
411        diff |= a[i] ^ b[i];
412    }
413    diff == 0
414}
415
416/// v7.17.0 Phase 3.P0-71 — same idea, sized for the SHA-1 digest
417/// used by `mysql_native_password`.
418fn constant_time_eq_sha1(a: &[u8; MYSQL_NATIVE_HASH_LEN], b: &[u8; MYSQL_NATIVE_HASH_LEN]) -> bool {
419    let mut diff: u8 = 0;
420    for i in 0..MYSQL_NATIVE_HASH_LEN {
421        diff |= a[i] ^ b[i];
422    }
423    diff == 0
424}
425
426// ---- snapshot encoding ----
427//
428// Layout (after a magic + version envelope handled at Engine level):
429//
430// v1 (v4.1.0 — original):
431//   [u32 user_count]
432//   for each user:
433//     [u16 name_len][name][u8 role][16 salt][32 hash]
434//
435// v2 (v4.8.0 — adds SCRAM):
436//   [u8 format_version = 2]    // distinguishes from v1 (where the
437//                                 first byte is the LO of user_count
438//                                 u32, never 0xff)
439//   [u32 user_count]
440//   for each user:
441//     [u16 name_len][name][u8 role][16 salt][32 hash]
442//     [u8 scram_present]       // 0 or 1
443//     if scram_present:
444//       [u32 iters][16 scram_salt][32 stored_key][32 server_key]
445//
446// We use byte 0xff as the v2 marker — v1 would have to have ≥
447// 4 billion users for its first byte to be 0xff, so the version
448// switch is unambiguous.
449
450const SCRAM_FORMAT_MARKER: u8 = 0xff;
451/// v7.17.0 Phase 3.P0-71 — v3 format marker. v3 extends v2 by
452/// appending an optional `mysql_native_password` SHA1(SHA1(pwd))
453/// per user.
454const MYSQL_NATIVE_FORMAT_MARKER: u8 = 0xfe;
455/// v7.17.0 Phase 3.P0-72 — v4 format marker. v4 extends v3 by
456/// also appending an optional `caching_sha2_password`
457/// SHA256(SHA256(pwd)) per user. Writer always emits v4;
458/// reader understands v1 / v2 / v3 / v4.
459const CACHING_SHA2_FORMAT_MARKER: u8 = 0xfd;
460
461pub(crate) fn serialize_users(store: &UserStore) -> Vec<u8> {
462    let per_user_floor = 2 + 16 + 1 + SALT_LEN + HASH_LEN + 1 + 1;
463    let mut out = Vec::with_capacity(1 + 4 + store.len() * per_user_floor);
464    // v7.17.0 Phase 3.P0-72 — bump on-disk format to v4 so the
465    // per-user `caching_sha2_password` hash trails the
466    // mysql_native block.
467    out.push(CACHING_SHA2_FORMAT_MARKER);
468    out.extend_from_slice(
469        &u32::try_from(store.users.len())
470            .expect("≤ 4G users")
471            .to_le_bytes(),
472    );
473    for (name, rec) in &store.users {
474        let nl = u16::try_from(name.len()).expect("≤ 65k name");
475        out.extend_from_slice(&nl.to_le_bytes());
476        out.extend_from_slice(name.as_bytes());
477        out.push(match rec.role {
478            Role::Admin => 0,
479            Role::ReadWrite => 1,
480            Role::ReadOnly => 2,
481        });
482        out.extend_from_slice(&rec.salt);
483        out.extend_from_slice(&rec.hash);
484        match &rec.scram {
485            None => out.push(0),
486            Some(s) => {
487                out.push(1);
488                out.extend_from_slice(&s.iters.to_le_bytes());
489                out.extend_from_slice(&s.salt);
490                out.extend_from_slice(&s.stored_key);
491                out.extend_from_slice(&s.server_key);
492            }
493        }
494        match &rec.mysql_native {
495            None => out.push(0),
496            Some(h) => {
497                out.push(1);
498                out.extend_from_slice(h);
499            }
500        }
501        match &rec.caching_sha2 {
502            None => out.push(0),
503            Some(h) => {
504                out.push(1);
505                out.extend_from_slice(h);
506            }
507        }
508    }
509    out
510}
511
512#[derive(Debug)]
513pub enum UserDeserializeError {
514    Truncated,
515    BadRole(u8),
516    InvalidUtf8,
517}
518
519impl core::fmt::Display for UserDeserializeError {
520    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
521        match self {
522            Self::Truncated => f.write_str("user blob truncated"),
523            Self::BadRole(b) => write!(f, "unknown role byte: {b}"),
524            Self::InvalidUtf8 => f.write_str("username not valid UTF-8"),
525        }
526    }
527}
528
529fn take<'a>(p: &mut usize, n: usize, buf: &'a [u8]) -> Result<&'a [u8], UserDeserializeError> {
530    if *p + n > buf.len() {
531        return Err(UserDeserializeError::Truncated);
532    }
533    let s = &buf[*p..*p + n];
534    *p += n;
535    Ok(s)
536}
537
538pub(crate) fn deserialize_users(buf: &[u8]) -> Result<UserStore, UserDeserializeError> {
539    let mut p = 0usize;
540    // v1 → starts with a u32 user_count (LO byte rarely 0xfd /
541    // 0xfe / 0xff in practice).
542    // v2 → 0xff marker (SCRAM_FORMAT_MARKER) then u32 count.
543    // v3 → 0xfe marker (MYSQL_NATIVE_FORMAT_MARKER) then u32
544    //      count; per-user payload adds a 1-byte flag + 20-byte
545    //      SHA1(SHA1(pwd)) tail for `mysql_native_password`.
546    // v4 → 0xfd marker (CACHING_SHA2_FORMAT_MARKER) then u32
547    //      count; per-user payload further adds 1-byte flag +
548    //      32-byte SHA256(SHA256(pwd)) tail for
549    //      `caching_sha2_password`.
550    let (scram_present_inline, mysql_native_present_inline, caching_sha2_present_inline) =
551        if !buf.is_empty() && buf[0] == CACHING_SHA2_FORMAT_MARKER {
552            p += 1;
553            (true, true, true)
554        } else if !buf.is_empty() && buf[0] == MYSQL_NATIVE_FORMAT_MARKER {
555            p += 1;
556            (true, true, false)
557        } else if !buf.is_empty() && buf[0] == SCRAM_FORMAT_MARKER {
558            p += 1;
559            (true, false, false)
560        } else {
561            (false, false, false)
562        };
563    let count_bytes = take(&mut p, 4, buf)?;
564    let count = u32::from_le_bytes(count_bytes.try_into().unwrap()) as usize;
565    let mut store = UserStore::new();
566    for _ in 0..count {
567        let nl_bytes = take(&mut p, 2, buf)?;
568        let nl = u16::from_le_bytes(nl_bytes.try_into().unwrap()) as usize;
569        let name_bytes = take(&mut p, nl, buf)?;
570        let name = core::str::from_utf8(name_bytes)
571            .map_err(|_| UserDeserializeError::InvalidUtf8)?
572            .to_string();
573        let role_byte = take(&mut p, 1, buf)?[0];
574        let role = match role_byte {
575            0 => Role::Admin,
576            1 => Role::ReadWrite,
577            2 => Role::ReadOnly,
578            b => return Err(UserDeserializeError::BadRole(b)),
579        };
580        let mut salt = [0u8; SALT_LEN];
581        salt.copy_from_slice(take(&mut p, SALT_LEN, buf)?);
582        let mut hash = [0u8; HASH_LEN];
583        hash.copy_from_slice(take(&mut p, HASH_LEN, buf)?);
584        let scram = if scram_present_inline {
585            let flag = take(&mut p, 1, buf)?[0];
586            if flag == 1 {
587                let iters_bytes = take(&mut p, 4, buf)?;
588                let iters = u32::from_le_bytes(iters_bytes.try_into().unwrap());
589                let mut s_salt = [0u8; SCRAM_SALT_LEN];
590                s_salt.copy_from_slice(take(&mut p, SCRAM_SALT_LEN, buf)?);
591                let mut stored_key = [0u8; HASH_LEN];
592                stored_key.copy_from_slice(take(&mut p, HASH_LEN, buf)?);
593                let mut server_key = [0u8; HASH_LEN];
594                server_key.copy_from_slice(take(&mut p, HASH_LEN, buf)?);
595                Some(ScramSecrets {
596                    iters,
597                    salt: s_salt,
598                    stored_key,
599                    server_key,
600                })
601            } else {
602                None
603            }
604        } else {
605            None
606        };
607        let mysql_native = if mysql_native_present_inline {
608            let flag = take(&mut p, 1, buf)?[0];
609            if flag == 1 {
610                let mut h = [0u8; MYSQL_NATIVE_HASH_LEN];
611                h.copy_from_slice(take(&mut p, MYSQL_NATIVE_HASH_LEN, buf)?);
612                Some(h)
613            } else {
614                None
615            }
616        } else {
617            None
618        };
619        let caching_sha2 = if caching_sha2_present_inline {
620            let flag = take(&mut p, 1, buf)?[0];
621            if flag == 1 {
622                let mut h = [0u8; CACHING_SHA2_HASH_LEN];
623                h.copy_from_slice(take(&mut p, CACHING_SHA2_HASH_LEN, buf)?);
624                Some(h)
625            } else {
626                None
627            }
628        } else {
629            None
630        };
631        store.users.insert(
632            name,
633            UserRecord {
634                role,
635                salt,
636                hash,
637                scram,
638                mysql_native,
639                caching_sha2,
640            },
641        );
642    }
643    if p != buf.len() {
644        return Err(UserDeserializeError::Truncated);
645    }
646    Ok(store)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    #[test]
654    fn create_then_verify_succeeds_with_right_password_only() {
655        let mut s = UserStore::new();
656        s.create("alice", "hunter2", Role::Admin, [1; SALT_LEN])
657            .unwrap();
658        assert_eq!(s.verify("alice", "hunter2"), Some(Role::Admin));
659        assert_eq!(s.verify("alice", "wrong"), None);
660        assert_eq!(s.verify("bob", "hunter2"), None);
661    }
662
663    #[test]
664    fn create_duplicate_user_is_rejected() {
665        let mut s = UserStore::new();
666        s.create("a", "p", Role::ReadOnly, [0; SALT_LEN]).unwrap();
667        assert_eq!(
668            s.create("a", "p2", Role::Admin, [0; SALT_LEN]),
669            Err(UserError::Exists)
670        );
671    }
672
673    #[test]
674    fn drop_user_removes_them() {
675        let mut s = UserStore::new();
676        s.create("a", "p", Role::Admin, [0; SALT_LEN]).unwrap();
677        s.drop("a").unwrap();
678        assert!(s.is_empty());
679        assert_eq!(s.drop("a"), Err(UserError::NotFound));
680    }
681
682    #[test]
683    fn role_parse_accepts_aliases() {
684        assert_eq!(Role::parse("ADMIN"), Some(Role::Admin));
685        assert_eq!(Role::parse("rw"), Some(Role::ReadWrite));
686        assert_eq!(Role::parse("ro"), Some(Role::ReadOnly));
687        assert_eq!(Role::parse("god"), None);
688    }
689
690    #[test]
691    fn snapshot_round_trip_preserves_users_and_verify() {
692        let mut s = UserStore::new();
693        s.create("alice", "pw1", Role::Admin, [7; SALT_LEN])
694            .unwrap();
695        s.create("bob", "pw2", Role::ReadOnly, [13; SALT_LEN])
696            .unwrap();
697        let bytes = serialize_users(&s);
698        let s2 = deserialize_users(&bytes).unwrap();
699        assert_eq!(s2.len(), 2);
700        assert_eq!(s2.verify("alice", "pw1"), Some(Role::Admin));
701        assert_eq!(s2.verify("bob", "pw2"), Some(Role::ReadOnly));
702        assert_eq!(s2.verify("bob", "wrong"), None);
703    }
704
705    #[test]
706    fn empty_store_round_trip() {
707        // v7.17.0 Phase 3.P0-72: writer flipped to v4 marker (0xfd).
708        let s = UserStore::new();
709        let bytes = serialize_users(&s);
710        assert_eq!(bytes, [0xfd, 0, 0, 0, 0]);
711        let s2 = deserialize_users(&bytes).unwrap();
712        assert!(s2.is_empty());
713    }
714
715    #[test]
716    fn v2_blob_still_loads_with_mysql_native_none() {
717        // v7.17.0 Phase 3.P0-71: cross-version compat — readers
718        // must still parse v2-shaped blobs written before the v3
719        // bump and surface `mysql_native = None` for those
720        // users so the operator knows to reset the password.
721        let mut buf = Vec::new();
722        buf.push(0xff); // v2 marker
723        buf.extend_from_slice(&1u32.to_le_bytes());
724        buf.extend_from_slice(&3u16.to_le_bytes());
725        buf.extend_from_slice(b"old");
726        buf.push(0); // role = admin
727        buf.extend_from_slice(&[1u8; SALT_LEN]);
728        buf.extend_from_slice(&[2u8; HASH_LEN]);
729        buf.push(0); // no SCRAM
730        let s = deserialize_users(&buf).unwrap();
731        let rec = s.get("old").expect("v2 user loads");
732        assert!(rec.mysql_native().is_none());
733    }
734}
735
736#[cfg(test)]
737mod p0_71_tests {
738    use super::*;
739
740    #[test]
741    fn create_populates_mysql_native_hash() {
742        let mut s = UserStore::new();
743        s.create("alice", "wonderland", Role::Admin, [9u8; SALT_LEN])
744            .unwrap();
745        let rec = s.get("alice").unwrap();
746        let expected = compute_mysql_native_hash("wonderland");
747        assert_eq!(rec.mysql_native(), Some(&expected));
748    }
749
750    #[test]
751    fn verify_mysql_native_password_accepts_correct_response() {
752        // Build a fake scramble + the canonical client response.
753        let mut s = UserStore::new();
754        s.create("bob", "secret", Role::Admin, [3u8; SALT_LEN])
755            .unwrap();
756        let rec = s.get("bob").unwrap();
757        let scramble: [u8; 20] = core::array::from_fn(|i| (i as u8).wrapping_mul(7));
758        // Compute the same response the client would.
759        let sha1_pwd = sha1_bytes(b"secret");
760        let sha1_sha1_pwd = sha1_bytes(&sha1_pwd);
761        let mut concat = [0u8; 40];
762        concat[..20].copy_from_slice(&scramble);
763        concat[20..].copy_from_slice(&sha1_sha1_pwd);
764        let mask = sha1_bytes(&concat);
765        let response: [u8; MYSQL_NATIVE_HASH_LEN] = core::array::from_fn(|i| sha1_pwd[i] ^ mask[i]);
766        assert!(rec.verify_mysql_native_password(&scramble, &response));
767        // Tamper with one byte → rejected.
768        let mut bad = response;
769        bad[0] ^= 1;
770        assert!(!rec.verify_mysql_native_password(&scramble, &bad));
771    }
772
773    #[test]
774    fn v4_serialise_round_trips_both_mysql_native_and_caching_sha2() {
775        let mut s = UserStore::new();
776        s.create("alice", "wonderland", Role::Admin, [4u8; SALT_LEN])
777            .unwrap();
778        let bytes = serialize_users(&s);
779        assert_eq!(bytes[0], 0xfd, "v4 marker advertised");
780        let s2 = deserialize_users(&bytes).unwrap();
781        let r1 = s.get("alice").unwrap();
782        let r2 = s2.get("alice").unwrap();
783        assert_eq!(r1.mysql_native(), r2.mysql_native());
784        assert_eq!(r1.caching_sha2(), r2.caching_sha2());
785        // Sanity: both verifiers are populated for a fresh user.
786        assert!(r1.mysql_native().is_some());
787        assert!(r1.caching_sha2().is_some());
788    }
789
790    #[test]
791    fn v3_blob_still_loads_with_caching_sha2_none() {
792        // v7.17.0 Phase 3.P0-72: backward compat — readers must
793        // still parse v3-shaped blobs (mysql_native present,
794        // caching_sha2 missing) written before the v4 bump.
795        let mut buf = Vec::new();
796        buf.push(0xfe); // v3 marker
797        buf.extend_from_slice(&1u32.to_le_bytes());
798        buf.extend_from_slice(&5u16.to_le_bytes());
799        buf.extend_from_slice(b"older");
800        buf.push(0); // role = admin
801        buf.extend_from_slice(&[1u8; SALT_LEN]);
802        buf.extend_from_slice(&[2u8; HASH_LEN]);
803        buf.push(0); // no SCRAM
804        buf.push(1); // mysql_native flag = present
805        buf.extend_from_slice(&[3u8; MYSQL_NATIVE_HASH_LEN]);
806        let s = deserialize_users(&buf).unwrap();
807        let rec = s.get("older").unwrap();
808        assert!(rec.mysql_native().is_some());
809        assert!(rec.caching_sha2().is_none());
810    }
811
812    #[test]
813    fn verify_caching_sha2_password_accepts_correct_response() {
814        let mut s = UserStore::new();
815        s.create("bob", "secret", Role::Admin, [3u8; SALT_LEN])
816            .unwrap();
817        let rec = s.get("bob").unwrap();
818        let scramble: [u8; 20] = core::array::from_fn(|i| (i as u8).wrapping_mul(11));
819        let sha_pwd = sha256_bytes(b"secret");
820        let sha_sha_pwd = sha256_bytes(&sha_pwd);
821        let mut concat = [0u8; 20 + CACHING_SHA2_HASH_LEN];
822        concat[..20].copy_from_slice(&scramble);
823        concat[20..].copy_from_slice(&sha_sha_pwd);
824        let mask = sha256_bytes(&concat);
825        let response: [u8; CACHING_SHA2_HASH_LEN] = core::array::from_fn(|i| sha_pwd[i] ^ mask[i]);
826        assert!(rec.verify_caching_sha2_password(&scramble, &response));
827        let mut bad = response;
828        bad[0] ^= 1;
829        assert!(!rec.verify_caching_sha2_password(&scramble, &bad));
830    }
831
832    #[test]
833    fn old_v1_user_blob_still_loads() {
834        // Hand-constructed v1 blob: 1 user, no SCRAM byte.
835        // [u32 count=1][u16 name_len=3]["bob"][u8 role=0][16 salt][32 hash]
836        let mut buf = Vec::new();
837        buf.extend_from_slice(&1u32.to_le_bytes());
838        buf.extend_from_slice(&3u16.to_le_bytes());
839        buf.extend_from_slice(b"bob");
840        buf.push(0); // role = admin
841        buf.extend_from_slice(&[7u8; SALT_LEN]);
842        buf.extend_from_slice(&[42u8; HASH_LEN]);
843        let s = deserialize_users(&buf).expect("v1 blob must still load");
844        assert_eq!(s.len(), 1);
845        let (n, rec) = s.iter().next().unwrap();
846        assert_eq!(n, "bob");
847        assert_eq!(rec.role, Role::Admin);
848        assert!(rec.scram().is_none(), "v1 users have no SCRAM secrets");
849    }
850
851    #[test]
852    fn scram_round_trip_preserves_iters_salt_keys() {
853        let mut s = UserStore::new();
854        s.create("alice", "pw", Role::Admin, [3; SALT_LEN]).unwrap();
855        s.enable_scram("alice", "pw", [9; SCRAM_SALT_LEN], 4096)
856            .unwrap();
857        let bytes = serialize_users(&s);
858        let s2 = deserialize_users(&bytes).unwrap();
859        let (_, rec) = s2.iter().next().unwrap();
860        let scram = rec.scram().expect("scram must round-trip");
861        assert_eq!(scram.iters, 4096);
862        assert_eq!(scram.salt, [9u8; SCRAM_SALT_LEN]);
863        // StoredKey and ServerKey are deterministic given (password,
864        // salt, iters); verify by recomputing.
865        let expected = compute_scram_secrets("pw", [9; SCRAM_SALT_LEN], 4096);
866        assert_eq!(scram.stored_key, expected.stored_key);
867        assert_eq!(scram.server_key, expected.server_key);
868    }
869
870    #[test]
871    fn deserialize_truncation_is_caught() {
872        assert!(deserialize_users(&[]).is_err());
873        assert!(deserialize_users(&[0, 0, 0]).is_err());
874    }
875}