1use argon2::{Algorithm, Argon2, Params, Version};
33use chacha20poly1305::aead::{Aead, KeyInit, Payload};
34use chacha20poly1305::{XChaCha20Poly1305, XNonce};
35use ed25519_dalek::{Signer, SigningKey, VerifyingKey, SIGNATURE_LENGTH};
36use serde::{Deserialize, Serialize};
37use zeroize::Zeroizing;
38
39const SCHEME_VERSION: u32 = 2;
45const CONTEXT: &[u8] = b"kintsugi.admin.settings.v1";
47const SALT_LEN: usize = 16;
48const KEY_LEN: usize = 32;
49const NONCE_LEN: usize = 24; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct LockedSettings {
56 pub recording: bool,
58 pub autostart: bool,
60 pub require_password_to_stop: bool,
62 pub enforcement: Enforcement,
64 pub fail_closed: bool,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum Enforcement {
72 Attended,
73 Unattended,
74 Notify,
75}
76
77impl Default for LockedSettings {
78 fn default() -> Self {
79 Self {
80 recording: true,
81 autostart: true,
82 require_password_to_stop: true,
83 enforcement: Enforcement::Attended,
84 fail_closed: false,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91pub struct KdfParams {
92 pub m_cost: u32, pub t_cost: u32, pub p_cost: u32, }
96
97impl KdfParams {
98 pub const fn production() -> Self {
100 Self {
101 m_cost: 19 * 1024,
102 t_cost: 2,
103 p_cost: 1,
104 }
105 }
106 #[cfg(test)]
108 const fn fast() -> Self {
109 Self {
110 m_cost: 64,
111 t_cost: 1,
112 p_cost: 1,
113 }
114 }
115
116 fn argon2(&self) -> Result<Argon2<'static>, AdminError> {
117 let params = Params::new(self.m_cost, self.t_cost, self.p_cost, Some(KEY_LEN))
118 .map_err(|_| AdminError::Kdf)?;
119 Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
120 }
121
122 fn derive(&self, password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, AdminError> {
124 let mut out = Zeroizing::new([0u8; KEY_LEN]);
125 self.argon2()?
126 .hash_password_into(password, salt, out.as_mut())
127 .map_err(|_| AdminError::Kdf)?;
128 Ok(out)
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct SealedVault {
136 pub scheme_version: u32,
137 pub params: KdfParams,
138 verifier_salt: String,
140 verifier: String,
141 #[serde(default)]
145 auth_salt: String,
146 #[serde(default)]
147 auth_pubkey: String,
148 seal_salt: String,
150 seal_nonce: String,
151 seal_ct: String,
152 recovery_nonce: String,
154 recovery_ct: String,
155}
156
157pub struct Provisioned {
160 pub vault: SealedVault,
161 pub recovery_key: String,
162}
163
164#[derive(Debug, thiserror::Error, PartialEq, Eq)]
165pub enum AdminError {
166 #[error("wrong password")]
167 WrongPassword,
168 #[error("invalid recovery key")]
169 WrongRecoveryKey,
170 #[error("vault is corrupt or was tampered with")]
171 Tampered,
172 #[error("malformed vault field")]
173 Decode,
174 #[error("key derivation failed")]
175 Kdf,
176 #[error("random source unavailable")]
177 Random,
178}
179
180fn random_bytes<const N: usize>() -> Result<[u8; N], AdminError> {
181 let mut b = [0u8; N];
182 getrandom::getrandom(&mut b).map_err(|_| AdminError::Random)?;
183 Ok(b)
184}
185
186fn aead(key: &[u8; KEY_LEN]) -> XChaCha20Poly1305 {
187 XChaCha20Poly1305::new(key.into())
188}
189
190fn seal(key: &[u8; KEY_LEN], plaintext: &[u8]) -> Result<(String, String), AdminError> {
191 let nonce = random_bytes::<NONCE_LEN>()?;
192 let ct = aead(key)
193 .encrypt(
194 XNonce::from_slice(&nonce),
195 Payload {
196 msg: plaintext,
197 aad: CONTEXT,
198 },
199 )
200 .map_err(|_| AdminError::Kdf)?;
201 Ok((hex::encode(nonce), hex::encode(ct)))
202}
203
204fn open(key: &[u8; KEY_LEN], nonce_hex: &str, ct_hex: &str) -> Result<Vec<u8>, AdminError> {
205 let nonce = hex::decode(nonce_hex).map_err(|_| AdminError::Decode)?;
206 let ct = hex::decode(ct_hex).map_err(|_| AdminError::Decode)?;
207 if nonce.len() != NONCE_LEN {
208 return Err(AdminError::Decode);
209 }
210 aead(key)
211 .decrypt(
212 XNonce::from_slice(&nonce),
213 Payload {
214 msg: &ct,
215 aad: CONTEXT,
216 },
217 )
218 .map_err(|_| AdminError::Tampered)
220}
221
222fn to_key(bytes: &[u8]) -> Result<[u8; KEY_LEN], AdminError> {
224 if bytes.len() != KEY_LEN {
225 return Err(AdminError::Decode);
226 }
227 let mut k = [0u8; KEY_LEN];
228 k.copy_from_slice(bytes);
229 Ok(k)
230}
231
232fn auth_message(nonce: &[u8], op: &[u8]) -> Vec<u8> {
235 let mut msg = Vec::with_capacity(CONTEXT.len() + nonce.len() + 1 + op.len());
236 msg.extend_from_slice(CONTEXT);
237 msg.extend_from_slice(nonce);
238 msg.push(0x1f);
239 msg.extend_from_slice(op);
240 msg
241}
242
243fn auth_signing_key(
246 params: KdfParams,
247 password: &str,
248 auth_salt_hex: &str,
249) -> Result<SigningKey, AdminError> {
250 let salt = hex::decode(auth_salt_hex).map_err(|_| AdminError::Decode)?;
251 let seed = params.derive(password.as_bytes(), &salt)?;
252 Ok(SigningKey::from_bytes(&seed))
253}
254
255pub fn random_auth_nonce() -> Result<Vec<u8>, AdminError> {
257 Ok(random_bytes::<NONCE_LEN>()?.to_vec())
258}
259
260pub fn compute_proof(
264 password: &str,
265 salt_hex: &str,
266 params: KdfParams,
267 nonce: &[u8],
268 op: &[u8],
269) -> Result<Vec<u8>, AdminError> {
270 if nonce.len() != NONCE_LEN {
271 return Err(AdminError::Decode);
272 }
273 let signing = auth_signing_key(params, password, salt_hex)?;
275 Ok(signing.sign(&auth_message(nonce, op)).to_bytes().to_vec())
276}
277
278fn ct_eq(a: &[u8], b: &[u8]) -> bool {
280 if a.len() != b.len() {
281 return false;
282 }
283 let mut diff = 0u8;
284 for (x, y) in a.iter().zip(b.iter()) {
285 diff |= x ^ y;
286 }
287 diff == 0
288}
289
290pub fn provision(password: &str, settings: &LockedSettings) -> Result<Provisioned, AdminError> {
292 provision_with(password, settings, KdfParams::production())
293}
294
295fn provision_with(
296 password: &str,
297 settings: &LockedSettings,
298 params: KdfParams,
299) -> Result<Provisioned, AdminError> {
300 let pw = password.as_bytes();
301 let verifier_salt = random_bytes::<SALT_LEN>()?;
303 let verifier = params.derive(pw, &verifier_salt)?;
304 let auth_salt = random_bytes::<SALT_LEN>()?;
306 let auth_seed = params.derive(pw, &auth_salt)?;
307 let auth_pubkey = SigningKey::from_bytes(&auth_seed)
308 .verifying_key()
309 .to_bytes();
310 let seal_salt = random_bytes::<SALT_LEN>()?;
312 let seal_key = params.derive(pw, &seal_salt)?;
313 let plaintext = serde_json::to_vec(settings).map_err(|_| AdminError::Decode)?;
314 let (seal_nonce, seal_ct) = seal(&seal_key, &plaintext)?;
315 let recovery_raw = random_bytes::<KEY_LEN>()?;
317 let (recovery_nonce, recovery_ct) = seal(&recovery_raw, seal_key.as_ref())?;
318
319 Ok(Provisioned {
320 vault: SealedVault {
321 scheme_version: SCHEME_VERSION,
322 params,
323 verifier_salt: hex::encode(verifier_salt),
324 verifier: hex::encode(verifier.as_ref()),
325 auth_salt: hex::encode(auth_salt),
326 auth_pubkey: hex::encode(auth_pubkey),
327 seal_salt: hex::encode(seal_salt),
328 seal_nonce,
329 seal_ct,
330 recovery_nonce,
331 recovery_ct,
332 },
333 recovery_key: hex::encode(recovery_raw),
334 })
335}
336
337impl SealedVault {
338 pub fn verify_password(&self, password: &str) -> bool {
340 let Ok(salt) = hex::decode(&self.verifier_salt) else {
341 return false;
342 };
343 let Ok(want) = hex::decode(&self.verifier) else {
344 return false;
345 };
346 let Ok(got) = self.params.derive(password.as_bytes(), &salt) else {
347 return false;
348 };
349 ct_eq(got.as_ref(), &want)
350 }
351
352 pub fn auth_challenge(&self) -> (String, KdfParams) {
355 (self.auth_salt.clone(), self.params)
356 }
357
358 pub fn verify_proof(&self, nonce: &[u8], op: &[u8], proof: &[u8]) -> bool {
364 if nonce.len() != NONCE_LEN || proof.len() != SIGNATURE_LENGTH {
365 return false;
366 }
367 let Ok(pk_bytes) = hex::decode(&self.auth_pubkey) else {
369 return false;
370 };
371 let Ok(pk_arr) = to_key(&pk_bytes) else {
372 return false;
373 };
374 let Ok(pk) = VerifyingKey::from_bytes(&pk_arr) else {
375 return false;
376 };
377 let mut sig = [0u8; SIGNATURE_LENGTH];
378 sig.copy_from_slice(proof);
379 pk.verify_strict(
380 &auth_message(nonce, op),
381 &ed25519_dalek::Signature::from_bytes(&sig),
382 )
383 .is_ok()
384 }
385
386 fn sealing_key(&self, password: &str) -> Result<Zeroizing<[u8; KEY_LEN]>, AdminError> {
388 if !self.verify_password(password) {
389 return Err(AdminError::WrongPassword);
390 }
391 let salt = hex::decode(&self.seal_salt).map_err(|_| AdminError::Decode)?;
392 self.params.derive(password.as_bytes(), &salt)
393 }
394
395 pub fn unseal(&self, password: &str) -> Result<LockedSettings, AdminError> {
397 let key = self.sealing_key(password)?;
398 let plaintext = open(&key, &self.seal_nonce, &self.seal_ct)?;
399 serde_json::from_slice(&plaintext).map_err(|_| AdminError::Decode)
400 }
401
402 pub fn unseal_with_recovery(&self, recovery_key: &str) -> Result<LockedSettings, AdminError> {
404 let raw = hex::decode(recovery_key).map_err(|_| AdminError::WrongRecoveryKey)?;
405 if raw.len() != KEY_LEN {
406 return Err(AdminError::WrongRecoveryKey);
407 }
408 let mut rk = Zeroizing::new([0u8; KEY_LEN]);
409 rk.copy_from_slice(&raw);
410 let seal_key_bytes = open(&rk, &self.recovery_nonce, &self.recovery_ct)
412 .map_err(|_| AdminError::WrongRecoveryKey)?;
413 if seal_key_bytes.len() != KEY_LEN {
414 return Err(AdminError::Decode);
415 }
416 let mut seal_key = Zeroizing::new([0u8; KEY_LEN]);
417 seal_key.copy_from_slice(&seal_key_bytes);
418 let plaintext = open(&seal_key, &self.seal_nonce, &self.seal_ct)?;
419 serde_json::from_slice(&plaintext).map_err(|_| AdminError::Decode)
420 }
421
422 pub fn update_settings(
426 &self,
427 password: &str,
428 new_settings: &LockedSettings,
429 ) -> Result<SealedVault, AdminError> {
430 let key = self.sealing_key(password)?;
431 let plaintext = serde_json::to_vec(new_settings).map_err(|_| AdminError::Decode)?;
432 let (seal_nonce, seal_ct) = seal(&key, &plaintext)?;
433 Ok(SealedVault {
434 seal_nonce,
435 seal_ct,
436 ..self.clone()
437 })
438 }
439
440 pub fn change_password(&self, old: &str, new: &str) -> Result<Provisioned, AdminError> {
444 let settings = self.unseal(old)?; provision_with(new, &settings, self.params)
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum VaultState {
454 Unprovisioned,
457 Locked(Box<SealedVault>),
459 Degraded(String),
463}
464
465impl VaultState {
466 pub fn is_locked(&self) -> bool {
468 !matches!(self, VaultState::Unprovisioned)
469 }
470}
471
472pub fn default_vault_path() -> std::path::PathBuf {
476 if let Ok(p) = std::env::var("KINTSUGI_VAULT") {
477 return std::path::PathBuf::from(p);
478 }
479 if let Some(dirs) = directories::ProjectDirs::from("", "", "kintsugi") {
480 return dirs.data_dir().join("admin-vault.json");
481 }
482 std::env::temp_dir().join("kintsugi-admin-vault.json")
483}
484
485pub fn load_vault(path: &std::path::Path) -> VaultState {
488 match std::fs::read(path) {
489 Err(e) if e.kind() == std::io::ErrorKind::NotFound => VaultState::Unprovisioned,
490 Err(e) => VaultState::Degraded(format!("vault unreadable: {}", e.kind())),
491 Ok(bytes) => match serde_json::from_slice::<SealedVault>(&bytes) {
492 Ok(v) => VaultState::Locked(Box::new(v)),
493 Err(_) => VaultState::Degraded("vault is corrupt or not valid JSON".into()),
494 },
495 }
496}
497
498pub fn save_vault(path: &std::path::Path, vault: &SealedVault) -> std::io::Result<()> {
503 if let Some(parent) = path.parent() {
504 std::fs::create_dir_all(parent)?;
505 }
506 let tmp = path.with_extension("tmp");
507 let json = serde_json::to_vec_pretty(vault)
508 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
509 std::fs::write(&tmp, &json)?;
510 #[cfg(unix)]
511 {
512 use std::os::unix::fs::PermissionsExt;
513 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
514 }
515 std::fs::rename(&tmp, path)
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn pw(tag: &str) -> String {
526 format!("kintsugi-test-pw-{}-{tag}", std::process::id())
527 }
528
529 fn provision_fast(password: &str, s: &LockedSettings) -> Provisioned {
530 provision_with(password, s, KdfParams::fast()).unwrap()
531 }
532
533 #[test]
534 fn auth_proof_round_trips_and_rejects_tampering() {
535 let p = provision_fast(&pw("ok"), &LockedSettings::default());
536 let v = &p.vault;
537 let (salt, params) = v.auth_challenge();
538 let nonce = random_auth_nonce().unwrap();
539 let op = b"shutdown";
540
541 let proof = compute_proof(&pw("ok"), &salt, params, &nonce, op).unwrap();
543 assert!(v.verify_proof(&nonce, op, &proof));
544
545 let bad = compute_proof(&pw("bad"), &salt, params, &nonce, op).unwrap();
547 assert!(!v.verify_proof(&nonce, op, &bad));
548
549 let other = random_auth_nonce().unwrap();
551 assert!(!v.verify_proof(&other, op, &proof));
552
553 assert!(!v.verify_proof(&nonce, b"unhook", &proof));
555 }
556
557 #[test]
558 fn vault_contents_cannot_forge_a_proof_without_the_password() {
559 use ed25519_dalek::{Signer, SigningKey};
560 let p = provision_fast(&pw("secret"), &LockedSettings::default());
561 let v = &p.vault;
562 let (salt, params) = v.auth_challenge();
563 let nonce = random_auth_nonce().unwrap();
564 let op = b"shutdown";
565 let msg = auth_message(&nonce, op);
566 let verifier: [u8; KEY_LEN] = hex::decode(&v.verifier).unwrap().try_into().unwrap();
567 let forged = SigningKey::from_bytes(&verifier)
568 .sign(&msg)
569 .to_bytes()
570 .to_vec();
571 assert!(
572 !v.verify_proof(&nonce, op, &forged),
573 "verifier must not be a usable signing key"
574 );
575 let pk: [u8; KEY_LEN] = hex::decode(&v.auth_pubkey).unwrap().try_into().unwrap();
576 let forged2 = SigningKey::from_bytes(&pk).sign(&msg).to_bytes().to_vec();
577 assert!(!v.verify_proof(&nonce, op, &forged2));
578 let real = compute_proof(&pw("secret"), &salt, params, &nonce, op).unwrap();
579 assert!(v.verify_proof(&nonce, op, &real));
580 }
581
582 #[test]
583 fn pre_v2_vault_without_pubkey_fails_proofs_closed() {
584 let p = provision_fast(&pw("ok"), &LockedSettings::default());
585 let mut v = p.vault.clone();
586 v.auth_pubkey = String::new();
587 v.auth_salt = String::new();
588 let (salt, params) = p.vault.auth_challenge();
589 let nonce = random_auth_nonce().unwrap();
590 let proof = compute_proof(&pw("ok"), &salt, params, &nonce, b"shutdown").unwrap();
591 assert!(!v.verify_proof(&nonce, b"shutdown", &proof));
592 }
593
594 #[test]
595 fn round_trips_settings() {
596 let s = LockedSettings::default();
597 let p = provision_fast(&pw("ok"), &s);
598 assert!(p.vault.verify_password(&pw("ok")));
599 assert_eq!(p.vault.unseal(&pw("ok")).unwrap(), s);
600 }
601
602 #[test]
603 fn wrong_password_is_rejected_and_does_not_unseal() {
604 let p = provision_fast(&pw("ok"), &LockedSettings::default());
605 assert!(!p.vault.verify_password(&pw("bad")));
606 assert_eq!(
607 p.vault.unseal(&pw("bad")).unwrap_err(),
608 AdminError::WrongPassword
609 );
610 }
611
612 #[test]
613 fn verifier_is_not_the_sealing_key() {
614 let password = pw("ok");
617 let p = provision_fast(&password, &LockedSettings::default());
618 let salt = hex::decode(&p.vault.seal_salt).unwrap();
619 let seal_key = p.vault.params.derive(password.as_bytes(), &salt).unwrap();
620 assert_ne!(hex::encode(seal_key.as_ref()), p.vault.verifier);
621 assert_ne!(p.vault.verifier_salt, p.vault.seal_salt);
622 }
623
624 #[test]
625 fn recovery_key_unseals_without_password() {
626 let s = LockedSettings {
627 recording: false,
628 ..Default::default()
629 };
630 let p = provision_fast(&pw("ok"), &s);
631 assert_eq!(p.vault.unseal_with_recovery(&p.recovery_key).unwrap(), s);
632 let bad = hex::encode([7u8; KEY_LEN]);
634 assert!(p.vault.unseal_with_recovery(&bad).is_err());
635 assert!(p.vault.unseal_with_recovery("nothex").is_err());
636 }
637
638 #[test]
639 fn tampering_with_the_ciphertext_is_detected() {
640 let mut p = provision_fast(&pw("ok"), &LockedSettings::default());
641 let mut ct = hex::decode(&p.vault.seal_ct).unwrap();
643 ct[0] ^= 0xff;
644 p.vault.seal_ct = hex::encode(ct);
645 assert_eq!(p.vault.unseal(&pw("ok")).unwrap_err(), AdminError::Tampered);
646 }
647
648 #[test]
649 fn update_settings_requires_password_and_persists() {
650 let p = provision_fast(&pw("ok"), &LockedSettings::default());
651 let new = LockedSettings {
652 recording: false,
653 enforcement: Enforcement::Unattended,
654 ..Default::default()
655 };
656 assert_eq!(
657 p.vault.update_settings(&pw("bad"), &new).unwrap_err(),
658 AdminError::WrongPassword
659 );
660 let v2 = p.vault.update_settings(&pw("ok"), &new).unwrap();
661 assert_eq!(v2.unseal(&pw("ok")).unwrap(), new);
662 assert_ne!(v2.seal_nonce, p.vault.seal_nonce);
664 }
665
666 #[test]
667 fn change_password_rotates_and_invalidates_old() {
668 let p = provision_fast(&pw("old"), &LockedSettings::default());
669 let p2 = p.vault.change_password(&pw("old"), &pw("new")).unwrap();
670 assert!(p2.vault.verify_password(&pw("new")));
671 assert!(!p2.vault.verify_password(&pw("old")));
672 assert!(p2.vault.unseal_with_recovery(&p.recovery_key).is_err());
674 assert!(p2.vault.unseal_with_recovery(&p2.recovery_key).is_ok());
675 assert!(matches!(
677 p.vault.change_password(&pw("bad"), &pw("x")),
678 Err(AdminError::WrongPassword)
679 ));
680 }
681
682 #[test]
683 fn vault_serializes_round_trip() {
684 let p = provision_fast(&pw("ok"), &LockedSettings::default());
685 let json = serde_json::to_string(&p.vault).unwrap();
686 let back: SealedVault = serde_json::from_str(&json).unwrap();
687 assert_eq!(back, p.vault);
688 assert!(back.unseal(&pw("ok")).is_ok());
689 assert!(!json.contains("recording"));
691 }
692
693 #[test]
694 fn vault_store_states_and_failclosed() {
695 let dir = tempfile::tempdir().unwrap();
696 let path = dir.path().join("admin-vault.json");
697
698 assert_eq!(load_vault(&path), VaultState::Unprovisioned);
700 assert!(!load_vault(&path).is_locked());
701
702 let p = provision_fast(&pw("ok"), &LockedSettings::default());
704 save_vault(&path, &p.vault).unwrap();
705 match load_vault(&path) {
706 VaultState::Locked(v) => assert!(v.unseal(&pw("ok")).is_ok()),
707 other => panic!("expected Locked, got {other:?}"),
708 }
709 assert!(load_vault(&path).is_locked());
710
711 std::fs::write(&path, b"{ not valid json").unwrap();
713 match load_vault(&path) {
714 VaultState::Degraded(_) => {}
715 other => panic!("corrupt vault must be Degraded, got {other:?}"),
716 }
717 assert!(load_vault(&path).is_locked());
718 }
719
720 #[cfg(unix)]
721 #[test]
722 fn saved_vault_is_0600() {
723 use std::os::unix::fs::PermissionsExt;
724 let dir = tempfile::tempdir().unwrap();
725 let path = dir.path().join("v.json");
726 let p = provision_fast(&pw("ok"), &LockedSettings::default());
727 save_vault(&path, &p.vault).unwrap();
728 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
729 assert_eq!(mode, 0o600, "vault must be private to the owner");
730 }
731}