1use alloc::collections::BTreeMap;
18use alloc::string::{String, ToString};
19use alloc::vec::Vec;
20
21const SALT_LEN: usize = 16;
22const HASH_LEN: usize = 32;
23pub const MYSQL_NATIVE_HASH_LEN: usize = 20;
26pub 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 pub const fn can_read(self) -> bool {
58 true
59 }
60
61 pub const fn can_write(self) -> bool {
63 matches!(self, Self::Admin | Self::ReadWrite)
64 }
65
66 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 scram: Option<ScramSecrets>,
84 mysql_native: Option<[u8; MYSQL_NATIVE_HASH_LEN]>,
93 caching_sha2: Option<[u8; CACHING_SHA2_HASH_LEN]>,
100}
101
102#[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 pub const fn mysql_native(&self) -> Option<&[u8; MYSQL_NATIVE_HASH_LEN]> {
131 self.mysql_native.as_ref()
132 }
133
134 pub const fn caching_sha2(&self) -> Option<&[u8; CACHING_SHA2_HASH_LEN]> {
138 self.caching_sha2.as_ref()
139 }
140
141 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 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#[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#[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 #[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 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
379pub 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
406fn 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
416fn 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
426const SCRAM_FORMAT_MARKER: u8 = 0xff;
451const MYSQL_NATIVE_FORMAT_MARKER: u8 = 0xfe;
455const 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 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 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 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 let mut buf = Vec::new();
722 buf.push(0xff); 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); buf.extend_from_slice(&[1u8; SALT_LEN]);
728 buf.extend_from_slice(&[2u8; HASH_LEN]);
729 buf.push(0); 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 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 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 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 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 let mut buf = Vec::new();
796 buf.push(0xfe); 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); buf.extend_from_slice(&[1u8; SALT_LEN]);
802 buf.extend_from_slice(&[2u8; HASH_LEN]);
803 buf.push(0); buf.push(1); 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 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); 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 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}