use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
const SCHEME_VERSION: u32 = 1;
const CONTEXT: &[u8] = b"kintsugi.admin.settings.v1";
const SALT_LEN: usize = 16;
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 24;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockedSettings {
pub recording: bool,
pub autostart: bool,
pub require_password_to_stop: bool,
pub enforcement: Enforcement,
pub fail_closed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Enforcement {
Attended,
Unattended,
Notify,
}
impl Default for LockedSettings {
fn default() -> Self {
Self {
recording: true,
autostart: true,
require_password_to_stop: true,
enforcement: Enforcement::Attended,
fail_closed: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct KdfParams {
pub m_cost: u32, pub t_cost: u32, pub p_cost: u32, }
impl KdfParams {
pub const fn production() -> Self {
Self {
m_cost: 19 * 1024,
t_cost: 2,
p_cost: 1,
}
}
#[cfg(test)]
const fn fast() -> Self {
Self {
m_cost: 64,
t_cost: 1,
p_cost: 1,
}
}
fn argon2(&self) -> Result<Argon2<'static>, AdminError> {
let params = Params::new(self.m_cost, self.t_cost, self.p_cost, Some(KEY_LEN))
.map_err(|_| AdminError::Kdf)?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
fn derive(&self, password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, AdminError> {
let mut out = Zeroizing::new([0u8; KEY_LEN]);
self.argon2()?
.hash_password_into(password, salt, out.as_mut())
.map_err(|_| AdminError::Kdf)?;
Ok(out)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealedVault {
pub scheme_version: u32,
pub params: KdfParams,
verifier_salt: String,
verifier: String,
seal_salt: String,
seal_nonce: String,
seal_ct: String,
recovery_nonce: String,
recovery_ct: String,
}
pub struct Provisioned {
pub vault: SealedVault,
pub recovery_key: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum AdminError {
#[error("wrong password")]
WrongPassword,
#[error("invalid recovery key")]
WrongRecoveryKey,
#[error("vault is corrupt or was tampered with")]
Tampered,
#[error("malformed vault field")]
Decode,
#[error("key derivation failed")]
Kdf,
#[error("random source unavailable")]
Random,
}
fn random_bytes<const N: usize>() -> Result<[u8; N], AdminError> {
let mut b = [0u8; N];
getrandom::getrandom(&mut b).map_err(|_| AdminError::Random)?;
Ok(b)
}
fn aead(key: &[u8; KEY_LEN]) -> XChaCha20Poly1305 {
XChaCha20Poly1305::new(key.into())
}
fn seal(key: &[u8; KEY_LEN], plaintext: &[u8]) -> Result<(String, String), AdminError> {
let nonce = random_bytes::<NONCE_LEN>()?;
let ct = aead(key)
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: plaintext,
aad: CONTEXT,
},
)
.map_err(|_| AdminError::Kdf)?;
Ok((hex::encode(nonce), hex::encode(ct)))
}
fn open(key: &[u8; KEY_LEN], nonce_hex: &str, ct_hex: &str) -> Result<Vec<u8>, AdminError> {
let nonce = hex::decode(nonce_hex).map_err(|_| AdminError::Decode)?;
let ct = hex::decode(ct_hex).map_err(|_| AdminError::Decode)?;
if nonce.len() != NONCE_LEN {
return Err(AdminError::Decode);
}
aead(key)
.decrypt(
XNonce::from_slice(&nonce),
Payload {
msg: &ct,
aad: CONTEXT,
},
)
.map_err(|_| AdminError::Tampered)
}
fn to_key(bytes: &[u8]) -> Result<[u8; KEY_LEN], AdminError> {
if bytes.len() != KEY_LEN {
return Err(AdminError::Decode);
}
let mut k = [0u8; KEY_LEN];
k.copy_from_slice(bytes);
Ok(k)
}
fn auth_mac(key: &[u8; KEY_LEN], nonce: &[u8], op: &[u8]) -> Result<Vec<u8>, AdminError> {
if nonce.len() != NONCE_LEN {
return Err(AdminError::Decode);
}
let mut aad = Vec::with_capacity(CONTEXT.len() + nonce.len() + 1 + op.len());
aad.extend_from_slice(CONTEXT);
aad.extend_from_slice(nonce);
aad.push(0x1f);
aad.extend_from_slice(op);
aead(key)
.encrypt(
XNonce::from_slice(nonce),
Payload {
msg: b"",
aad: &aad,
},
)
.map_err(|_| AdminError::Kdf)
}
pub fn random_auth_nonce() -> Result<Vec<u8>, AdminError> {
Ok(random_bytes::<NONCE_LEN>()?.to_vec())
}
pub fn compute_proof(
password: &str,
salt_hex: &str,
params: KdfParams,
nonce: &[u8],
op: &[u8],
) -> Result<Vec<u8>, AdminError> {
let salt = hex::decode(salt_hex).map_err(|_| AdminError::Decode)?;
let key = params.derive(password.as_bytes(), &salt)?;
auth_mac(&key, nonce, op)
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
pub fn provision(password: &str, settings: &LockedSettings) -> Result<Provisioned, AdminError> {
provision_with(password, settings, KdfParams::production())
}
fn provision_with(
password: &str,
settings: &LockedSettings,
params: KdfParams,
) -> Result<Provisioned, AdminError> {
let pw = password.as_bytes();
let verifier_salt = random_bytes::<SALT_LEN>()?;
let verifier = params.derive(pw, &verifier_salt)?;
let seal_salt = random_bytes::<SALT_LEN>()?;
let seal_key = params.derive(pw, &seal_salt)?;
let plaintext = serde_json::to_vec(settings).map_err(|_| AdminError::Decode)?;
let (seal_nonce, seal_ct) = seal(&seal_key, &plaintext)?;
let recovery_raw = random_bytes::<KEY_LEN>()?;
let (recovery_nonce, recovery_ct) = seal(&recovery_raw, seal_key.as_ref())?;
Ok(Provisioned {
vault: SealedVault {
scheme_version: SCHEME_VERSION,
params,
verifier_salt: hex::encode(verifier_salt),
verifier: hex::encode(verifier.as_ref()),
seal_salt: hex::encode(seal_salt),
seal_nonce,
seal_ct,
recovery_nonce,
recovery_ct,
},
recovery_key: hex::encode(recovery_raw),
})
}
impl SealedVault {
pub fn verify_password(&self, password: &str) -> bool {
let Ok(salt) = hex::decode(&self.verifier_salt) else {
return false;
};
let Ok(want) = hex::decode(&self.verifier) else {
return false;
};
let Ok(got) = self.params.derive(password.as_bytes(), &salt) else {
return false;
};
ct_eq(got.as_ref(), &want)
}
pub fn auth_challenge(&self) -> (String, KdfParams) {
(self.verifier_salt.clone(), self.params)
}
pub fn verify_proof(&self, nonce: &[u8], op: &[u8], proof: &[u8]) -> bool {
let Ok(verifier) = hex::decode(&self.verifier) else {
return false;
};
let Ok(key) = to_key(&verifier) else {
return false;
};
let Ok(want) = auth_mac(&key, nonce, op) else {
return false;
};
ct_eq(&want, proof)
}
fn sealing_key(&self, password: &str) -> Result<Zeroizing<[u8; KEY_LEN]>, AdminError> {
if !self.verify_password(password) {
return Err(AdminError::WrongPassword);
}
let salt = hex::decode(&self.seal_salt).map_err(|_| AdminError::Decode)?;
self.params.derive(password.as_bytes(), &salt)
}
pub fn unseal(&self, password: &str) -> Result<LockedSettings, AdminError> {
let key = self.sealing_key(password)?;
let plaintext = open(&key, &self.seal_nonce, &self.seal_ct)?;
serde_json::from_slice(&plaintext).map_err(|_| AdminError::Decode)
}
pub fn unseal_with_recovery(&self, recovery_key: &str) -> Result<LockedSettings, AdminError> {
let raw = hex::decode(recovery_key).map_err(|_| AdminError::WrongRecoveryKey)?;
if raw.len() != KEY_LEN {
return Err(AdminError::WrongRecoveryKey);
}
let mut rk = Zeroizing::new([0u8; KEY_LEN]);
rk.copy_from_slice(&raw);
let seal_key_bytes = open(&rk, &self.recovery_nonce, &self.recovery_ct)
.map_err(|_| AdminError::WrongRecoveryKey)?;
if seal_key_bytes.len() != KEY_LEN {
return Err(AdminError::Decode);
}
let mut seal_key = Zeroizing::new([0u8; KEY_LEN]);
seal_key.copy_from_slice(&seal_key_bytes);
let plaintext = open(&seal_key, &self.seal_nonce, &self.seal_ct)?;
serde_json::from_slice(&plaintext).map_err(|_| AdminError::Decode)
}
pub fn update_settings(
&self,
password: &str,
new_settings: &LockedSettings,
) -> Result<SealedVault, AdminError> {
let key = self.sealing_key(password)?;
let plaintext = serde_json::to_vec(new_settings).map_err(|_| AdminError::Decode)?;
let (seal_nonce, seal_ct) = seal(&key, &plaintext)?;
Ok(SealedVault {
seal_nonce,
seal_ct,
..self.clone()
})
}
pub fn change_password(&self, old: &str, new: &str) -> Result<Provisioned, AdminError> {
let settings = self.unseal(old)?; provision_with(new, &settings, self.params)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VaultState {
Unprovisioned,
Locked(Box<SealedVault>),
Degraded(String),
}
impl VaultState {
pub fn is_locked(&self) -> bool {
!matches!(self, VaultState::Unprovisioned)
}
}
pub fn default_vault_path() -> std::path::PathBuf {
if let Ok(p) = std::env::var("KINTSUGI_VAULT") {
return std::path::PathBuf::from(p);
}
if let Some(dirs) = directories::ProjectDirs::from("", "", "kintsugi") {
return dirs.data_dir().join("admin-vault.json");
}
std::env::temp_dir().join("kintsugi-admin-vault.json")
}
pub fn load_vault(path: &std::path::Path) -> VaultState {
match std::fs::read(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => VaultState::Unprovisioned,
Err(e) => VaultState::Degraded(format!("vault unreadable: {}", e.kind())),
Ok(bytes) => match serde_json::from_slice::<SealedVault>(&bytes) {
Ok(v) => VaultState::Locked(Box::new(v)),
Err(_) => VaultState::Degraded("vault is corrupt or not valid JSON".into()),
},
}
}
pub fn save_vault(path: &std::path::Path, vault: &SealedVault) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
let json = serde_json::to_vec_pretty(vault)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&tmp, &json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
fn pw(tag: &str) -> String {
format!("kintsugi-test-pw-{}-{tag}", std::process::id())
}
fn provision_fast(password: &str, s: &LockedSettings) -> Provisioned {
provision_with(password, s, KdfParams::fast()).unwrap()
}
#[test]
fn auth_proof_round_trips_and_rejects_tampering() {
let p = provision_fast(&pw("ok"), &LockedSettings::default());
let v = &p.vault;
let (salt, params) = v.auth_challenge();
let nonce = random_auth_nonce().unwrap();
let op = b"shutdown";
let proof = compute_proof(&pw("ok"), &salt, params, &nonce, op).unwrap();
assert!(v.verify_proof(&nonce, op, &proof));
let bad = compute_proof(&pw("bad"), &salt, params, &nonce, op).unwrap();
assert!(!v.verify_proof(&nonce, op, &bad));
let other = random_auth_nonce().unwrap();
assert!(!v.verify_proof(&other, op, &proof));
assert!(!v.verify_proof(&nonce, b"unhook", &proof));
}
#[test]
fn round_trips_settings() {
let s = LockedSettings::default();
let p = provision_fast(&pw("ok"), &s);
assert!(p.vault.verify_password(&pw("ok")));
assert_eq!(p.vault.unseal(&pw("ok")).unwrap(), s);
}
#[test]
fn wrong_password_is_rejected_and_does_not_unseal() {
let p = provision_fast(&pw("ok"), &LockedSettings::default());
assert!(!p.vault.verify_password(&pw("bad")));
assert_eq!(
p.vault.unseal(&pw("bad")).unwrap_err(),
AdminError::WrongPassword
);
}
#[test]
fn verifier_is_not_the_sealing_key() {
let password = pw("ok");
let p = provision_fast(&password, &LockedSettings::default());
let salt = hex::decode(&p.vault.seal_salt).unwrap();
let seal_key = p.vault.params.derive(password.as_bytes(), &salt).unwrap();
assert_ne!(hex::encode(seal_key.as_ref()), p.vault.verifier);
assert_ne!(p.vault.verifier_salt, p.vault.seal_salt);
}
#[test]
fn recovery_key_unseals_without_password() {
let s = LockedSettings {
recording: false,
..Default::default()
};
let p = provision_fast(&pw("ok"), &s);
assert_eq!(p.vault.unseal_with_recovery(&p.recovery_key).unwrap(), s);
let bad = hex::encode([7u8; KEY_LEN]);
assert!(p.vault.unseal_with_recovery(&bad).is_err());
assert!(p.vault.unseal_with_recovery("nothex").is_err());
}
#[test]
fn tampering_with_the_ciphertext_is_detected() {
let mut p = provision_fast(&pw("ok"), &LockedSettings::default());
let mut ct = hex::decode(&p.vault.seal_ct).unwrap();
ct[0] ^= 0xff;
p.vault.seal_ct = hex::encode(ct);
assert_eq!(p.vault.unseal(&pw("ok")).unwrap_err(), AdminError::Tampered);
}
#[test]
fn update_settings_requires_password_and_persists() {
let p = provision_fast(&pw("ok"), &LockedSettings::default());
let new = LockedSettings {
recording: false,
enforcement: Enforcement::Unattended,
..Default::default()
};
assert_eq!(
p.vault.update_settings(&pw("bad"), &new).unwrap_err(),
AdminError::WrongPassword
);
let v2 = p.vault.update_settings(&pw("ok"), &new).unwrap();
assert_eq!(v2.unseal(&pw("ok")).unwrap(), new);
assert_ne!(v2.seal_nonce, p.vault.seal_nonce);
}
#[test]
fn change_password_rotates_and_invalidates_old() {
let p = provision_fast(&pw("old"), &LockedSettings::default());
let p2 = p.vault.change_password(&pw("old"), &pw("new")).unwrap();
assert!(p2.vault.verify_password(&pw("new")));
assert!(!p2.vault.verify_password(&pw("old")));
assert!(p2.vault.unseal_with_recovery(&p.recovery_key).is_err());
assert!(p2.vault.unseal_with_recovery(&p2.recovery_key).is_ok());
assert!(matches!(
p.vault.change_password(&pw("bad"), &pw("x")),
Err(AdminError::WrongPassword)
));
}
#[test]
fn vault_serializes_round_trip() {
let p = provision_fast(&pw("ok"), &LockedSettings::default());
let json = serde_json::to_string(&p.vault).unwrap();
let back: SealedVault = serde_json::from_str(&json).unwrap();
assert_eq!(back, p.vault);
assert!(back.unseal(&pw("ok")).is_ok());
assert!(!json.contains("recording"));
}
#[test]
fn vault_store_states_and_failclosed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("admin-vault.json");
assert_eq!(load_vault(&path), VaultState::Unprovisioned);
assert!(!load_vault(&path).is_locked());
let p = provision_fast(&pw("ok"), &LockedSettings::default());
save_vault(&path, &p.vault).unwrap();
match load_vault(&path) {
VaultState::Locked(v) => assert!(v.unseal(&pw("ok")).is_ok()),
other => panic!("expected Locked, got {other:?}"),
}
assert!(load_vault(&path).is_locked());
std::fs::write(&path, b"{ not valid json").unwrap();
match load_vault(&path) {
VaultState::Degraded(_) => {}
other => panic!("corrupt vault must be Degraded, got {other:?}"),
}
assert!(load_vault(&path).is_locked());
}
#[cfg(unix)]
#[test]
fn saved_vault_is_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("v.json");
let p = provision_fast(&pw("ok"), &LockedSettings::default());
save_vault(&path, &p.vault).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "vault must be private to the owner");
}
}