fix(crypto): K-03 — emergency card persistence warning and rekey command
This commit is contained in:
+230
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user