1use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
18
19use hmac::digest::KeyInit;
20use hmac::{Hmac, Mac};
21use sha2::{Digest, Sha256};
22
23use crate::error::KipukaError;
24
25type HmacSha256 = Hmac<Sha256>;
27
28const RS: u8 = 0x1e;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum AuditEventType {
46 CaStart,
49 CaStop,
51 CaHealthChange,
53
54 EnrollRequest,
57 CertIssue,
59 CertReenroll,
61 EnrollReject,
63 CertRevoke,
65 CrlGenerate,
67
68 KeyGenerate,
71 KeyLoad,
73 KeyDestroy,
75
76 OtpCreate,
79 OtpUse,
81 OtpExpire,
83 OtpRevoke,
85
86 AuthSuccess,
89 AuthFailure,
91
92 AdminLogin,
95 AdminLogout,
97 AdminAction,
99
100 SecurityViolation,
103}
104
105impl AuditEventType {
106 pub fn as_str(self) -> &'static str {
108 match self {
109 AuditEventType::CaStart => "ca.start",
110 AuditEventType::CaStop => "ca.stop",
111 AuditEventType::CaHealthChange => "ca.health-change",
112 AuditEventType::EnrollRequest => "enroll.request",
113 AuditEventType::CertIssue => "cert.issue",
114 AuditEventType::CertReenroll => "cert.reenroll",
115 AuditEventType::EnrollReject => "enroll.reject",
116 AuditEventType::CertRevoke => "cert.revoke",
117 AuditEventType::CrlGenerate => "crl.generate",
118 AuditEventType::KeyGenerate => "key.generate",
119 AuditEventType::KeyLoad => "key.load",
120 AuditEventType::KeyDestroy => "key.destroy",
121 AuditEventType::OtpCreate => "otp.create",
122 AuditEventType::OtpUse => "otp.use",
123 AuditEventType::OtpExpire => "otp.expire",
124 AuditEventType::OtpRevoke => "otp.revoke",
125 AuditEventType::AuthSuccess => "auth.success",
126 AuditEventType::AuthFailure => "auth.failure",
127 AuditEventType::AdminLogin => "admin.login",
128 AuditEventType::AdminLogout => "admin.logout",
129 AuditEventType::AdminAction => "admin.action",
130 AuditEventType::SecurityViolation => "security.violation",
131 }
132 }
133}
134
135pub struct AuditEvent {
137 pub event_type: AuditEventType,
139 pub ca_id: Option<String>,
141 pub subject: Option<String>,
143 pub detail: Option<String>,
145 pub client_addr: Option<String>,
147 pub operator: Option<String>,
149}
150
151impl AuditEvent {
152 pub fn new(event_type: AuditEventType) -> Self {
154 Self {
155 event_type,
156 ca_id: None,
157 subject: None,
158 detail: None,
159 client_addr: None,
160 operator: None,
161 }
162 }
163
164 pub fn with_ca_id(mut self, ca_id: impl Into<String>) -> Self {
166 self.ca_id = Some(ca_id.into());
167 self
168 }
169
170 pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
172 self.subject = Some(subject.into());
173 self
174 }
175
176 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
178 self.detail = Some(detail.into());
179 self
180 }
181
182 pub fn with_client_addr(mut self, addr: impl Into<String>) -> Self {
184 self.client_addr = Some(addr.into());
185 self
186 }
187
188 pub fn with_operator(mut self, operator: impl Into<String>) -> Self {
190 self.operator = Some(operator.into());
191 self
192 }
193}
194
195pub struct AuditState {
201 pub halted: AtomicBool,
203 config: crate::config::AuditConfig,
204 write_lock: tokio::sync::Mutex<()>,
205
206 pub violation_count: AtomicU32,
209
210 integrity_key: Option<Vec<u8>>,
213}
214
215impl AuditState {
216 pub fn new() -> Self {
218 Self::with_config(crate::config::AuditConfig::default())
219 }
220
221 pub fn with_config(config: crate::config::AuditConfig) -> Self {
226 Self::from_config(config, None)
227 }
228
229 pub fn from_config(config: crate::config::AuditConfig, integrity_key: Option<Vec<u8>>) -> Self {
233 Self {
234 config,
235 write_lock: tokio::sync::Mutex::new(()),
236 halted: AtomicBool::new(false),
237 violation_count: AtomicU32::new(0),
238 integrity_key,
239 }
240 }
241
242 fn chain_hash(&self, prev: Option<&str>, canonical: &[u8]) -> String {
249 let prev_bytes = prev.unwrap_or("").as_bytes();
250 match &self.integrity_key {
251 Some(key) => {
252 let mut mac =
253 HmacSha256::new_from_slice(key).expect("HMAC accepts a key of any length");
254 mac.update(prev_bytes);
255 mac.update(&[RS]);
256 mac.update(canonical);
257 hex::encode(mac.finalize().into_bytes())
258 }
259 None => {
260 let mut h = Sha256::new();
261 h.update(prev_bytes);
262 h.update([RS]);
263 h.update(canonical);
264 hex::encode(h.finalize())
265 }
266 }
267 }
268
269 pub fn is_halted(&self) -> bool {
272 self.halted.load(Ordering::Relaxed)
273 }
274
275 pub fn set_halted(&self, halted: bool) {
278 self.halted.store(halted, Ordering::Relaxed);
279 }
280
281 pub fn record_violation(&self) -> u32 {
283 self.violation_count.fetch_add(1, Ordering::Relaxed) + 1
284 }
285
286 pub fn reset_violations(&self) {
288 self.violation_count.store(0, Ordering::Relaxed);
289 }
290}
291
292impl Default for AuditState {
293 fn default() -> Self {
294 Self::new()
295 }
296}
297
298pub async fn record(pool: &sqlx::AnyPool, state: &AuditState, event: AuditEvent) {
304 if let Err(error) = record_checked(pool, state, event).await {
305 tracing::error!(%error, "audit event could not be persisted");
306 }
307}
308
309pub async fn record_checked(
311 pool: &sqlx::AnyPool,
312 state: &AuditState,
313 event: AuditEvent,
314) -> Result<(), crate::error::KipukaError> {
315 if !state.config.enabled {
316 return Ok(());
317 }
318 let _guard = state.write_lock.lock().await;
319 let result = record_inner(pool, state, event).await;
320 if result.is_err() && state.config.overflow_policy == crate::config::OverflowPolicy::Halt {
321 state.set_halted(true);
322 }
323 result
324}
325
326async fn record_inner(
327 pool: &sqlx::AnyPool,
328 state: &AuditState,
329 event: AuditEvent,
330) -> Result<(), crate::error::KipukaError> {
331 if state.is_halted() {
332 return Err(KipukaError::Db("audit operation admission halted".into()));
333 }
334 let mut tx = pool
335 .begin()
336 .await
337 .map_err(|e| KipukaError::Db(e.to_string()))?;
338 write_event(&mut tx, state, &event).await?;
339 tx.commit()
340 .await
341 .map_err(|e| KipukaError::Db(e.to_string()))?;
342
343 if event.event_type == AuditEventType::SecurityViolation {
345 let count = state.record_violation();
346 if count >= state.config.alarm_threshold {
347 tracing::error!(count, "AUDIT SECURITY ALARM");
348 if state.config.alarm_action == "halt" {
349 state.set_halted(true);
350 } else {
351 emit_syslog_alarm(count)
352 .map_err(|e| KipukaError::Db(format!("audit syslog alarm failed: {e}")))?;
353 }
354 }
355 tracing::warn!(
356 consecutive_violations = count,
357 "security violation recorded"
358 );
359 } else if event.event_type == AuditEventType::AuthSuccess {
360 state.reset_violations();
361 }
362 Ok(())
363}
364
365pub(crate) async fn record_issuance_in_transaction(
369 tx: &mut sqlx::Transaction<'_, sqlx::Any>,
370 state: &AuditState,
371 ca_id: &str,
372 detail: String,
373) -> Result<(), crate::error::KipukaError> {
374 if !state.config.enabled {
375 return Ok(());
376 }
377 let event = AuditEvent::new(AuditEventType::CertIssue)
378 .with_ca_id(ca_id)
379 .with_detail(detail);
380 let result = write_event(tx, state, &event).await;
381 if result.is_err() && state.config.overflow_policy == crate::config::OverflowPolicy::Halt {
382 state.set_halted(true);
383 }
384 result
385}
386
387pub(crate) async fn commit_issuance(
389 tx: sqlx::Transaction<'_, sqlx::Any>,
390 state: &AuditState,
391) -> Result<(), crate::error::KipukaError> {
392 if let Err(error) = tx.commit().await {
393 if state.config.enabled
394 && state.config.overflow_policy == crate::config::OverflowPolicy::Halt
395 {
396 state.set_halted(true);
397 }
398 return Err(error.into());
399 }
400 Ok(())
401}
402
403async fn write_event(
417 connection: &mut sqlx::AnyConnection,
418 state: &AuditState,
419 event: &AuditEvent,
420) -> Result<(), crate::error::KipukaError> {
421 if state.is_halted() {
422 return Err(KipukaError::Db("audit operation admission halted".into()));
423 }
424 sqlx::query("UPDATE audit_writer_lock SET revision = revision WHERE id = 1")
427 .execute(&mut *connection)
428 .await
429 .map_err(|e| KipukaError::Db(e.to_string()))?;
430 if let Some(limit) = state.config.max_rows {
431 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_events")
432 .fetch_one(&mut *connection)
433 .await
434 .map_err(|e| KipukaError::Db(e.to_string()))?;
435 if count >= limit as i64 {
436 if state.config.overflow_policy == crate::config::OverflowPolicy::Halt {
437 return Err(KipukaError::Db("audit storage row limit reached".into()));
438 }
439 let ids: Vec<i64> = sqlx::query_scalar(crate::db::pg_sql(
440 "SELECT id FROM audit_events ORDER BY id LIMIT ?",
441 ))
442 .bind(count - limit as i64 + 1)
443 .fetch_all(&mut *connection)
444 .await
445 .map_err(|e| KipukaError::Db(e.to_string()))?;
446 for id in ids {
447 sqlx::query(crate::db::pg_sql("DELETE FROM audit_events WHERE id = ?"))
448 .bind(id)
449 .execute(&mut *connection)
450 .await
451 .map_err(|e| KipukaError::Db(e.to_string()))?;
452 }
453 }
454 }
455 let detail_json = match (&event.detail, &event.ca_id) {
459 (Some(d), Some(ca)) => Some(serde_json::json!({"detail": d, "ca_id": ca}).to_string()),
460 (Some(d), None) => Some(serde_json::json!({"detail": d}).to_string()),
461 (None, Some(ca)) => Some(serde_json::json!({"ca_id": ca}).to_string()),
462 (None, None) => None,
463 };
464
465 let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
472 let prev_hash: Option<String> = sqlx::query_scalar::<_, Option<String>>(
473 "SELECT record_hash FROM audit_events ORDER BY id DESC LIMIT 1",
474 )
475 .fetch_optional(&mut *connection)
476 .await
477 .map_err(|e| KipukaError::Db(format!("loading audit chain tail: {e}")))?
478 .flatten();
479 let canonical = canonical_bytes(
480 ×tamp,
481 event.event_type.as_str(),
482 event.operator.as_deref(),
483 event.subject.as_deref(),
484 detail_json.as_deref(),
485 event.client_addr.as_deref(),
486 None, );
488 let record_hash = state.chain_hash(prev_hash.as_deref(), &canonical);
489
490 let sql = crate::db::pg_sql(
491 "INSERT INTO audit_events \
492 (timestamp, event_type, actor, target, detail_json, source_ip, session_id, prev_hash, record_hash) \
493 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
494 );
495 let result = sqlx::query(sql)
496 .bind(×tamp)
497 .bind(event.event_type.as_str())
498 .bind(&event.operator)
499 .bind(&event.subject)
500 .bind(&detail_json)
501 .bind(&event.client_addr)
502 .bind(None::<String>)
503 .bind(&prev_hash)
504 .bind(&record_hash)
505 .execute(&mut *connection)
506 .await;
507
508 result.map_err(|e| KipukaError::Db(format!("audit insert failed: {e}")))?;
509 Ok(())
510}
511
512fn canonical_bytes(
519 timestamp: &str,
520 event_type: &str,
521 actor: Option<&str>,
522 target: Option<&str>,
523 detail_json: Option<&str>,
524 source_ip: Option<&str>,
525 session_id: Option<&str>,
526) -> Vec<u8> {
527 let mut buf = Vec::with_capacity(128);
528 for field in [
529 timestamp,
530 event_type,
531 actor.unwrap_or(""),
532 target.unwrap_or(""),
533 detail_json.unwrap_or(""),
534 source_ip.unwrap_or(""),
535 session_id.unwrap_or(""),
536 ] {
537 buf.extend_from_slice(field.as_bytes());
538 buf.push(RS);
539 }
540 buf
541}
542
543#[derive(sqlx::FromRow)]
549struct AuditRow {
550 id: i64,
551 timestamp: String,
552 event_type: String,
553 actor: Option<String>,
554 target: Option<String>,
555 detail_json: Option<String>,
556 source_ip: Option<String>,
557 session_id: Option<String>,
558 prev_hash: Option<String>,
559 record_hash: Option<String>,
560}
561
562#[derive(Debug, Clone)]
568pub struct ChainVerifyReport {
569 pub total: u64,
571 pub ok: bool,
573 pub broken_at: Option<i64>,
575 pub detail: Option<String>,
577}
578
579pub async fn verify_chain(
593 pool: &sqlx::AnyPool,
594 state: &AuditState,
595) -> Result<ChainVerifyReport, KipukaError> {
596 let rows: Vec<AuditRow> = sqlx::query_as(
597 "SELECT id, timestamp, event_type, actor, target, detail_json, source_ip, \
598 session_id, prev_hash, record_hash FROM audit_events ORDER BY id ASC",
599 )
600 .fetch_all(pool)
601 .await
602 .map_err(|e| KipukaError::Db(format!("reading audit trail for verification: {e}")))?;
603
604 let total = rows.len() as u64;
605 let mut expected_prev: Option<String> = None;
606
607 for row in &rows {
608 if row.prev_hash.as_deref() != expected_prev.as_deref() {
610 return Ok(ChainVerifyReport {
611 total,
612 ok: false,
613 broken_at: Some(row.id),
614 detail: Some(format!(
615 "prev_hash linkage broken at id={}: stored prev_hash does not match the \
616 preceding record_hash (row deleted, inserted, or reordered)",
617 row.id
618 )),
619 });
620 }
621
622 let canonical = canonical_bytes(
624 &row.timestamp,
625 &row.event_type,
626 row.actor.as_deref(),
627 row.target.as_deref(),
628 row.detail_json.as_deref(),
629 row.source_ip.as_deref(),
630 row.session_id.as_deref(),
631 );
632 let recomputed = state.chain_hash(row.prev_hash.as_deref(), &canonical);
633 match &row.record_hash {
634 Some(stored) if *stored == recomputed => {}
635 _ => {
636 return Ok(ChainVerifyReport {
637 total,
638 ok: false,
639 broken_at: Some(row.id),
640 detail: Some(format!(
641 "record_hash mismatch at id={}: row contents were altered after \
642 insertion",
643 row.id
644 )),
645 });
646 }
647 }
648
649 expected_prev = row.record_hash.clone();
650 }
651
652 Ok(ChainVerifyReport {
653 total,
654 ok: true,
655 broken_at: None,
656 detail: None,
657 })
658}
659
660#[cfg(unix)]
662fn emit_syslog_alarm(count: u32) -> std::io::Result<()> {
663 let socket = std::os::unix::net::UnixDatagram::unbound()?;
664 socket.set_write_timeout(Some(std::time::Duration::from_secs(1)))?;
665 #[cfg(target_os = "macos")]
666 let path = "/var/run/syslog";
667 #[cfg(not(target_os = "macos"))]
668 let path = "/dev/log";
669 socket.connect(path)?;
670 socket.send(
671 format!("<81>kipuka: audit security alarm: {count} consecutive violations").as_bytes(),
672 )?;
673 Ok(())
674}
675#[cfg(not(unix))]
676fn emit_syslog_alarm(_: u32) -> std::io::Result<()> {
677 Err(std::io::Error::new(
678 std::io::ErrorKind::Unsupported,
679 "syslog requires Unix",
680 ))
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 #[test]
688 fn event_type_strings() {
689 assert_eq!(AuditEventType::CaStart.as_str(), "ca.start");
690 assert_eq!(AuditEventType::CertIssue.as_str(), "cert.issue");
691 assert_eq!(AuditEventType::OtpCreate.as_str(), "otp.create");
692 assert_eq!(AuditEventType::AuthFailure.as_str(), "auth.failure");
693 assert_eq!(AuditEventType::AdminLogin.as_str(), "admin.login");
694 assert_eq!(
695 AuditEventType::SecurityViolation.as_str(),
696 "security.violation"
697 );
698 }
699
700 #[test]
701 fn audit_state_violation_tracking() {
702 let state = AuditState::new();
703 assert_eq!(state.record_violation(), 1);
704 assert_eq!(state.record_violation(), 2);
705 state.reset_violations();
706 assert_eq!(state.record_violation(), 1);
707 }
708
709 #[test]
710 fn audit_state_halt_flag() {
711 let state = AuditState::new();
712 assert!(!state.is_halted());
713 state.set_halted(true);
714 assert!(state.is_halted());
715 state.set_halted(false);
716 assert!(!state.is_halted());
717 }
718
719 #[test]
720 fn audit_event_builder() {
721 let event = AuditEvent::new(AuditEventType::CertIssue)
722 .with_ca_id("production")
723 .with_subject("CN=device.example.com")
724 .with_detail("serial=ABC123")
725 .with_client_addr("10.0.0.1");
726
727 assert_eq!(event.event_type, AuditEventType::CertIssue);
728 assert_eq!(event.ca_id.as_deref(), Some("production"));
729 assert_eq!(event.subject.as_deref(), Some("CN=device.example.com"));
730 assert_eq!(event.detail.as_deref(), Some("serial=ABC123"));
731 assert_eq!(event.client_addr.as_deref(), Some("10.0.0.1"));
732 assert!(event.operator.is_none());
733 }
734}
735
736#[cfg(test)]
737mod issuance_commit_tests {
738 use super::*;
739 #[tokio::test]
740 async fn failed_commit_rolls_back_audit_and_latches_halt() {
741 let (db, kind) =
742 crate::db::init_pool(&crate::config::DbConfig::default(), "sqlite::memory:")
743 .await
744 .unwrap();
745 crate::db::run_migrations(&db, kind).await.unwrap();
746 sqlx::query("CREATE TABLE commit_failure (id INTEGER REFERENCES audit_writer_lock(id) DEFERRABLE INITIALLY DEFERRED)").execute(&db).await.unwrap();
747 let state = AuditState::with_config(crate::config::AuditConfig {
748 overflow_policy: crate::config::OverflowPolicy::Halt,
749 ..Default::default()
750 });
751 let mut tx = db.begin().await.unwrap();
752 sqlx::query("INSERT INTO commit_failure VALUES (999)")
753 .execute(&mut *tx)
754 .await
755 .unwrap();
756 record_issuance_in_transaction(&mut tx, &state, "synthetic", "commit regression".into())
757 .await
758 .unwrap();
759 assert!(commit_issuance(tx, &state).await.is_err());
760 assert!(state.is_halted());
761 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_events")
762 .fetch_one(&db)
763 .await
764 .unwrap();
765 assert_eq!(count, 0);
766 }
767}
768
769#[cfg(test)]
770mod chain_tests {
771 use super::*;
772
773 async fn test_db() -> sqlx::AnyPool {
775 let (db, kind) =
776 crate::db::init_pool(&crate::config::DbConfig::default(), "sqlite::memory:")
777 .await
778 .unwrap();
779 crate::db::run_migrations(&db, kind).await.unwrap();
780 db
781 }
782
783 #[tokio::test]
788 async fn chain_verifies_across_all_write_paths() {
789 let db = test_db().await;
790 let state = AuditState::new();
791
792 record(
794 &db,
795 &state,
796 AuditEvent::new(AuditEventType::AdminLogin).with_operator("admin"),
797 )
798 .await;
799
800 record_checked(
802 &db,
803 &state,
804 AuditEvent::new(AuditEventType::EnrollRequest).with_detail("simpleenroll"),
805 )
806 .await
807 .unwrap();
808
809 let mut tx = db.begin().await.unwrap();
811 record_issuance_in_transaction(&mut tx, &state, "ca-a", "serial=01".into())
812 .await
813 .unwrap();
814 record_issuance_in_transaction(&mut tx, &state, "ca-a", "serial=02".into())
816 .await
817 .unwrap();
818 commit_issuance(tx, &state).await.unwrap();
819
820 let report = verify_chain(&db, &state).await.unwrap();
821 assert_eq!(report.total, 4, "all four events must be present");
822 assert!(report.ok, "chain must verify: {:?}", report.detail);
823 assert!(report.broken_at.is_none());
824
825 let first_prev: Option<String> =
827 sqlx::query_scalar("SELECT prev_hash FROM audit_events ORDER BY id ASC LIMIT 1")
828 .fetch_one(&db)
829 .await
830 .unwrap();
831 assert!(first_prev.is_none(), "chain head has no predecessor");
832 }
833
834 #[tokio::test]
837 async fn tampering_with_a_row_breaks_the_chain() {
838 let db = test_db().await;
839 let state = AuditState::new();
840
841 for i in 0..3 {
842 record_checked(
843 &db,
844 &state,
845 AuditEvent::new(AuditEventType::AdminAction).with_detail(format!("op-{i}")),
846 )
847 .await
848 .unwrap();
849 }
850 assert!(verify_chain(&db, &state).await.unwrap().ok);
851
852 let victim: i64 =
854 sqlx::query_scalar("SELECT id FROM audit_events ORDER BY id ASC LIMIT 1 OFFSET 1")
855 .fetch_one(&db)
856 .await
857 .unwrap();
858 sqlx::query("UPDATE audit_events SET detail_json = ? WHERE id = ?")
859 .bind(Some(r#"{"detail":"tampered"}"#.to_string()))
860 .bind(victim)
861 .execute(&db)
862 .await
863 .unwrap();
864
865 let report = verify_chain(&db, &state).await.unwrap();
866 assert!(!report.ok, "tampering must be detected");
867 assert_eq!(report.broken_at, Some(victim));
868 }
869
870 #[tokio::test]
873 async fn deleting_a_row_breaks_linkage() {
874 let db = test_db().await;
875 let state = AuditState::new();
876
877 for i in 0..3 {
878 record_checked(
879 &db,
880 &state,
881 AuditEvent::new(AuditEventType::AdminAction).with_detail(format!("op-{i}")),
882 )
883 .await
884 .unwrap();
885 }
886
887 let victim: i64 =
889 sqlx::query_scalar("SELECT id FROM audit_events ORDER BY id ASC LIMIT 1 OFFSET 1")
890 .fetch_one(&db)
891 .await
892 .unwrap();
893 sqlx::query("DELETE FROM audit_events WHERE id = ?")
894 .bind(victim)
895 .execute(&db)
896 .await
897 .unwrap();
898
899 let report = verify_chain(&db, &state).await.unwrap();
900 assert!(!report.ok, "deletion must break linkage");
901 }
902
903 #[tokio::test]
908 async fn keyed_hmac_detects_forgery_without_the_key() {
909 let db = test_db().await;
910 let signed_cfg = crate::config::AuditConfig {
911 signed: true,
912 ..Default::default()
913 };
914 let keyed = AuditState::from_config(signed_cfg, Some(b"super-secret-key".to_vec()));
915
916 for i in 0..3 {
917 record_checked(
918 &db,
919 &keyed,
920 AuditEvent::new(AuditEventType::CertIssue).with_detail(format!("serial-{i}")),
921 )
922 .await
923 .unwrap();
924 }
925
926 assert!(verify_chain(&db, &keyed).await.unwrap().ok);
928
929 let first = &sqlx::query_as::<_, AuditRow>(
933 "SELECT id, timestamp, event_type, actor, target, detail_json, source_ip, \
934 session_id, prev_hash, record_hash FROM audit_events ORDER BY id ASC LIMIT 1",
935 )
936 .fetch_all(&db)
937 .await
938 .unwrap()[0];
939 let unkeyed = AuditState::new();
940 let canonical = canonical_bytes(
941 &first.timestamp,
942 &first.event_type,
943 first.actor.as_deref(),
944 first.target.as_deref(),
945 Some(r#"{"detail":"forged"}"#),
946 first.source_ip.as_deref(),
947 first.session_id.as_deref(),
948 );
949 let forged = unkeyed.chain_hash(first.prev_hash.as_deref(), &canonical);
950 sqlx::query("UPDATE audit_events SET detail_json = ?, record_hash = ? WHERE id = ?")
951 .bind(Some(r#"{"detail":"forged"}"#.to_string()))
952 .bind(Some(forged))
953 .bind(first.id)
954 .execute(&db)
955 .await
956 .unwrap();
957
958 let report = verify_chain(&db, &keyed).await.unwrap();
961 assert!(
962 !report.ok,
963 "keyed HMAC must reject a forgery made without the key"
964 );
965 assert_eq!(report.broken_at, Some(first.id));
966 }
967}