1use serde::{Deserialize, Serialize};
10
11use crate::abi::{InstanceId, Principal, Tick, TypeCode};
12use crate::runtime::stage::StepStage;
13
14use super::signature::{SignatureClass, VerifierClass};
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct TypeRegistryPin {
21 pub type_code: TypeCode,
23 pub schema_hash: [u8; 32],
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct WalHeader {
32 pub magic: [u8; 8],
34 pub kernel_semver: (u16, u16, u16),
36 pub postcard_version: u32,
38 pub blake3_version: u32,
40 pub domain_separation_context: Vec<u8>,
45 pub world_id: [u8; 32],
48 pub abi_version: (u16, u16),
50 pub manifest_digest: [u8; 32],
52 pub type_registry_pins: Vec<TypeRegistryPin>,
55 pub verifying_key: Option<[u8; 32]>,
59 pub verifying_key_pqc: Option<Vec<u8>>,
65}
66
67impl WalHeader {
68 pub const MAGIC: [u8; 8] = *b"ARKHEWAL";
70 pub const CURRENT_KERNEL_SEMVER: (u16, u16, u16) = (0, 13, 0);
72 pub const ABI_VERSION: (u16, u16) = (0, 13);
74 pub const POSTCARD_MAJOR: u32 = 1;
76 pub const BLAKE3_MAJOR: u32 = 1;
78 pub const DOMAIN_CTX: &'static [u8] = b"arkhe-kernel v0.13 WAL chain domain separation context";
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[repr(u8)]
92pub enum AuthDecisionAnnotation {
93 AllAuthorized = 0,
95 SomeDenied = 1,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct WalRecord {
102 pub seq: u64,
104 pub at: Tick,
106 pub instance: InstanceId,
108 pub principal: Principal,
110 pub action_type_code: TypeCode,
112 pub action_bytes: Vec<u8>,
114 pub caps_bits: u64,
116 pub(crate) stage: StepStage,
117 pub auth_decision: AuthDecisionAnnotation,
119 pub prev_chain_hash: [u8; 32],
121 pub this_chain_hash: [u8; 32],
123 pub signature: Option<Vec<u8>>,
130 pub signature_pqc: Option<Vec<u8>>,
137}
138
139#[derive(Serialize)]
140struct WalRecordBody<'a> {
141 seq: u64,
142 at: Tick,
143 instance: InstanceId,
144 principal: &'a Principal,
145 action_type_code: TypeCode,
146 action_bytes: &'a [u8],
147 caps_bits: u64,
148 stage: &'a StepStage,
149 auth_decision: AuthDecisionAnnotation,
150 prev_chain_hash: [u8; 32],
151}
152
153impl<'a> WalRecordBody<'a> {
154 fn from_record(rec: &'a WalRecord, prev: [u8; 32]) -> Self {
160 Self {
161 seq: rec.seq,
162 at: rec.at,
163 instance: rec.instance,
164 principal: &rec.principal,
165 action_type_code: rec.action_type_code,
166 action_bytes: &rec.action_bytes,
167 caps_bits: rec.caps_bits,
168 stage: &rec.stage,
169 auth_decision: rec.auth_decision,
170 prev_chain_hash: prev,
171 }
172 }
173}
174
175#[derive(Debug, Serialize, Deserialize)]
179pub struct Wal {
180 pub header: WalHeader,
182 pub records: Vec<WalRecord>,
184}
185
186pub struct WalWriter {
189 header: WalHeader,
190 records: Vec<WalRecord>,
191 next_seq: u64,
192 prev_hash: [u8; 32],
193 chain_key: [u8; 32],
194 sig_class: SignatureClass,
195}
196
197fn build_chain_key(world_id: &[u8; 32]) -> [u8; 32] {
198 let ctx = core::str::from_utf8(WalHeader::DOMAIN_CTX).expect("DOMAIN_CTX is valid UTF-8 ASCII");
199 blake3::derive_key(ctx, world_id)
200}
201
202fn build_dsc() -> Vec<u8> {
203 WalHeader::DOMAIN_CTX.to_vec()
204}
205
206impl WalWriter {
207 pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
209 Self::with_signature(world_id, manifest_digest, SignatureClass::None)
210 }
211
212 pub fn with_signature(
216 world_id: [u8; 32],
217 manifest_digest: [u8; 32],
218 sig_class: SignatureClass,
219 ) -> Self {
220 let chain_key = build_chain_key(&world_id);
221 let header = WalHeader {
222 magic: WalHeader::MAGIC,
223 kernel_semver: WalHeader::CURRENT_KERNEL_SEMVER,
224 postcard_version: WalHeader::POSTCARD_MAJOR,
225 blake3_version: WalHeader::BLAKE3_MAJOR,
226 domain_separation_context: build_dsc(),
227 world_id,
228 abi_version: WalHeader::ABI_VERSION,
229 manifest_digest,
230 type_registry_pins: Vec::new(),
231 verifying_key: sig_class.verifying_key_bytes(),
232 verifying_key_pqc: sig_class.verifying_key_pqc_bytes(),
233 };
234 Self {
235 header,
236 records: Vec::new(),
237 next_seq: 0,
238 prev_hash: [0u8; 32],
239 chain_key,
240 sig_class,
241 }
242 }
243
244 #[allow(clippy::too_many_arguments)]
245 pub(crate) fn append(
246 &mut self,
247 at: Tick,
248 instance: InstanceId,
249 principal: Principal,
250 action_type_code: TypeCode,
251 action_bytes: Vec<u8>,
252 caps_bits: u64,
253 stage: StepStage,
254 auth_decision: AuthDecisionAnnotation,
255 ) -> Result<&WalRecord, WalError> {
256 self.next_seq = self.next_seq.saturating_add(1);
257 let body = WalRecordBody {
258 seq: self.next_seq,
259 at,
260 instance,
261 principal: &principal,
262 action_type_code,
263 action_bytes: &action_bytes,
264 caps_bits,
265 stage: &stage,
266 auth_decision,
267 prev_chain_hash: self.prev_hash,
268 };
269 let body_bytes = postcard::to_allocvec(&body)
270 .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
271 let mut hasher = blake3::Hasher::new_keyed(&self.chain_key);
272 hasher.update(&self.prev_hash);
273 hasher.update(&body_bytes);
274 let this_hash: [u8; 32] = *hasher.finalize().as_bytes();
275
276 let (signature, signature_pqc) = match self.sig_class.sign_hybrid(&body_bytes) {
280 Some(hyb) => (Some(hyb.ed25519.to_vec()), Some(hyb.pqc)),
281 None => (self.sig_class.sign(&body_bytes).map(|s| s.to_vec()), None),
282 };
283
284 let record = WalRecord {
285 seq: self.next_seq,
286 at,
287 instance,
288 principal,
289 action_type_code,
290 action_bytes,
291 caps_bits,
292 stage,
293 auth_decision,
294 prev_chain_hash: self.prev_hash,
295 this_chain_hash: this_hash,
296 signature,
297 signature_pqc,
298 };
299 self.records.push(record);
300 self.prev_hash = this_hash;
301 Ok(self.records.last().expect("just pushed"))
302 }
303
304 pub fn header(&self) -> &WalHeader {
306 &self.header
307 }
308 pub fn records(&self) -> &[WalRecord] {
310 &self.records
311 }
312 pub fn chain_tip(&self) -> [u8; 32] {
314 self.prev_hash
315 }
316 pub fn record_count(&self) -> usize {
318 self.records.len()
319 }
320}
321
322impl Wal {
323 pub fn from_writer(w: WalWriter) -> Self {
325 Self {
326 header: w.header,
327 records: w.records,
328 }
329 }
330
331 pub fn serialize(&self) -> Result<Vec<u8>, WalError> {
334 postcard::to_allocvec(self).map_err(|e| WalError::SerializeFailed(format!("{}", e)))
335 }
336
337 pub fn deserialize(bytes: &[u8]) -> Result<Self, WalError> {
339 postcard::from_bytes(bytes).map_err(|e| WalError::DeserializeFailed(format!("{}", e)))
340 }
341
342 pub fn chain_tip(&self) -> [u8; 32] {
344 self.records
345 .last()
346 .map(|r| r.this_chain_hash)
347 .unwrap_or([0u8; 32])
348 }
349
350 pub fn verify_chain(&self, world_id: [u8; 32]) -> Result<(), WalError> {
356 let chain_key = build_chain_key(&world_id);
357 let verifier = VerifierClass::from_header_bytes(
358 self.header.verifying_key.as_ref(),
359 self.header.verifying_key_pqc.as_deref(),
360 )
361 .map_err(|e| match e {
362 crate::persist::signature::VerifierInitError::InvalidEd25519Key
363 | crate::persist::signature::VerifierInitError::InvalidPqcKey => {
364 WalError::InvalidVerifyingKey
365 }
366 crate::persist::signature::VerifierInitError::PqcWithoutEd25519 => {
367 WalError::PqcWithoutEd25519
368 }
369 })?;
370 let mut prev = [0u8; 32];
371 for (i, rec) in self.records.iter().enumerate() {
372 if blake3::Hash::from(rec.prev_chain_hash) != blake3::Hash::from(prev) {
373 return Err(WalError::ChainBroken { at_record: i });
374 }
375 let body = WalRecordBody::from_record(rec, prev);
376 let body_bytes = postcard::to_allocvec(&body)
377 .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
378 let mut hasher = blake3::Hasher::new_keyed(&chain_key);
379 hasher.update(&prev);
380 hasher.update(&body_bytes);
381 let computed: [u8; 32] = *hasher.finalize().as_bytes();
382 if blake3::Hash::from(computed) != blake3::Hash::from(rec.this_chain_hash) {
383 return Err(WalError::HashMismatch { at_record: i });
384 }
385
386 match &verifier {
387 VerifierClass::None => {}
388 VerifierClass::Ed25519(_) => {
389 let sig_vec = rec
390 .signature
391 .as_ref()
392 .ok_or(WalError::MissingSignature { at_record: i })?;
393 verifier
394 .verify(&body_bytes, sig_vec)
395 .map_err(|_| WalError::SignatureMismatch { at_record: i })?;
396 }
397 VerifierClass::Hybrid { .. } => {
398 let sig_vec = rec
399 .signature
400 .as_ref()
401 .ok_or(WalError::MissingSignature { at_record: i })?;
402 let sig_pqc = rec
403 .signature_pqc
404 .as_ref()
405 .ok_or(WalError::MissingPqcSignature { at_record: i })?;
406 verifier
407 .verify_hybrid(&body_bytes, sig_vec, sig_pqc)
408 .map_err(|_| WalError::PqcSignatureMismatch { at_record: i })?;
409 }
410 }
411
412 prev = computed;
413 }
414 Ok(())
415 }
416}
417
418#[derive(Debug, Clone)]
421#[non_exhaustive]
422pub enum WalError {
423 SerializeFailed(String),
425 DeserializeFailed(String),
427 ChainBroken {
430 at_record: usize,
432 },
433 HashMismatch {
436 at_record: usize,
438 },
439 HeaderIncompatible(String),
441 InvalidVerifyingKey,
443 MissingSignature {
445 at_record: usize,
447 },
448 SignatureMismatch {
450 at_record: usize,
452 },
453 MissingPqcSignature {
456 at_record: usize,
458 },
459 PqcSignatureMismatch {
462 at_record: usize,
464 },
465 PqcWithoutEd25519,
469}
470
471impl core::fmt::Display for WalError {
472 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
473 match self {
474 Self::SerializeFailed(m) => write!(f, "wal serialize failed: {}", m),
475 Self::DeserializeFailed(m) => write!(f, "wal deserialize failed: {}", m),
476 Self::ChainBroken { at_record } => {
477 write!(f, "wal chain broken at record {}", at_record)
478 }
479 Self::HashMismatch { at_record } => {
480 write!(f, "wal hash mismatch at record {}", at_record)
481 }
482 Self::HeaderIncompatible(m) => write!(f, "wal header incompatible: {}", m),
483 Self::InvalidVerifyingKey => write!(
484 f,
485 "wal verifying_key invalid (not a valid Ed25519 public key)"
486 ),
487 Self::MissingSignature { at_record } => {
488 write!(f, "wal signature missing at record {}", at_record)
489 }
490 Self::SignatureMismatch { at_record } => {
491 write!(f, "wal signature mismatch at record {}", at_record)
492 }
493 Self::MissingPqcSignature { at_record } => {
494 write!(f, "wal PQC signature missing at record {}", at_record)
495 }
496 Self::PqcSignatureMismatch { at_record } => {
497 write!(f, "wal PQC signature mismatch at record {}", at_record)
498 }
499 Self::PqcWithoutEd25519 => write!(
500 f,
501 "wal envelope invalid (verifying_key_pqc set without verifying_key)"
502 ),
503 }
504 }
505}
506
507impl std::error::Error for WalError {}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::abi::{EntityId, ExternalId, RouteId};
513 use crate::runtime::stage::{LedgerOp, StagedStateDelta};
514 use crate::state::EntityMeta;
515
516 fn world() -> [u8; 32] {
517 [7u8; 32]
518 }
519 fn manifest() -> [u8; 32] {
520 [3u8; 32]
521 }
522
523 fn sample_stage() -> StepStage {
524 let mut s = StepStage::default();
525 s.state_ops.push(StagedStateDelta::SpawnEntity {
526 id: EntityId::new(1).unwrap(),
527 meta: EntityMeta {
528 owner: Principal::System,
529 created: Tick(0),
530 },
531 });
532 s.ledger_delta
533 .ops
534 .push(LedgerOp::AddEntity(EntityId::new(1).unwrap()));
535 s.id_counters.next_entity_advance = 1;
536 s
537 }
538
539 #[test]
540 fn empty_writer_serializes_and_deserializes() {
541 let w = WalWriter::new(world(), manifest());
542 let wal = Wal::from_writer(w);
543 let bytes = wal.serialize().unwrap();
544 let back = Wal::deserialize(&bytes).unwrap();
545 assert_eq!(back.header, wal.header);
546 assert_eq!(back.records.len(), 0);
547 assert_eq!(back.chain_tip(), [0u8; 32]);
548 }
549
550 #[test]
551 fn single_append_produces_nonzero_chain_tip() {
552 let mut w = WalWriter::new(world(), manifest());
553 w.append(
554 Tick(5),
555 InstanceId::new(1).unwrap(),
556 Principal::System,
557 TypeCode(100),
558 vec![1, 2, 3],
559 0,
560 sample_stage(),
561 AuthDecisionAnnotation::AllAuthorized,
562 )
563 .unwrap();
564 let tip = w.chain_tip();
565 assert_ne!(tip, [0u8; 32]);
566 assert_eq!(w.record_count(), 1);
567 }
568
569 #[test]
570 fn multi_record_chain_links_each_record() {
571 let mut w = WalWriter::new(world(), manifest());
572 for i in 0..5 {
573 w.append(
574 Tick(i),
575 InstanceId::new(1).unwrap(),
576 Principal::System,
577 TypeCode(100),
578 vec![i as u8],
579 0,
580 StepStage::default(),
581 AuthDecisionAnnotation::AllAuthorized,
582 )
583 .unwrap();
584 }
585 let wal = Wal::from_writer(w);
586 assert_eq!(wal.records.len(), 5);
587 let mut prev = [0u8; 32];
589 for rec in &wal.records {
590 assert_eq!(rec.prev_chain_hash, prev);
591 prev = rec.this_chain_hash;
592 }
593 wal.verify_chain(world()).expect("clean chain");
594 }
595
596 #[test]
597 fn tampered_record_breaks_verify_chain() {
598 let mut w = WalWriter::new(world(), manifest());
599 for i in 0..3 {
600 w.append(
601 Tick(i),
602 InstanceId::new(1).unwrap(),
603 Principal::System,
604 TypeCode(100),
605 vec![i as u8],
606 0,
607 StepStage::default(),
608 AuthDecisionAnnotation::AllAuthorized,
609 )
610 .unwrap();
611 }
612 let mut wal = Wal::from_writer(w);
613 wal.records[1].caps_bits = 0xDEAD_BEEF;
615 let result = wal.verify_chain(world());
616 assert!(matches!(result, Err(WalError::HashMismatch { .. })));
617 }
618
619 #[test]
620 fn verify_chain_detects_broken_prev_link() {
621 let mut w = WalWriter::new(world(), manifest());
622 for i in 0..3 {
623 w.append(
624 Tick(i),
625 InstanceId::new(1).unwrap(),
626 Principal::System,
627 TypeCode(100),
628 vec![i as u8],
629 0,
630 StepStage::default(),
631 AuthDecisionAnnotation::AllAuthorized,
632 )
633 .unwrap();
634 }
635 let mut wal = Wal::from_writer(w);
636 wal.records[1].prev_chain_hash[0] ^= 1;
641 let result = wal.verify_chain(world());
642 assert!(matches!(
643 result,
644 Err(WalError::ChainBroken { at_record: 1 })
645 ));
646 }
647
648 #[test]
649 fn different_world_id_produces_different_chain() {
650 let mut w1 = WalWriter::new([1u8; 32], manifest());
651 let mut w2 = WalWriter::new([2u8; 32], manifest());
652 for w in [&mut w1, &mut w2] {
653 w.append(
654 Tick(0),
655 InstanceId::new(1).unwrap(),
656 Principal::System,
657 TypeCode(100),
658 vec![],
659 0,
660 StepStage::default(),
661 AuthDecisionAnnotation::AllAuthorized,
662 )
663 .unwrap();
664 }
665 assert_ne!(w1.chain_tip(), w2.chain_tip());
667 }
668
669 #[test]
670 fn verify_chain_against_wrong_world_id_fails() {
671 let mut w = WalWriter::new(world(), manifest());
672 w.append(
673 Tick(0),
674 InstanceId::new(1).unwrap(),
675 Principal::System,
676 TypeCode(100),
677 vec![],
678 0,
679 StepStage::default(),
680 AuthDecisionAnnotation::AllAuthorized,
681 )
682 .unwrap();
683 let wal = Wal::from_writer(w);
684 let result = wal.verify_chain([99u8; 32]);
685 assert!(matches!(result, Err(WalError::HashMismatch { .. })));
686 }
687
688 #[test]
689 fn auth_decision_annotation_round_trips() {
690 let mut w = WalWriter::new(world(), manifest());
691 w.append(
692 Tick(0),
693 InstanceId::new(1).unwrap(),
694 Principal::External(ExternalId(7)),
695 TypeCode(101),
696 vec![],
697 0,
698 StepStage::default(),
699 AuthDecisionAnnotation::SomeDenied,
700 )
701 .unwrap();
702 let wal = Wal::from_writer(w);
703 let bytes = wal.serialize().unwrap();
704 let back = Wal::deserialize(&bytes).unwrap();
705 assert_eq!(
706 back.records[0].auth_decision,
707 AuthDecisionAnnotation::SomeDenied
708 );
709 }
710
711 #[test]
712 fn header_carries_magic_and_versions() {
713 let h = WalWriter::new(world(), manifest()).header().clone();
714 assert_eq!(h.magic, *b"ARKHEWAL");
715 assert_eq!(h.kernel_semver, (0, 13, 0));
716 assert_eq!(h.world_id, world());
717 assert_eq!(h.manifest_digest, manifest());
718 assert!(h.type_registry_pins.is_empty());
719 assert!(h.verifying_key.is_none());
720 let _ = RouteId(1);
721 }
722
723 fn append_one(w: &mut WalWriter) {
726 w.append(
727 Tick(0),
728 InstanceId::new(1).unwrap(),
729 Principal::System,
730 TypeCode(100),
731 vec![1, 2, 3],
732 0,
733 sample_stage(),
734 AuthDecisionAnnotation::AllAuthorized,
735 )
736 .unwrap();
737 }
738
739 #[test]
740 fn signature_class_none_produces_no_signature() {
741 let mut w = WalWriter::new(world(), manifest());
742 append_one(&mut w);
743 let wal = Wal::from_writer(w);
744 assert!(wal.header.verifying_key.is_none());
745 assert!(wal.records[0].signature.is_none());
746 wal.verify_chain(world())
747 .expect("Tier 1 chain still verifies");
748 }
749
750 #[test]
751 fn signature_class_ed25519_signs_each_record() {
752 let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
753 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
754 for _ in 0..3 {
755 append_one(&mut w);
756 }
757 let wal = Wal::from_writer(w);
758 assert!(wal.header.verifying_key.is_some());
759 assert_eq!(wal.records.len(), 3);
760 for rec in &wal.records {
761 let sig = rec.signature.as_ref().expect("Ed25519 signs every record");
762 assert_eq!(sig.len(), 64);
763 }
764 }
765
766 #[test]
767 fn verify_chain_validates_signatures() {
768 let sig_class = SignatureClass::new_ed25519_from_secret([11u8; 32]);
769 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
770 for _ in 0..3 {
771 append_one(&mut w);
772 }
773 let wal = Wal::from_writer(w);
774 let bytes = wal.serialize().unwrap();
776 let back = Wal::deserialize(&bytes).unwrap();
777 back.verify_chain(world()).expect("signed chain verifies");
778 }
779
780 #[test]
781 fn tampered_signature_fails_verify() {
782 let sig_class = SignatureClass::new_ed25519_from_secret([13u8; 32]);
785 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
786 append_one(&mut w);
787 append_one(&mut w);
788 let mut wal = Wal::from_writer(w);
789 if let Some(sig) = wal.records[1].signature.as_mut() {
791 sig[0] ^= 0xFF;
792 }
793 let result = wal.verify_chain(world());
794 assert!(matches!(
795 result,
796 Err(WalError::SignatureMismatch { at_record: 1 })
797 ));
798 }
799
800 #[test]
801 fn missing_signature_fails_verify_when_header_has_key() {
802 let sig_class = SignatureClass::new_ed25519_from_secret([17u8; 32]);
803 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
804 append_one(&mut w);
805 let mut wal = Wal::from_writer(w);
806 wal.records[0].signature = None;
808 let result = wal.verify_chain(world());
809 assert!(matches!(
810 result,
811 Err(WalError::MissingSignature { at_record: 0 })
812 ));
813 }
814
815 #[test]
816 fn wrong_key_fails_verify() {
817 let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
818 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
819 append_one(&mut w);
820 let mut wal = Wal::from_writer(w);
821 let other = SignatureClass::new_ed25519_from_secret([23u8; 32])
824 .verifying_key_bytes()
825 .unwrap();
826 wal.header.verifying_key = Some(other);
827 let result = wal.verify_chain(world());
828 assert!(matches!(
829 result,
830 Err(WalError::SignatureMismatch { at_record: 0 })
831 ));
832 }
833
834 #[test]
835 fn signature_deterministic_across_runs() {
836 let mk = |secret: [u8; 32]| -> Vec<Vec<u8>> {
840 let mut w = WalWriter::with_signature(
841 world(),
842 manifest(),
843 SignatureClass::new_ed25519_from_secret(secret),
844 );
845 append_one(&mut w);
846 append_one(&mut w);
847 let wal = Wal::from_writer(w);
848 wal.records
849 .iter()
850 .map(|r| r.signature.clone().unwrap())
851 .collect()
852 };
853 let sigs1 = mk([29u8; 32]);
854 let sigs2 = mk([29u8; 32]);
855 assert_eq!(sigs1, sigs2);
856 assert_eq!(sigs1[0].len(), 64);
857 }
858
859 #[test]
860 fn domain_ctx_byte_identity_blake3() {
861 const EXPECTED: &[u8] = b"arkhe-kernel v0.13 WAL chain domain separation context";
878 assert_eq!(WalHeader::DOMAIN_CTX, EXPECTED);
879 assert_eq!(WalHeader::DOMAIN_CTX.len(), 54);
880
881 const FROZEN_HEX: &str = "a2537fb224ba77e9a3d9237ae7afac2db2d3cc1f45ddb1fd9d07548e6eee6ab8";
883 let actual_hex = blake3::hash(WalHeader::DOMAIN_CTX).to_hex();
884 assert_eq!(
885 actual_hex.as_str(),
886 FROZEN_HEX,
887 "DOMAIN_CTX BLAKE3 hash regression — byte-level edit detected",
888 );
889 }
890
891 #[test]
894 fn wal_record_postcard_layout_byte_identity() {
895 let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
900 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
901 append_one(&mut w);
902 let wal = Wal::from_writer(w);
903 let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
904 const FROZEN_HEX: &str = "63655e756cf063655522dff4b8cc053019ab44846b767f67310816dcdf04d167";
905 let actual = blake3::hash(&encoded);
906 assert_eq!(
907 actual.to_hex().as_str(),
908 FROZEN_HEX,
909 "WalRecord postcard byte sequence regression",
910 );
911 }
912
913 #[test]
914 fn wal_record_hybrid_layout_byte_identity() {
915 let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
920 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
921 append_one(&mut w);
922 let mut wal = Wal::from_writer(w);
923 let baseline_encoded = postcard::to_allocvec(&wal.records[0]).expect("baseline encode");
925 wal.records[0].signature_pqc = Some(vec![0xAB; 3309]);
927 let with_pqc_encoded = postcard::to_allocvec(&wal.records[0]).expect("with_pqc encode");
928 assert_eq!(
933 with_pqc_encoded.len() - baseline_encoded.len(),
934 3311,
935 "PQC signature envelope size mismatch — ML-DSA 65 must fit",
936 );
937 }
938
939 #[test]
940 fn wal_header_verifying_key_pqc_slot_pinned() {
941 let h = WalWriter::new(world(), manifest()).header().clone();
946 assert!(h.verifying_key.is_none());
948 assert!(h.verifying_key_pqc.is_none());
949
950 let mut h_pqc = h.clone();
951 h_pqc.verifying_key_pqc = Some(vec![0xCD; 1952]);
953 let baseline = postcard::to_allocvec(&h).expect("encode baseline");
954 let with_pqc = postcard::to_allocvec(&h_pqc).expect("encode with pqc key");
955 assert_eq!(
960 with_pqc.len() - baseline.len(),
961 1954,
962 "PQC verifying-key envelope size mismatch — ML-DSA 65 must fit",
963 );
964 }
965
966 #[test]
967 fn chain_hash_unchanged_for_ed25519_records() {
968 let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
974 w.append(
975 Tick(0),
976 InstanceId::new(1).unwrap(),
977 Principal::System,
978 TypeCode(100),
979 vec![1, 2, 3],
980 0,
981 sample_stage(),
982 AuthDecisionAnnotation::AllAuthorized,
983 )
984 .unwrap();
985 let wal = Wal::from_writer(w);
986 const FROZEN_HEX: &str = "52c2764721d6ab8e709f13987c78c4482e05d41fe44f9aff5a538ac61af148d4";
987 let actual_hex = blake3::Hash::from(wal.records[0].this_chain_hash).to_hex();
988 assert_eq!(
989 actual_hex.as_str(),
990 FROZEN_HEX,
991 "chain hash regression — DOMAIN_CTX or WalRecordBody field order changed",
992 );
993 }
994
995 #[test]
996 fn wal_record_postcard_field_order_baseline() {
997 let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1002 w.append(
1003 Tick(42),
1004 InstanceId::new(99).unwrap(),
1005 Principal::System,
1006 TypeCode(0xCAFE),
1007 vec![0xAA, 0xBB, 0xCC],
1008 0xFF,
1009 sample_stage(),
1010 AuthDecisionAnnotation::AllAuthorized,
1011 )
1012 .unwrap();
1013 let wal = Wal::from_writer(w);
1014 let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1015 const FROZEN_HEX: &str = "d6ffb241f7f5a277ef2402fd25184620bac8f6539eb3f853f5d3562d2ce29ad8";
1016 let actual = blake3::hash(&encoded);
1017 assert_eq!(
1018 actual.to_hex().as_str(),
1019 FROZEN_HEX,
1020 "WalRecord postcard field order regression",
1021 );
1022 }
1023
1024 #[test]
1027 fn hybrid_writer_emits_both_signatures() {
1028 let sig_class = SignatureClass::new_hybrid_from_secrets([7u8; 32], [11u8; 32]);
1033 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1034 for _ in 0..3 {
1035 append_one(&mut w);
1036 }
1037 let wal = Wal::from_writer(w);
1038 assert_eq!(
1039 wal.header
1040 .verifying_key
1041 .expect("Hybrid pins Ed25519 vk")
1042 .len(),
1043 32
1044 );
1045 assert_eq!(
1046 wal.header
1047 .verifying_key_pqc
1048 .as_ref()
1049 .expect("Hybrid pins PQC vk")
1050 .len(),
1051 1952
1052 );
1053 assert_eq!(wal.records.len(), 3);
1054 for rec in &wal.records {
1055 assert_eq!(
1056 rec.signature
1057 .as_ref()
1058 .expect("Hybrid signs Ed25519 every record")
1059 .len(),
1060 64
1061 );
1062 assert_eq!(
1063 rec.signature_pqc
1064 .as_ref()
1065 .expect("Hybrid signs PQC every record")
1066 .len(),
1067 3309
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn hybrid_verify_chain_and_mode_passes_with_both_valid() {
1074 let sig_class = SignatureClass::new_hybrid_from_secrets([13u8; 32], [17u8; 32]);
1078 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1079 for _ in 0..3 {
1080 append_one(&mut w);
1081 }
1082 let wal = Wal::from_writer(w);
1083 let bytes = wal.serialize().unwrap();
1084 let back = Wal::deserialize(&bytes).unwrap();
1085 back.verify_chain(world())
1086 .expect("Hybrid signed chain verifies (AND-mode pass)");
1087 }
1088
1089 #[test]
1090 fn hybrid_verify_chain_rejects_missing_pqc() {
1091 let sig_class = SignatureClass::new_hybrid_from_secrets([19u8; 32], [23u8; 32]);
1094 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1095 append_one(&mut w);
1096 let mut wal = Wal::from_writer(w);
1097 wal.records[0].signature_pqc = None;
1099 let result = wal.verify_chain(world());
1100 assert!(matches!(
1101 result,
1102 Err(WalError::MissingPqcSignature { at_record: 0 })
1103 ));
1104 }
1105
1106 #[test]
1107 fn hybrid_verify_chain_rejects_corrupt_pqc_signature() {
1108 let sig_class = SignatureClass::new_hybrid_from_secrets([29u8; 32], [31u8; 32]);
1112 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1113 append_one(&mut w);
1114 append_one(&mut w);
1115 let mut wal = Wal::from_writer(w);
1116 if let Some(sig_pqc) = wal.records[1].signature_pqc.as_mut() {
1118 sig_pqc[0] ^= 0xFF;
1119 }
1120 let result = wal.verify_chain(world());
1121 assert!(matches!(
1122 result,
1123 Err(WalError::PqcSignatureMismatch { at_record: 1 })
1124 ));
1125 }
1126
1127 #[test]
1128 fn hybrid_verify_chain_rejects_corrupt_ed25519_when_pqc_valid() {
1129 let sig_class = SignatureClass::new_hybrid_from_secrets([37u8; 32], [41u8; 32]);
1135 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1136 append_one(&mut w);
1137 append_one(&mut w);
1138 let mut wal = Wal::from_writer(w);
1139 if let Some(sig) = wal.records[0].signature.as_mut() {
1141 sig[0] ^= 0xFF;
1142 }
1143 let result = wal.verify_chain(world());
1144 assert!(matches!(
1145 result,
1146 Err(WalError::PqcSignatureMismatch { at_record: 0 })
1147 ));
1148 }
1149
1150 #[test]
1151 fn ed25519_only_wal_replays_under_hybrid_kernel() {
1152 let sig_class = SignatureClass::new_ed25519_from_secret([43u8; 32]);
1158 let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1159 for _ in 0..3 {
1160 append_one(&mut w);
1161 }
1162 let wal = Wal::from_writer(w);
1163 assert!(wal.header.verifying_key.is_some());
1166 assert!(wal.header.verifying_key_pqc.is_none());
1167 for rec in &wal.records {
1168 assert!(rec.signature.is_some());
1169 assert!(rec.signature_pqc.is_none());
1170 }
1171 let bytes = wal.serialize().unwrap();
1172 let back = Wal::deserialize(&bytes).unwrap();
1173 back.verify_chain(world())
1174 .expect("Ed25519-only WAL replays under Hybrid-capable kernel");
1175 }
1176
1177 #[test]
1178 fn pqc_without_ed25519_envelope_rejected() {
1179 let sig_class = SignatureClass::new_hybrid_from_secrets([47u8; 32], [53u8; 32]);
1186 let w = WalWriter::with_signature(world(), manifest(), sig_class);
1187 let mut wal = Wal::from_writer(w);
1188 wal.header.verifying_key = None;
1191 let result = wal.verify_chain(world());
1192 assert!(matches!(result, Err(WalError::PqcWithoutEd25519)));
1193 }
1194}