fix(crypto): K-03 — emergency card persistence warning and rekey command

This commit is contained in:
2026-09-19 00:58:39 +02:00
parent 36a4094336
commit bdddd812ac
8 changed files with 560 additions and 9 deletions
+230 -1
View File
@@ -13,7 +13,10 @@ use sanctum::{
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
},
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
recovery::{
export_header_backup, rekey_container, restore_header_backup,
restore_header_from_recovery_key,
},
storage::Database,
verify::verify_container,
vfs::SanctumFs,
@@ -1561,3 +1564,229 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
let _ = server_handle.await;
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_k03_passwd_sheet_remains_valid_and_rekey_revokes_old_sheet() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!(
"test_sanctum_k03_{}_{}.sanctum",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password_v1 = "InitialPassword2026!";
let password_v2 = "ChangedPassword2026!";
// 1. Initialisiere Container
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password_v1, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.unwrap();
db.checkpoint().unwrap();
// 2. Schreibe eine Testdatei
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let secret_path = DavPath::new("/secret_doc.txt").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let mut f = fs.open(&secret_path, opts).await.unwrap();
let secret_data = b"Confidential financial data protected by Sanctum K-03.";
f.write_buf(Box::new(Bytes::from_static(secret_data)))
.await
.unwrap();
drop(f);
drop(fs);
db.checkpoint().unwrap();
// Initiales Notfallblatt
let initial_mnemonic = dek_to_mnemonic(&dek).unwrap();
// 3. Simuliere normales Passwort-Ändern (sanctum passwd)
// Bei passwd wird der DEK beibehalten, nur mit neuem KEK verpackt
let new_salt = generate_salt();
let new_kek = derive_kek(password_v2, &new_salt, &kdf_params).unwrap();
let (new_wrapped_dek, new_nonce, new_tag) =
sanctum::crypto::wrap_slot0_payload(&new_kek, &dek, 0).unwrap();
db.update_slot_keys(
0,
&new_salt,
&kdf_params,
&new_wrapped_dek,
&new_nonce,
&new_tag,
)
.unwrap();
db.checkpoint().unwrap();
// Verifiziere: Das ursprüngliche Notfallblatt ist WEITERHIN gültig nach normalem passwd!
// (Beweis für Finding K-03, weshalb Warnung + Rekeying notwendig sind)
{
// Lösche Header und stelle mit initial_mnemonic + temp password wieder her
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
restore_header_from_recovery_key(&container_path, &initial_mnemonic, "TempPass2026!")
.expect("Altes Notfallblatt muss nach passwd weiterhin funktionieren");
let r_db = Database::open(&container_path).unwrap();
let r_meta = r_db.read_meta().unwrap();
let r_kek = derive_kek("TempPass2026!", &r_meta.kdf_salt, &r_meta.kdf_params).unwrap();
let r_dek = unwrap_dek(
&r_kek,
&r_meta.wrapped_dek,
&r_meta.header_nonce,
&r_meta.header_tag,
)
.unwrap();
assert_eq!(
*dek, *r_dek,
"DEK muss nach normalem passwd identisch geblieben sein"
);
let r_fs = SanctumFs::new(r_db.clone(), r_dek, r_meta.version);
let mut r_opts = OpenOptions::default();
r_opts.read = true;
let mut r_f = r_fs.open(&secret_path, r_opts).await.unwrap();
let read_bytes = r_f.read_bytes(secret_data.len()).await.unwrap();
assert_eq!(&read_bytes[..], secret_data);
}
// Setze Header wieder auf password_v2
let fresh_salt = generate_salt();
let fresh_kek = derive_kek(password_v2, &fresh_salt, &kdf_params).unwrap();
let (fresh_wrapped, fresh_nonce, fresh_tag) =
sanctum::crypto::wrap_slot0_payload(&fresh_kek, &dek, 0).unwrap();
db.update_slot_keys(
0,
&fresh_salt,
&kdf_params,
&fresh_wrapped,
&fresh_nonce,
&fresh_tag,
)
.unwrap();
db.checkpoint().unwrap();
// 4. Jetzt: Führe echtes Rekeying aus (sanctum rekey)
let rekeyed_mnemonic = rekey_container(&container_path, password_v2)
.expect("Rekeying muss erfolgreich durchlaufen");
assert_ne!(
initial_mnemonic, rekeyed_mnemonic,
"Neues Notfallblatt muss sich unterscheiden"
);
// 5. Container lässt sich mit neuem Passwort öffnen und Daten sind intakt
let rekeyed_db = Database::open(&container_path).unwrap();
let rekeyed_meta = rekeyed_db.read_meta().unwrap();
let rekeyed_kek = derive_kek(
password_v2,
&rekeyed_meta.kdf_salt,
&rekeyed_meta.kdf_params,
)
.unwrap();
let rekeyed_dek = unwrap_dek(
&rekeyed_kek,
&rekeyed_meta.wrapped_dek,
&rekeyed_meta.header_nonce,
&rekeyed_meta.header_tag,
)
.unwrap();
assert_ne!(
*dek, *rekeyed_dek,
"DEK muss nach Rekeying erneuert worden sein"
);
let rekeyed_fs = SanctumFs::new(
rekeyed_db.clone(),
rekeyed_dek.clone(),
rekeyed_meta.version,
);
let mut check_opts = OpenOptions::default();
check_opts.read = true;
let mut check_f = rekeyed_fs.open(&secret_path, check_opts).await.unwrap();
let rekeyed_data = check_f.read_bytes(secret_data.len()).await.unwrap();
assert_eq!(
&rekeyed_data[..],
secret_data,
"Daten müssen nach Rekeying vollständig intakt sein"
);
drop(check_f);
drop(rekeyed_fs);
// Integritätsprüfung nach Rekeying
let report = verify_container(&container_path, Some(&rekeyed_dek), true).unwrap();
assert!(
report.is_healthy(),
"Container muss nach Rekeying gesunden Status aufweisen"
);
assert_eq!(report.corrupted_chunks, 0);
// 6. Altes Notfallblatt ist entwertet: Kann umverschlüsselte Chunks NICHT mehr entschlüsseln!
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// Versuch, mit altem Notfallblatt wiederherzustellen
restore_header_from_recovery_key(&container_path, &initial_mnemonic, "OldKeyPassword!")
.unwrap();
let old_res_db = Database::open(&container_path).unwrap();
let old_res_meta = old_res_db.read_meta().unwrap();
let old_res_kek = derive_kek(
"OldKeyPassword!",
&old_res_meta.kdf_salt,
&old_res_meta.kdf_params,
)
.unwrap();
let old_res_dek = unwrap_dek(
&old_res_kek,
&old_res_meta.wrapped_dek,
&old_res_meta.header_nonce,
&old_res_meta.header_tag,
)
.unwrap();
// Entschlüsselung der Datei mit dem alten DEK muss scheitern (AEAD tag mismatch)
let old_fs = SanctumFs::new(
old_res_db.clone(),
old_res_dek.clone(),
old_res_meta.version,
);
let mut fail_opts = OpenOptions::default();
fail_opts.read = true;
let mut fail_f = old_fs.open(&secret_path, fail_opts).await.unwrap();
let fail_res = fail_f.read_bytes(secret_data.len()).await;
assert!(
fail_res.is_err(),
"Altes Notfallblatt darf nach Rekeying Chunks nicht mehr entschlüsseln können!"
);
// Auch verify_container mit altem DEK meldet Korruption / MAC-Fehlschlag
let fail_report = verify_container(&container_path, Some(&old_res_dek), true).unwrap();
assert!(
!fail_report.is_healthy(),
"Prüfung mit altem Notfallschlüssel muss Fehler melden"
);
}
// Aufräumen
let _ = std::fs::remove_file(&container_path);
}