1use 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;
27pub const MYSQL_NATIVE_HASH_LEN: usize = 20;
30pub 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 pub const fn can_read(self) -> bool {
62 true
63 }
64
65 pub const fn can_write(self) -> bool {
67 matches!(self, Self::Admin | Self::ReadWrite)
68 }
69
70 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 scram: Option<ScramSecrets>,
88 mysql_native: Option<[u8; MYSQL_NATIVE_HASH_LEN]>,
97 caching_sha2: Option<[u8; CACHING_SHA2_HASH_LEN]>,
104}
105
106#[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 pub const fn mysql_native(&self) -> Option<&[u8; MYSQL_NATIVE_HASH_LEN]> {
135 self.mysql_native.as_ref()
136 }
137
138 pub const fn caching_sha2(&self) -> Option<&[u8; CACHING_SHA2_HASH_LEN]> {
142 self.caching_sha2.as_ref()
143 }
144
145 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 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#[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#[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 #[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 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
383pub 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
410fn 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
420fn 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
430const SCRAM_FORMAT_MARKER: u8 = 0xff;
455const MYSQL_NATIVE_FORMAT_MARKER: u8 = 0xfe;
459const 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 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 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 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 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 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 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 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 let mut buf = Vec::new();
786 buf.push(0xff); 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); buf.extend_from_slice(&[1u8; SALT_LEN]);
792 buf.extend_from_slice(&[2u8; HASH_LEN]);
793 buf.push(0); 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 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 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 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 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 let mut buf = Vec::new();
860 buf.push(0xfe); 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); buf.extend_from_slice(&[1u8; SALT_LEN]);
866 buf.extend_from_slice(&[2u8; HASH_LEN]);
867 buf.push(0); buf.push(1); 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 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); 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 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}