Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_crypto/src/keystore.rs

27.0 KiB, 142 runs

created by r1870400018:11540, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Encrypted multi-admin keystore.
2//!
3//! A [`Wallet`] holds one symmetric master key protected by any number
4//! of password-derived wraps, each owned by a named administrator.
5//! Anyone who can supply a password that unwraps one of the entries
6//! authenticates as that admin and recovers the master key. The
7//! pattern mirrors LUKS key slots, PGP multi-recipient encryption and
8//! `age`'s multi-recipient files: one master, many wraps, adding or
9//! revoking a password-holder does not disturb the others.
10//!
11//! The keystore itself is transport-agnostic -- callers supply
12//! password bytes from whatever source suits them (interactive stdin,
13//! an environment variable, an OS keyring, a signed unlock envelope
14//! from a remote administrator). Only the password bytes cross the
15//! module boundary; how they were obtained is the caller's concern.
16//!
17//! # On-disk layout
18//!
19//! A wallet serialises to a JDAT ordered map:
20//!
21//! - `metadata` -- application-owned plaintext (app name, root path,
22//! whatever the host wants stamped on the wallet).
23//! - `admins` -- a list of [`AdminUser`] entries, each carrying its
24//! own [`WrappedKey`], scope list and expiry.
25//! - `app_encrypted_secrets` -- a map of application-owned encrypted
26//! secrets. Each value is independently encrypted; the keystore
27//! does not interpret the bytes.
28//!
29//! Admin names and scopes are plaintext by design: an attacker with
30//! disk access can enumerate admins without decrypting. The threat
31//! model protects against stolen files, not against admin
32//! enumeration.
33//!
34//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
35//! Anthropic Claude
36
37use crate::enc::EncryptionScheme;
38
39use oxedyne_fe2o3_core::prelude::*;
40use oxedyne_fe2o3_hash::kdf::KeyDerivationScheme;
41use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
42use oxedyne_fe2o3_iop_hash::kdf::KeyDeriver;
43use oxedyne_fe2o3_jdat::{
44 prelude::*,
45 file::JdatFile,
46};
47use oxedyne_fe2o3_text::base2x;
48
49use std::{
50 fmt,
51 time::{
52 SystemTime,
53 UNIX_EPOCH,
54 },
55};
56
57use rand_core::{
58 OsRng,
59 RngCore,
60};
61
62use secrecy::{
63 ExposeSecret,
64 Secret,
65};
66
67
68// Matches AES-256-GCM's key size, so the master key goes straight to
69// EncryptionScheme with no intermediate derivation.
70pub const WALLET_MASTER_KEY_LEN: usize = 32;
71
72
73// ┌───────────────────────────────────────────────────────────────────────────┐
74// │ KEY WRAPPING PRIMITIVES │
75// │ │
76// │ Wrap a symmetric master key with a password-derived key encryption key │
77// │ (KEK) so it can be safely stored on disk alongside the KDF parameters. │
78// │ The caller recovers the master key by re-deriving the KEK from the same │
79// │ password and decrypting the wrap. The pattern is the same as LUKS's key │
80// │ slots, PGP's multiple-recipient encryption, and `age`'s multi-recipient │
81// │ files: one master key, any number of independent wraps, adding or │
82// │ revoking a password-holder does not disturb the others. │
83// └───────────────────────────────────────────────────────────────────────────┘
84
85/// Every field is a plain string, so a wrap serialises straight into a JDAT
86/// file beside the rest of an admin entry.
87#[derive(Clone, Debug, Default)]
88pub struct WrappedKey {
89 pub kdf_name: String, // e.g. "Argon2id_v0x13"
90 // Salt and parameters, no hash; round-trips through
91 // KeyDerivationScheme::decode_cfg_from_string.
92 pub kdf_cfg: String,
93 pub enc_name: String, // e.g. "AES-256-GCM"
94 // Base2x HEMATITE64 over whatever EncryptionScheme::encrypt produced. For
95 // AES-256-GCM that is ciphertext with the tag embedded and the nonce
96 // appended, so the blob is self-contained.
97 pub wrapped_key: String,
98}
99
100/// Wraps `master_key` under a key derived from `password`.
101///
102/// The salt is freshly randomised, so the same master key and the same
103/// password wrap differently every time.
104pub fn wrap_master_key(
105 master_key: &[u8],
106 password: &[u8],
107 kdf_name: &str,
108)
109 -> Outcome<WrappedKey>
110{
111 // Derive the KEK from the password with a fresh salt. The KDF is
112 // stateful: `derive` stores the output internally, which we then
113 // read via `get_hash`.
114 let mut kdf = res!(KeyDerivationScheme::from_str(kdf_name));
115 res!(kdf.derive(password));
116 let kek = res!(kdf.get_hash()).to_vec();
117
118 // Wrap with AES-256-GCM. `EncryptionScheme::encrypt` appends the
119 // 12-byte nonce to the ciphertext and embeds the GCM auth tag in
120 // the ciphertext, so the returned bytes are self-contained.
121 let enc = res!(EncryptionScheme::new_aes_256_gcm_with_key(&kek));
122 let wrap_bytes = res!(enc.encrypt(master_key));
123
124 Ok(WrappedKey {
125 kdf_name: kdf_name.to_string(),
126 kdf_cfg: res!(kdf.encode_cfg_to_string()),
127 enc_name: fmt!("{:?}", enc),
128 wrapped_key: base2x::HEMATITE64.to_string(&wrap_bytes),
129 })
130}
131
132/// Recovers a master key from a wrap.
133///
134/// A wrong password and a corrupt wrap both fail the GCM tag check and are
135/// indistinguishable here, so a caller trying each admin entry in turn should
136/// read any error as "not this entry" rather than as a fault.
137pub fn unwrap_master_key(
138 wrapped: &WrappedKey,
139 password: &[u8],
140)
141 -> Outcome<Vec<u8>>
142{
143 // Re-derive the KEK from the supplied password and the stored
144 // KDF config.
145 let mut kdf = res!(KeyDerivationScheme::from_str(&wrapped.kdf_name));
146 res!(kdf.decode_cfg_from_string(&wrapped.kdf_cfg));
147 res!(kdf.derive(password));
148 let kek = res!(kdf.get_hash()).to_vec();
149
150 // Decode the wrap blob and decrypt.
151 let wrap_bytes = res!(base2x::HEMATITE64.from_str(&wrapped.wrapped_key));
152 let enc = res!(EncryptionScheme::new_aes_256_gcm_with_key(&kek));
153 let plain = res!(enc.decrypt(&wrap_bytes));
154 Ok(plain)
155}
156
157
158// ┌───────────────────────────────────────────────────────────────────────────┐
159// │ ADMIN USER │
160// └───────────────────────────────────────────────────────────────────────────┘
161
162/// A single administrator entry in the wallet.
163///
164/// Anyone who supplies a password that unwraps an entry has authenticated as
165/// that admin and holds the master key; the wallet does not care which entry it
166/// was. Scope and expiry are checked only after the wrap decrypts, so an admin
167/// with no scopes still unlocks the wallet and can then invoke nothing.
168#[derive(Clone, Debug, Default)]
169pub struct AdminUser {
170 // Audit output and `list` only; lookup is by which wrap the password opens.
171 pub name: String,
172 pub scopes: Vec<String>, // verbs, or "*" for all; "admin" manages entries
173 pub expires_at: u64, // seconds since epoch, 0 for never
174 pub wrap: WrappedKey,
175}
176
177impl AdminUser {
178 /// Creates an admin entry, wrapping `master_key` under `password`.
179 pub fn new(
180 name: impl Into<String>,
181 password: &[u8],
182 master_key: &[u8],
183 kdf_name: &str,
184 scopes: Vec<String>,
185 expires_at: u64,
186 )
187 -> Outcome<Self>
188 {
189 let wrap = res!(wrap_master_key(master_key, password, kdf_name));
190 Ok(Self {
191 name: name.into(),
192 scopes,
193 expires_at,
194 wrap,
195 })
196 }
197
198 /// A wrong password is `Ok(None)`, not an error: trying every entry in turn
199 /// is the intended use, and a failed wrap is not a fault.
200 pub fn try_unwrap(&self, password: &[u8]) -> Outcome<Option<Vec<u8>>> {
201 match unwrap_master_key(&self.wrap, password) {
202 Ok(k) => Ok(Some(k)),
203 Err(_) => Ok(None),
204 }
205 }
206
207 /// Is this admin past its expiry?
208 pub fn is_expired(&self) -> bool {
209 if self.expires_at == 0 { return false; }
210 let now = SystemTime::now()
211 .duration_since(UNIX_EPOCH)
212 .map(|d| d.as_secs())
213 .unwrap_or(0);
214 now >= self.expires_at
215 }
216
217 /// Is this admin authorised for `verb`?
218 pub fn has_scope(&self, verb: &str) -> bool {
219 self.scopes.iter().any(|s| s == "*" || s == verb)
220 }
221}
222
223impl ToDat for AdminUser {
224 fn to_dat(&self) -> Outcome<Dat> {
225 let scopes_dat: Vec<Dat> = self.scopes.iter()
226 .map(|s| dat!(s.clone()))
227 .collect();
228 let mut m = DaticleMap::new();
229 m.insert(dat!("name"), dat!(self.name.clone()));
230 m.insert(dat!("kdf_name"), dat!(self.wrap.kdf_name.clone()));
231 m.insert(dat!("kdf_cfg"), dat!(self.wrap.kdf_cfg.clone()));
232 m.insert(dat!("enc_name"), dat!(self.wrap.enc_name.clone()));
233 m.insert(dat!("scopes"), Dat::List(scopes_dat));
234 m.insert(dat!("expires_at"), dat!(self.expires_at));
235 m.insert(dat!("wrapped_key"), dat!(self.wrap.wrapped_key.clone()));
236 Ok(Dat::Map(m))
237 }
238}
239
240impl FromDat for AdminUser {
241 fn from_dat(mut dat: Dat) -> Outcome<Self> {
242 let name = try_extract_dat!(
243 res!(dat.map_remove_must(&dat!("name"))),
244 Str,
245 );
246 let kdf_name = try_extract_dat!(
247 res!(dat.map_remove_must(&dat!("kdf_name"))),
248 Str,
249 );
250 let kdf_cfg = try_extract_dat!(
251 res!(dat.map_remove_must(&dat!("kdf_cfg"))),
252 Str,
253 );
254 let enc_name = try_extract_dat!(
255 res!(dat.map_remove_must(&dat!("enc_name"))),
256 Str,
257 );
258 let scopes_dat = try_extract_dat!(
259 res!(dat.map_remove_must(&dat!("scopes"))),
260 List,
261 );
262 let mut scopes = Vec::with_capacity(scopes_dat.len());
263 for d in scopes_dat {
264 scopes.push(try_extract_dat!(d, Str));
265 }
266 let expires_at = match res!(dat.map_remove_must(&dat!("expires_at"))) {
267 Dat::U64(n) => n,
268 Dat::U32(n) => n as u64,
269 other => return Err(err!(
270 "AdminUser: 'expires_at' must be u64 (got {:?}).", other.kind();
271 Input, Mismatch)),
272 };
273 let wrapped_key = try_extract_dat!(
274 res!(dat.map_remove_must(&dat!("wrapped_key"))),
275 Str,
276 );
277 Ok(Self {
278 name,
279 scopes,
280 expires_at,
281 wrap: WrappedKey {
282 kdf_name,
283 kdf_cfg,
284 enc_name,
285 wrapped_key,
286 },
287 })
288 }
289}
290
291
292// ┌───────────────────────────────────────────────────────────────────────────┐
293// │ UNLOCKED WALLET │
294// └───────────────────────────────────────────────────────────────────────────┘
295
296/// The result of successfully unlocking a wallet.
297///
298/// The master key sits in a `Secret` so the bytes clear when the struct drops.
299/// The matched admin is copied in by value rather than borrowed, so an unlocked
300/// wallet can be passed around freely.
301///
302/// `Clone` is written out by hand because `secrecy::Secret<Vec<u8>>` does not
303/// derive it: `CloneableSecret` is not satisfied for `Vec<u8>`.
304pub struct UnlockedWallet {
305 pub master_key: Secret<Vec<u8>>, // 32 bytes
306 pub admin_name: String,
307 pub admin_scopes: Vec<String>,
308}
309
310impl Clone for UnlockedWallet {
311 fn clone(&self) -> Self {
312 Self {
313 master_key: Secret::new(self.master_key.expose_secret().clone()),
314 admin_name: self.admin_name.clone(),
315 admin_scopes: self.admin_scopes.clone(),
316 }
317 }
318}
319
320impl fmt::Debug for UnlockedWallet {
321 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322 f.debug_struct("UnlockedWallet")
323 .field("master_key", &"<redacted>")
324 .field("admin_name", &self.admin_name)
325 .field("admin_scopes", &self.admin_scopes)
326 .finish()
327 }
328}
329
330impl UnlockedWallet {
331 /// Is the matched admin authorised for `verb`?
332 pub fn has_scope(&self, verb: &str) -> bool {
333 self.admin_scopes.iter().any(|s| s == "*" || s == verb)
334 }
335}
336
337
338// ┌───────────────────────────────────────────────────────────────────────────┐
339// │ WALLET │
340// │ │
341// │ Holds public metadata, the list of admin entries (each with its own │
342// │ wrapped copy of the shared master key) and a map of application │
343// │ encrypted secrets. Persisted to disk as a JDAT file via the `JdatFile` │
344// │ trait. │
345// └───────────────────────────────────────────────────────────────────────────┘
346
347// Argon2id at the 0x13 parameter set, which is the OWASP recommendation.
348pub const DEFAULT_WALLET_KDF_NAME: &str = "Argon2id_v0x13";
349
350/// Multi-admin wallet: one master key, many wraps.
351///
352/// The master key never lives on disk; only its per-admin wraps do. Anyone
353/// holding a password that opens one entry recovers it, and with it every
354/// application secret in `enc_secs`.
355#[derive(Clone, Debug, Default)]
356pub struct Wallet {
357 metadata: DaticleMap,
358 admins: Vec<AdminUser>,
359 enc_secs: DaticleMap,
360}
361
362impl ToDat for Wallet {
363 fn to_dat(&self) -> Outcome<Dat> {
364 let mut admins_dat = Vec::with_capacity(self.admins.len());
365 for a in &self.admins {
366 admins_dat.push(res!(a.to_dat()));
367 }
368 Ok(omapdat!{
369 "metadata" => Dat::Map(self.metadata.clone()),
370 "admins" => Dat::List(admins_dat),
371 "app_encrypted_secrets" => Dat::Map(self.enc_secs.clone()),
372 })
373 }
374}
375
376impl FromDat for Wallet {
377 fn from_dat(mut dat: Dat) -> Outcome<Self> {
378 if dat.kind() != Kind::OrdMap {
379 return Err(err!(
380 "Wallet must be a Dat::OrdMap, found a {:?}.", dat.kind();
381 Input, Invalid, Mismatch));
382 }
383 let metadata = try_extract_dat!(
384 res!(dat.map_remove_must(&dat!("metadata"))),
385 Map,
386 );
387 let admins_dat = try_extract_dat!(
388 res!(dat.map_remove_must(&dat!("admins"))),
389 List,
390 );
391 let mut admins = Vec::with_capacity(admins_dat.len());
392 for d in admins_dat {
393 admins.push(res!(AdminUser::from_dat(d)));
394 }
395 let enc_secs = try_extract_dat!(
396 res!(dat.map_remove_must(&dat!("app_encrypted_secrets"))),
397 Map,
398 );
399 Ok(Self {
400 metadata,
401 admins,
402 enc_secs,
403 })
404 }
405}
406
407impl JdatFile for Wallet {}
408
409impl Wallet {
410 /// Assembles a wallet from parts. Most callers want
411 /// [`Wallet::create_with_first_admin`], which mints the master key too.
412 pub fn new(
413 metadata: DaticleMap,
414 admins: Vec<AdminUser>,
415 enc_secs: DaticleMap,
416 )
417 -> Self
418 {
419 Self {
420 metadata,
421 admins,
422 enc_secs,
423 }
424 }
425
426 /// Creates a wallet, minting a random master key and enrolling the first
427 /// admin under `password`.
428 ///
429 /// The wallet comes back already unlocked, so the caller need not prompt
430 /// again for the password it has just been given.
431 pub fn create_with_first_admin(
432 metadata: DaticleMap,
433 admin_name: impl Into<String>,
434 password: &[u8],
435 kdf_name: &str,
436 )
437 -> Outcome<(Self, UnlockedWallet)>
438 {
439 let mut master = vec![0u8; WALLET_MASTER_KEY_LEN];
440 OsRng.fill_bytes(&mut master);
441
442 let name = admin_name.into();
443 let admin = res!(AdminUser::new(
444 name.clone(),
445 password,
446 &master,
447 kdf_name,
448 vec!["*".to_string()],
449 0,
450 ));
451 let unlocked = UnlockedWallet {
452 master_key: Secret::new(master),
453 admin_name: name,
454 admin_scopes: vec!["*".to_string()],
455 };
456 let wallet = Self {
457 metadata,
458 admins: vec![admin],
459 enc_secs: DaticleMap::new(),
460 };
461 Ok((wallet, unlocked))
462 }
463
464 /// Tries every admin entry against `password`, in declaration order.
465 ///
466 /// Expiry is checked after the wrap decrypts, not before, so an expired
467 /// admin holding the right password is told it has expired rather than
468 /// that the password is wrong. Once you have proved you hold a credential,
469 /// the system says why it still refused you.
470 pub fn unlock(&self, password: &[u8]) -> Outcome<UnlockedWallet> {
471 for admin in &self.admins {
472 let key_opt = res!(admin.try_unwrap(password));
473 if let Some(key) = key_opt {
474 if admin.is_expired() {
475 return Err(err!(
476 "Admin '{}' is past its expiry; refused.",
477 admin.name;
478 Input, Invalid, Security));
479 }
480 return Ok(UnlockedWallet {
481 master_key: Secret::new(key),
482 admin_name: admin.name.clone(),
483 admin_scopes: admin.scopes.clone(),
484 });
485 }
486 }
487 Err(err!(
488 "No admin entry accepted the supplied password.";
489 Input, Invalid, Security, Input))
490 }
491
492 /// Adds an admin entry, wrapping the master key under `new_password`.
493 ///
494 /// `caller_password` does double duty: it proves the caller holds an
495 /// identity, and it is how the master key to be re-wrapped is recovered.
496 pub fn add_admin(
497 &mut self,
498 caller_password: &[u8],
499 new_name: impl Into<String>,
500 new_password: &[u8],
501 new_scopes: Vec<String>,
502 new_expires_at: u64,
503 kdf_name: &str,
504 )
505 -> Outcome<()>
506 {
507 let unlocked = res!(self.unlock(caller_password));
508 if !unlocked.has_scope("admin") {
509 return Err(err!(
510 "Admin '{}' does not have 'admin' scope; cannot add \
511 new admin entries.", unlocked.admin_name;
512 Input, Invalid, Security));
513 }
514 let master = unlocked.master_key.expose_secret().clone();
515 let new_entry = res!(AdminUser::new(
516 new_name,
517 new_password,
518 &master,
519 kdf_name,
520 new_scopes,
521 new_expires_at,
522 ));
523 self.admins.push(new_entry);
524 Ok(())
525 }
526
527 /// Removes the first admin entry named `target_name`.
528 pub fn remove_admin(
529 &mut self,
530 caller_password: &[u8],
531 target_name: &str,
532 )
533 -> Outcome<()>
534 {
535 let unlocked = res!(self.unlock(caller_password));
536 if !unlocked.has_scope("admin") {
537 return Err(err!(
538 "Admin '{}' does not have 'admin' scope; cannot \
539 remove admin entries.", unlocked.admin_name;
540 Input, Invalid, Security));
541 }
542 if self.admins.len() <= 1 {
543 return Err(err!(
544 "Refusing to remove the last remaining admin entry \
545 -- a wallet with no admins cannot be unlocked again.";
546 Invalid, Input));
547 }
548 let before = self.admins.len();
549 self.admins.retain(|a| a.name != target_name);
550 if self.admins.len() == before {
551 return Err(err!(
552 "No admin entry named '{}'.", target_name;
553 Missing, Input));
554 }
555 Ok(())
556 }
557
558 /// Enrols an admin entry against a master key the caller already holds.
559 ///
560 /// **This authenticates nobody and checks no scope**, unlike
561 /// [`Wallet::add_admin`]. It is for a host that unlocked at startup and is
562 /// carrying the master key; that host owns the scope check.
563 pub fn enrol(
564 &mut self,
565 master_key: &[u8],
566 new_name: impl Into<String>,
567 new_pass: &[u8],
568 new_scopes: Vec<String>,
569 new_expires_at: u64,
570 kdf_name: &str,
571 )
572 -> Outcome<()>
573 {
574 let new_entry = res!(AdminUser::new(
575 new_name, new_pass, master_key, kdf_name, new_scopes, new_expires_at,
576 ));
577 self.admins.push(new_entry);
578 Ok(())
579 }
580
581 /// Re-wraps one admin entry under `new_password`, keeping its scopes and
582 /// expiry and leaving every other entry alone.
583 ///
584 /// **This re-authenticates nobody**: holding `master_key` from an earlier
585 /// unlock is the whole of the authority required.
586 pub fn change_password(
587 &mut self,
588 admin_name: &str,
589 master_key: &[u8],
590 new_password: &[u8],
591 kdf_name: &str,
592 )
593 -> Outcome<()>
594 {
595 let admin = match self.admins.iter_mut().find(|a| a.name == admin_name) {
596 Some(a) => a,
597 None => return Err(err!(
598 "No admin entry named '{}'.", admin_name;
599 Input, Missing)),
600 };
601 admin.wrap = res!(wrap_master_key(master_key, new_password, kdf_name));
602 Ok(())
603 }
604
605 /// Removes the first admin entry named `target`.
606 ///
607 /// **This authenticates nobody**, as with [`Wallet::enrol`].
608 pub fn remove_by_name(&mut self, target: &str) -> Outcome<()> {
609 if self.admins.len() <= 1 {
610 return Err(err!(
611 "Refusing to remove the last remaining admin entry \
612 -- a wallet with no admins cannot be unlocked again.";
613 Input, Invalid));
614 }
615 let before = self.admins.len();
616 self.admins.retain(|a| a.name != target);
617 if self.admins.len() == before {
618 return Err(err!(
619 "No admin entry named '{}'.", target;
620 Input, Missing));
621 }
622 Ok(())
623 }
624
625 pub fn metadata(&self) -> &DaticleMap { &self.metadata }
626 pub fn admins(&self) -> &[AdminUser] { &self.admins }
627 pub fn enc_secs(&self) -> &DaticleMap { &self.enc_secs }
628
629 pub fn metadata_mut(&mut self) -> &mut DaticleMap { &mut self.metadata }
630 pub fn admins_mut(&mut self) -> &mut Vec<AdminUser> { &mut self.admins }
631 pub fn enc_secs_mut(&mut self) -> &mut DaticleMap { &mut self.enc_secs }
632}
633
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 #[test]
640 fn test_wrap_unwrap_roundtrip() -> Outcome<()> {
641 let master = vec![0x42u8; WALLET_MASTER_KEY_LEN];
642 let wrap = res!(wrap_master_key(
643 &master, b"correct horse battery staple", DEFAULT_WALLET_KDF_NAME,
644 ));
645 let recovered = res!(unwrap_master_key(&wrap, b"correct horse battery staple"));
646 req!(master, recovered);
647 // Wrong password must fail.
648 req!(unwrap_master_key(&wrap, b"wrong password").is_err(), true);
649 Ok(())
650 }
651
652 #[test]
653 fn test_wallet_create_unlock_roundtrip() -> Outcome<()> {
654 let (wallet, unlocked) = res!(Wallet::create_with_first_admin(
655 DaticleMap::new(),
656 "alice",
657 b"hunter2",
658 DEFAULT_WALLET_KDF_NAME,
659 ));
660 req!(wallet.admins().len(), 1);
661 req!(unlocked.admin_name, "alice".to_string());
662 // Reload via JDAT round-trip.
663 let dat = res!(wallet.to_dat());
664 let wallet2 = res!(Wallet::from_dat(dat));
665 let unlocked2 = res!(wallet2.unlock(b"hunter2"));
666 req!(unlocked.master_key.expose_secret(), unlocked2.master_key.expose_secret());
667 // Wrong password must fail.
668 req!(wallet2.unlock(b"notit").is_err(), true);
669 Ok(())
670 }
671
672 #[test]
673 fn test_wallet_change_password() -> Outcome<()> {
674 let (mut wallet, unlocked) = res!(Wallet::create_with_first_admin(
675 DaticleMap::new(),
676 "alice",
677 b"oldpass",
678 DEFAULT_WALLET_KDF_NAME,
679 ));
680 let master = unlocked.master_key.expose_secret().clone();
681 // Old password works.
682 req!(wallet.unlock(b"oldpass").is_ok(), true);
683 // Rotate alice's password, master key stays the same.
684 res!(wallet.change_password(
685 "alice",
686 &master,
687 b"newpass",
688 DEFAULT_WALLET_KDF_NAME,
689 ));
690 // Old no longer works, new does, master key unchanged.
691 req!(wallet.unlock(b"oldpass").is_err(), true);
692 let u2 = res!(wallet.unlock(b"newpass"));
693 req!(u2.master_key.expose_secret(), &master);
694 req!(u2.admin_name, "alice".to_string());
695 // Scopes preserved.
696 req!(u2.admin_scopes, vec!["*".to_string()]);
697 // Rotation on an unknown name fails.
698 req!(wallet.change_password(
699 "nobody", &master, b"x", DEFAULT_WALLET_KDF_NAME,
700 ).is_err(), true);
701 Ok(())
702 }
703
704 #[test]
705 fn test_wallet_add_remove_admin() -> Outcome<()> {
706 let (mut wallet, _) = res!(Wallet::create_with_first_admin(
707 DaticleMap::new(),
708 "alice",
709 b"alicepass",
710 DEFAULT_WALLET_KDF_NAME,
711 ));
712 res!(wallet.add_admin(
713 b"alicepass",
714 "bob",
715 b"bobpass",
716 vec!["restart".to_string(), "log".to_string()],
717 0,
718 DEFAULT_WALLET_KDF_NAME,
719 ));
720 req!(wallet.admins().len(), 2);
721 // Bob can unlock but cannot add further admins.
722 let bob_unlocked = res!(wallet.unlock(b"bobpass"));
723 req!(bob_unlocked.admin_name, "bob".to_string());
724 req!(bob_unlocked.has_scope("restart"), true);
725 req!(bob_unlocked.has_scope("admin"), false);
726 req!(wallet.add_admin(
727 b"bobpass",
728 "mallory",
729 b"mallorypass",
730 vec!["*".to_string()],
731 0,
732 DEFAULT_WALLET_KDF_NAME,
733 ).is_err(), true);
734 // Alice (with '*') can remove bob.
735 res!(wallet.remove_admin(b"alicepass", "bob"));
736 req!(wallet.admins().len(), 1);
737 // Cannot remove the last admin.
738 req!(wallet.remove_admin(b"alicepass", "alice").is_err(), true);
739 Ok(())
740 }
741}