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