1use argon2::{Algorithm, Argon2, Params, Version};
33use chacha20poly1305::aead::{Aead, KeyInit, Payload};
34use chacha20poly1305::{XChaCha20Poly1305, XNonce};
35use serde::{Deserialize, Serialize};
36use zeroize::Zeroizing;
37
38const SCHEME_VERSION: u32 = 1;
40const CONTEXT: &[u8] = b"kintsugi.admin.settings.v1";
42const SALT_LEN: usize = 16;
43const KEY_LEN: usize = 32;
44const NONCE_LEN: usize = 24; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct LockedSettings {
51 pub recording: bool,
53 pub autostart: bool,
55 pub require_password_to_stop: bool,
57 pub enforcement: Enforcement,
59 pub fail_closed: bool,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum Enforcement {
67 Attended,
68 Unattended,
69 Notify,
70}
71
72impl Default for LockedSettings {
73 fn default() -> Self {
74 Self {
75 recording: true,
76 autostart: true,
77 require_password_to_stop: true,
78 enforcement: Enforcement::Attended,
79 fail_closed: false,
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86pub struct KdfParams {
87 pub m_cost: u32, pub t_cost: u32, pub p_cost: u32, }
91
92impl KdfParams {
93 pub const fn production() -> Self {
95 Self {
96 m_cost: 19 * 1024,
97 t_cost: 2,
98 p_cost: 1,
99 }
100 }
101 #[cfg(test)]
103 const fn fast() -> Self {
104 Self {
105 m_cost: 64,
106 t_cost: 1,
107 p_cost: 1,
108 }
109 }
110
111 fn argon2(&self) -> Result<Argon2<'static>, AdminError> {
112 let params = Params::new(self.m_cost, self.t_cost, self.p_cost, Some(KEY_LEN))
113 .map_err(|_| AdminError::Kdf)?;
114 Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
115 }
116
117 fn derive(&self, password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, AdminError> {
119 let mut out = Zeroizing::new([0u8; KEY_LEN]);
120 self.argon2()?
121 .hash_password_into(password, salt, out.as_mut())
122 .map_err(|_| AdminError::Kdf)?;
123 Ok(out)
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SealedVault {
131 pub scheme_version: u32,
132 pub params: KdfParams,
133 verifier_salt: String,
135 verifier: String,
136 seal_salt: String,
138 seal_nonce: String,
139 seal_ct: String,
140 recovery_nonce: String,
142 recovery_ct: String,
143}
144
145pub struct Provisioned {
148 pub vault: SealedVault,
149 pub recovery_key: String,
150}
151
152#[derive(Debug, thiserror::Error, PartialEq, Eq)]
153pub enum AdminError {
154 #[error("wrong password")]
155 WrongPassword,
156 #[error("invalid recovery key")]
157 WrongRecoveryKey,
158 #[error("vault is corrupt or was tampered with")]
159 Tampered,
160 #[error("malformed vault field")]
161 Decode,
162 #[error("key derivation failed")]
163 Kdf,
164 #[error("random source unavailable")]
165 Random,
166}
167
168fn random_bytes<const N: usize>() -> Result<[u8; N], AdminError> {
169 let mut b = [0u8; N];
170 getrandom::getrandom(&mut b).map_err(|_| AdminError::Random)?;
171 Ok(b)
172}
173
174fn aead(key: &[u8; KEY_LEN]) -> XChaCha20Poly1305 {
175 XChaCha20Poly1305::new(key.into())
176}
177
178fn seal(key: &[u8; KEY_LEN], plaintext: &[u8]) -> Result<(String, String), AdminError> {
179 let nonce = random_bytes::<NONCE_LEN>()?;
180 let ct = aead(key)
181 .encrypt(
182 XNonce::from_slice(&nonce),
183 Payload {
184 msg: plaintext,
185 aad: CONTEXT,
186 },
187 )
188 .map_err(|_| AdminError::Kdf)?;
189 Ok((hex::encode(nonce), hex::encode(ct)))
190}
191
192fn open(key: &[u8; KEY_LEN], nonce_hex: &str, ct_hex: &str) -> Result<Vec<u8>, AdminError> {
193 let nonce = hex::decode(nonce_hex).map_err(|_| AdminError::Decode)?;
194 let ct = hex::decode(ct_hex).map_err(|_| AdminError::Decode)?;
195 if nonce.len() != NONCE_LEN {
196 return Err(AdminError::Decode);
197 }
198 aead(key)
199 .decrypt(
200 XNonce::from_slice(&nonce),
201 Payload {
202 msg: &ct,
203 aad: CONTEXT,
204 },
205 )
206 .map_err(|_| AdminError::Tampered)
208}
209
210fn to_key(bytes: &[u8]) -> Result<[u8; KEY_LEN], AdminError> {
212 if bytes.len() != KEY_LEN {
213 return Err(AdminError::Decode);
214 }
215 let mut k = [0u8; KEY_LEN];
216 k.copy_from_slice(bytes);
217 Ok(k)
218}
219
220fn auth_mac(key: &[u8; KEY_LEN], nonce: &[u8], op: &[u8]) -> Result<Vec<u8>, AdminError> {
225 if nonce.len() != NONCE_LEN {
226 return Err(AdminError::Decode);
227 }
228 let mut aad = Vec::with_capacity(CONTEXT.len() + nonce.len() + 1 + op.len());
231 aad.extend_from_slice(CONTEXT);
232 aad.extend_from_slice(nonce);
233 aad.push(0x1f);
234 aad.extend_from_slice(op);
235 aead(key)
236 .encrypt(
237 XNonce::from_slice(nonce),
238 Payload {
239 msg: b"",
240 aad: &aad,
241 },
242 )
243 .map_err(|_| AdminError::Kdf)
244}
245
246pub fn random_auth_nonce() -> Result<Vec<u8>, AdminError> {
248 Ok(random_bytes::<NONCE_LEN>()?.to_vec())
249}
250
251pub fn compute_proof(
255 password: &str,
256 salt_hex: &str,
257 params: KdfParams,
258 nonce: &[u8],
259 op: &[u8],
260) -> Result<Vec<u8>, AdminError> {
261 let salt = hex::decode(salt_hex).map_err(|_| AdminError::Decode)?;
262 let key = params.derive(password.as_bytes(), &salt)?;
263 auth_mac(&key, nonce, op)
264}
265
266fn ct_eq(a: &[u8], b: &[u8]) -> bool {
268 if a.len() != b.len() {
269 return false;
270 }
271 let mut diff = 0u8;
272 for (x, y) in a.iter().zip(b.iter()) {
273 diff |= x ^ y;
274 }
275 diff == 0
276}
277
278pub fn provision(password: &str, settings: &LockedSettings) -> Result<Provisioned, AdminError> {
280 provision_with(password, settings, KdfParams::production())
281}
282
283fn provision_with(
284 password: &str,
285 settings: &LockedSettings,
286 params: KdfParams,
287) -> Result<Provisioned, AdminError> {
288 let pw = password.as_bytes();
289 let verifier_salt = random_bytes::<SALT_LEN>()?;
291 let verifier = params.derive(pw, &verifier_salt)?;
292 let seal_salt = random_bytes::<SALT_LEN>()?;
294 let seal_key = params.derive(pw, &seal_salt)?;
295 let plaintext = serde_json::to_vec(settings).map_err(|_| AdminError::Decode)?;
296 let (seal_nonce, seal_ct) = seal(&seal_key, &plaintext)?;
297 let recovery_raw = random_bytes::<KEY_LEN>()?;
299 let (recovery_nonce, recovery_ct) = seal(&recovery_raw, seal_key.as_ref())?;
300
301 Ok(Provisioned {
302 vault: SealedVault {
303 scheme_version: SCHEME_VERSION,
304 params,
305 verifier_salt: hex::encode(verifier_salt),
306 verifier: hex::encode(verifier.as_ref()),
307 seal_salt: hex::encode(seal_salt),
308 seal_nonce,
309 seal_ct,
310 recovery_nonce,
311 recovery_ct,
312 },
313 recovery_key: hex::encode(recovery_raw),
314 })
315}
316
317impl SealedVault {
318 pub fn verify_password(&self, password: &str) -> bool {
320 let Ok(salt) = hex::decode(&self.verifier_salt) else {
321 return false;
322 };
323 let Ok(want) = hex::decode(&self.verifier) else {
324 return false;
325 };
326 let Ok(got) = self.params.derive(password.as_bytes(), &salt) else {
327 return false;
328 };
329 ct_eq(got.as_ref(), &want)
330 }
331
332 pub fn auth_challenge(&self) -> (String, KdfParams) {
335 (self.verifier_salt.clone(), self.params)
336 }
337
338 pub fn verify_proof(&self, nonce: &[u8], op: &[u8], proof: &[u8]) -> bool {
344 let Ok(verifier) = hex::decode(&self.verifier) else {
345 return false;
346 };
347 let Ok(key) = to_key(&verifier) else {
348 return false;
349 };
350 let Ok(want) = auth_mac(&key, nonce, op) else {
351 return false;
352 };
353 ct_eq(&want, proof)
354 }
355
356 fn sealing_key(&self, password: &str) -> Result<Zeroizing<[u8; KEY_LEN]>, AdminError> {
358 if !self.verify_password(password) {
359 return Err(AdminError::WrongPassword);
360 }
361 let salt = hex::decode(&self.seal_salt).map_err(|_| AdminError::Decode)?;
362 self.params.derive(password.as_bytes(), &salt)
363 }
364
365 pub fn unseal(&self, password: &str) -> Result<LockedSettings, AdminError> {
367 let key = self.sealing_key(password)?;
368 let plaintext = open(&key, &self.seal_nonce, &self.seal_ct)?;
369 serde_json::from_slice(&plaintext).map_err(|_| AdminError::Decode)
370 }
371
372 pub fn unseal_with_recovery(&self, recovery_key: &str) -> Result<LockedSettings, AdminError> {
374 let raw = hex::decode(recovery_key).map_err(|_| AdminError::WrongRecoveryKey)?;
375 if raw.len() != KEY_LEN {
376 return Err(AdminError::WrongRecoveryKey);
377 }
378 let mut rk = Zeroizing::new([0u8; KEY_LEN]);
379 rk.copy_from_slice(&raw);
380 let seal_key_bytes = open(&rk, &self.recovery_nonce, &self.recovery_ct)
382 .map_err(|_| AdminError::WrongRecoveryKey)?;
383 if seal_key_bytes.len() != KEY_LEN {
384 return Err(AdminError::Decode);
385 }
386 let mut seal_key = Zeroizing::new([0u8; KEY_LEN]);
387 seal_key.copy_from_slice(&seal_key_bytes);
388 let plaintext = open(&seal_key, &self.seal_nonce, &self.seal_ct)?;
389 serde_json::from_slice(&plaintext).map_err(|_| AdminError::Decode)
390 }
391
392 pub fn update_settings(
396 &self,
397 password: &str,
398 new_settings: &LockedSettings,
399 ) -> Result<SealedVault, AdminError> {
400 let key = self.sealing_key(password)?;
401 let plaintext = serde_json::to_vec(new_settings).map_err(|_| AdminError::Decode)?;
402 let (seal_nonce, seal_ct) = seal(&key, &plaintext)?;
403 Ok(SealedVault {
404 seal_nonce,
405 seal_ct,
406 ..self.clone()
407 })
408 }
409
410 pub fn change_password(&self, old: &str, new: &str) -> Result<Provisioned, AdminError> {
414 let settings = self.unseal(old)?; provision_with(new, &settings, self.params)
418 }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
423pub enum VaultState {
424 Unprovisioned,
427 Locked(Box<SealedVault>),
429 Degraded(String),
433}
434
435impl VaultState {
436 pub fn is_locked(&self) -> bool {
438 !matches!(self, VaultState::Unprovisioned)
439 }
440}
441
442pub fn default_vault_path() -> std::path::PathBuf {
446 if let Ok(p) = std::env::var("KINTSUGI_VAULT") {
447 return std::path::PathBuf::from(p);
448 }
449 if let Some(dirs) = directories::ProjectDirs::from("", "", "kintsugi") {
450 return dirs.data_dir().join("admin-vault.json");
451 }
452 std::env::temp_dir().join("kintsugi-admin-vault.json")
453}
454
455pub fn load_vault(path: &std::path::Path) -> VaultState {
458 match std::fs::read(path) {
459 Err(e) if e.kind() == std::io::ErrorKind::NotFound => VaultState::Unprovisioned,
460 Err(e) => VaultState::Degraded(format!("vault unreadable: {}", e.kind())),
461 Ok(bytes) => match serde_json::from_slice::<SealedVault>(&bytes) {
462 Ok(v) => VaultState::Locked(Box::new(v)),
463 Err(_) => VaultState::Degraded("vault is corrupt or not valid JSON".into()),
464 },
465 }
466}
467
468pub fn save_vault(path: &std::path::Path, vault: &SealedVault) -> std::io::Result<()> {
473 if let Some(parent) = path.parent() {
474 std::fs::create_dir_all(parent)?;
475 }
476 let tmp = path.with_extension("tmp");
477 let json = serde_json::to_vec_pretty(vault)
478 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
479 std::fs::write(&tmp, &json)?;
480 #[cfg(unix)]
481 {
482 use std::os::unix::fs::PermissionsExt;
483 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
484 }
485 std::fs::rename(&tmp, path)
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 fn pw(tag: &str) -> String {
496 format!("kintsugi-test-pw-{}-{tag}", std::process::id())
497 }
498
499 fn provision_fast(password: &str, s: &LockedSettings) -> Provisioned {
500 provision_with(password, s, KdfParams::fast()).unwrap()
501 }
502
503 #[test]
504 fn auth_proof_round_trips_and_rejects_tampering() {
505 let p = provision_fast(&pw("ok"), &LockedSettings::default());
506 let v = &p.vault;
507 let (salt, params) = v.auth_challenge();
508 let nonce = random_auth_nonce().unwrap();
509 let op = b"shutdown";
510
511 let proof = compute_proof(&pw("ok"), &salt, params, &nonce, op).unwrap();
513 assert!(v.verify_proof(&nonce, op, &proof));
514
515 let bad = compute_proof(&pw("bad"), &salt, params, &nonce, op).unwrap();
517 assert!(!v.verify_proof(&nonce, op, &bad));
518
519 let other = random_auth_nonce().unwrap();
521 assert!(!v.verify_proof(&other, op, &proof));
522
523 assert!(!v.verify_proof(&nonce, b"unhook", &proof));
525 }
526
527 #[test]
528 fn round_trips_settings() {
529 let s = LockedSettings::default();
530 let p = provision_fast(&pw("ok"), &s);
531 assert!(p.vault.verify_password(&pw("ok")));
532 assert_eq!(p.vault.unseal(&pw("ok")).unwrap(), s);
533 }
534
535 #[test]
536 fn wrong_password_is_rejected_and_does_not_unseal() {
537 let p = provision_fast(&pw("ok"), &LockedSettings::default());
538 assert!(!p.vault.verify_password(&pw("bad")));
539 assert_eq!(
540 p.vault.unseal(&pw("bad")).unwrap_err(),
541 AdminError::WrongPassword
542 );
543 }
544
545 #[test]
546 fn verifier_is_not_the_sealing_key() {
547 let password = pw("ok");
550 let p = provision_fast(&password, &LockedSettings::default());
551 let salt = hex::decode(&p.vault.seal_salt).unwrap();
552 let seal_key = p.vault.params.derive(password.as_bytes(), &salt).unwrap();
553 assert_ne!(hex::encode(seal_key.as_ref()), p.vault.verifier);
554 assert_ne!(p.vault.verifier_salt, p.vault.seal_salt);
555 }
556
557 #[test]
558 fn recovery_key_unseals_without_password() {
559 let s = LockedSettings {
560 recording: false,
561 ..Default::default()
562 };
563 let p = provision_fast(&pw("ok"), &s);
564 assert_eq!(p.vault.unseal_with_recovery(&p.recovery_key).unwrap(), s);
565 let bad = hex::encode([7u8; KEY_LEN]);
567 assert!(p.vault.unseal_with_recovery(&bad).is_err());
568 assert!(p.vault.unseal_with_recovery("nothex").is_err());
569 }
570
571 #[test]
572 fn tampering_with_the_ciphertext_is_detected() {
573 let mut p = provision_fast(&pw("ok"), &LockedSettings::default());
574 let mut ct = hex::decode(&p.vault.seal_ct).unwrap();
576 ct[0] ^= 0xff;
577 p.vault.seal_ct = hex::encode(ct);
578 assert_eq!(p.vault.unseal(&pw("ok")).unwrap_err(), AdminError::Tampered);
579 }
580
581 #[test]
582 fn update_settings_requires_password_and_persists() {
583 let p = provision_fast(&pw("ok"), &LockedSettings::default());
584 let new = LockedSettings {
585 recording: false,
586 enforcement: Enforcement::Unattended,
587 ..Default::default()
588 };
589 assert_eq!(
590 p.vault.update_settings(&pw("bad"), &new).unwrap_err(),
591 AdminError::WrongPassword
592 );
593 let v2 = p.vault.update_settings(&pw("ok"), &new).unwrap();
594 assert_eq!(v2.unseal(&pw("ok")).unwrap(), new);
595 assert_ne!(v2.seal_nonce, p.vault.seal_nonce);
597 }
598
599 #[test]
600 fn change_password_rotates_and_invalidates_old() {
601 let p = provision_fast(&pw("old"), &LockedSettings::default());
602 let p2 = p.vault.change_password(&pw("old"), &pw("new")).unwrap();
603 assert!(p2.vault.verify_password(&pw("new")));
604 assert!(!p2.vault.verify_password(&pw("old")));
605 assert!(p2.vault.unseal_with_recovery(&p.recovery_key).is_err());
607 assert!(p2.vault.unseal_with_recovery(&p2.recovery_key).is_ok());
608 assert!(matches!(
610 p.vault.change_password(&pw("bad"), &pw("x")),
611 Err(AdminError::WrongPassword)
612 ));
613 }
614
615 #[test]
616 fn vault_serializes_round_trip() {
617 let p = provision_fast(&pw("ok"), &LockedSettings::default());
618 let json = serde_json::to_string(&p.vault).unwrap();
619 let back: SealedVault = serde_json::from_str(&json).unwrap();
620 assert_eq!(back, p.vault);
621 assert!(back.unseal(&pw("ok")).is_ok());
622 assert!(!json.contains("recording"));
624 }
625
626 #[test]
627 fn vault_store_states_and_failclosed() {
628 let dir = tempfile::tempdir().unwrap();
629 let path = dir.path().join("admin-vault.json");
630
631 assert_eq!(load_vault(&path), VaultState::Unprovisioned);
633 assert!(!load_vault(&path).is_locked());
634
635 let p = provision_fast(&pw("ok"), &LockedSettings::default());
637 save_vault(&path, &p.vault).unwrap();
638 match load_vault(&path) {
639 VaultState::Locked(v) => assert!(v.unseal(&pw("ok")).is_ok()),
640 other => panic!("expected Locked, got {other:?}"),
641 }
642 assert!(load_vault(&path).is_locked());
643
644 std::fs::write(&path, b"{ not valid json").unwrap();
646 match load_vault(&path) {
647 VaultState::Degraded(_) => {}
648 other => panic!("corrupt vault must be Degraded, got {other:?}"),
649 }
650 assert!(load_vault(&path).is_locked());
651 }
652
653 #[cfg(unix)]
654 #[test]
655 fn saved_vault_is_0600() {
656 use std::os::unix::fs::PermissionsExt;
657 let dir = tempfile::tempdir().unwrap();
658 let path = dir.path().join("v.json");
659 let p = provision_fast(&pw("ok"), &LockedSettings::default());
660 save_vault(&path, &p.vault).unwrap();
661 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
662 assert_eq!(mode, 0o600, "vault must be private to the owner");
663 }
664}