use crate::record::VersionRow;
use openehr::security::audit_chain::{
BreakReason, Chain, ChainEntry, ChainKey, ChainStatus, Digest256, Tag,
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Integrity {
Verified,
Unkeyed,
Empty,
Broken {
at: usize,
uid: String,
reason: Breach,
},
Unrecognised,
UnknownKey {
at: usize,
uid: String,
key_id: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Breach {
ContentAltered,
PreviousMismatch,
DigestMismatch,
TagMismatch,
}
impl Integrity {
#[must_use]
pub const fn is_breach(&self) -> bool {
matches!(self, Self::Broken { .. })
}
#[must_use]
pub const fn is_intact(&self) -> bool {
matches!(self, Self::Verified | Self::Unkeyed)
}
}
fn hashed_bytes(row: &VersionRow) -> &[u8] {
row.data_json.as_deref().map_or(b"null", str::as_bytes)
}
#[must_use]
pub fn verify_versions(rows: &[VersionRow], keys: &[&ChainKey]) -> Integrity {
for (at, row) in rows.iter().enumerate() {
if Digest256::of(hashed_bytes(row)) != Digest256::from_bytes(row.chain.content) {
return Integrity::Broken {
at,
uid: row.uid.clone(),
reason: Breach::ContentAltered,
};
}
}
let chain = Chain::from_stored(
rows.iter()
.map(|row| ChainEntry {
version_uid: row.uid.clone(),
previous: Digest256::from_bytes(row.chain.previous),
content: Digest256::from_bytes(row.chain.content),
digest: Digest256::from_bytes(row.chain.digest),
tag: row
.chain
.tag_key_id
.as_ref()
.zip(row.chain.tag_mac)
.map(|(key_id, mac)| Tag::from_stored(key_id, mac.to_vec())),
})
.collect(),
);
let uid = |at: usize| rows.get(at).map_or_else(String::new, |r| r.uid.clone());
match chain.verify(keys) {
ChainStatus::Verified => Integrity::Verified,
ChainStatus::UnkeyedOnly => Integrity::Unkeyed,
ChainStatus::Empty => Integrity::Empty,
ChainStatus::UnknownKey { at, key_id } => Integrity::UnknownKey {
at,
uid: uid(at),
key_id,
},
ChainStatus::Broken { at, reason } => Integrity::Broken {
at,
uid: uid(at),
reason: match reason {
BreakReason::PreviousMismatch => Breach::PreviousMismatch,
BreakReason::TagMismatch => Breach::TagMismatch,
_ => Breach::DigestMismatch,
},
},
_ => Integrity::Unrecognised,
}
}
#[cfg(test)]
mod tests {
use super::{Breach, Integrity, verify_versions};
use crate::conformance::{RECORD, SYSTEM, sample_version};
use crate::record::VersionRow;
use openehr::security::Digest256;
fn rows(n: u32) -> Vec<VersionRow> {
let mut previous = None;
let mut out = Vec::new();
for v in 1..=n {
let version = sample_version(v, (v > 1).then(|| v - 1), v * 5);
let row = VersionRow::project(&version, "c1", previous, None).expect("projects");
previous = Some(row.chain.digest);
out.push(row);
}
out
}
#[test]
fn an_untouched_history_is_unkeyed_rather_than_verified() {
let verdict = verify_versions(&rows(3), &[]);
assert_eq!(verdict, Integrity::Unkeyed);
assert!(verdict.is_intact());
assert!(!verdict.is_breach());
}
#[test]
fn an_empty_container_is_neither_intact_nor_a_breach() {
let verdict = verify_versions(&[], &[]);
assert_eq!(verdict, Integrity::Empty);
assert!(!verdict.is_breach());
assert!(!verdict.is_intact());
}
#[test]
fn an_edited_document_is_reported_as_altered_content() {
let mut rows = rows(3);
rows[1].data_json = Some(r#"{"altered":true}"#.to_owned());
match verify_versions(&rows, &[]) {
Integrity::Broken { at, uid, reason } => {
assert_eq!(reason, Breach::ContentAltered);
assert_eq!(at, 1);
assert_eq!(uid, format!("{RECORD}::{SYSTEM}::2"));
}
other => panic!("an edited document was not detected: {other:?}"),
}
}
#[test]
fn a_deletion_hashes_as_null_rather_than_reporting_as_altered() {
let mut rows = rows(1);
rows[0].data_json = None;
rows[0].chain.content = *Digest256::of(b"null").as_bytes();
match verify_versions(&rows, &[]) {
Integrity::Broken { reason, .. } => assert_eq!(reason, Breach::DigestMismatch),
other => panic!("expected a digest mismatch, got {other:?}"),
}
}
#[test]
fn a_removed_version_breaks_the_link_rather_than_the_content() {
let mut rows = rows(3);
rows.remove(1);
match verify_versions(&rows, &[]) {
Integrity::Broken { at, reason, .. } => {
assert_eq!(reason, Breach::PreviousMismatch);
assert_eq!(at, 1);
}
other => panic!("a removed version was not detected: {other:?}"),
}
}
#[test]
fn a_rewritten_entry_digest_is_reported_as_a_digest_mismatch() {
let mut rows = rows(2);
rows[0].chain.digest = [0u8; 32];
match verify_versions(&rows, &[]) {
Integrity::Broken { at, reason, .. } => {
assert_eq!(reason, Breach::DigestMismatch);
assert_eq!(at, 0);
}
other => panic!("a rewritten digest was not detected: {other:?}"),
}
}
#[test]
fn a_tag_naming_a_key_this_process_does_not_hold_is_not_a_pass() {
let mut rows = rows(1);
rows[0].chain.tag_key_id = Some("k-unheld".to_owned());
rows[0].chain.tag_mac = Some([9u8; 32]);
match verify_versions(&rows, &[]) {
Integrity::UnknownKey { at, uid, key_id } => {
assert_eq!(at, 0);
assert_eq!(key_id, "k-unheld");
assert_eq!(uid, format!("{RECORD}::{SYSTEM}::1"));
}
other => panic!("expected UnknownKey, got {other:?}"),
}
let verdict = verify_versions(&rows, &[]);
assert!(!verdict.is_breach());
assert!(!verdict.is_intact());
}
#[test]
fn a_signed_history_verifies_and_a_forged_tag_does_not() {
use openehr::security::ChainKey;
let key = ChainKey::new("k1", vec![7u8; 32]).expect("key");
let signed = |n: u32| {
let mut previous = None;
let mut out = Vec::new();
for v in 1..=n {
let version = sample_version(v, (v > 1).then(|| v - 1), v * 5);
let row =
VersionRow::project(&version, "c1", previous, Some(&key)).expect("projects");
previous = Some(row.chain.digest);
out.push(row);
}
out
};
let verdict = verify_versions(&signed(2), &[&key]);
assert_eq!(verdict, Integrity::Verified);
assert!(verdict.is_intact());
assert!(!verdict.is_breach());
let mut forged = signed(2);
forged[1].chain.tag_mac = Some([0u8; 32]);
match verify_versions(&forged, &[&key]) {
Integrity::Broken { at, reason, .. } => {
assert_eq!(reason, Breach::TagMismatch);
assert_eq!(at, 1);
}
other => panic!("a forged tag was not detected: {other:?}"),
}
assert!(verify_versions(&forged, &[&key]).is_breach());
}
}