1use arkhe_forge_core::context::{ActionContext, ActionError};
20use arkhe_forge_core::event::{RuntimeBootstrap, RuntimeSignatureClass, SemVer};
21use arkhe_kernel::abi::{Tick, TypeCode};
22use serde::{Deserialize, Serialize};
23
24pub type ManifestDigest = [u8; 32];
26
27const DIGEST_DOMAIN: &str = "arkhe-forge-manifest-digest";
29
30#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct ShellSection {
36 pub shell_id: String,
38 pub display_name: String,
40}
41
42#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct RuntimeSection {
46 pub runtime_max: String,
49 pub runtime_current: String,
51}
52
53#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct AuditSection {
57 pub pii_cipher: String,
60 pub dek_backend: String,
63 pub kms_auto_promote: String,
67 pub signature_class: String,
71 pub compliance_tier: u8,
74}
75
76#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct FrontendSection {
81 pub tls_required: bool,
83 pub alpha_credential_rotation_required: bool,
85}
86
87impl Default for FrontendSection {
88 fn default() -> Self {
89 Self {
90 tls_required: true,
91 alpha_credential_rotation_required: true,
92 }
93 }
94}
95
96#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ManifestSnapshot {
100 pub schema_version: u16,
102 pub shell: ShellSection,
104 pub runtime: RuntimeSection,
106 pub audit: AuditSection,
108 #[serde(default)]
110 pub frontend: FrontendSection,
111}
112
113#[derive(Debug, thiserror::Error)]
117#[non_exhaustive]
118pub enum ManifestError {
119 #[error("parse error: {0}")]
121 ParseError(String),
122
123 #[error("missing required field: {0}")]
126 MissingRequired(&'static str),
127
128 #[error("tier {tier} incompatible with backend '{backend}'")]
130 TierBackendMismatch {
131 tier: u8,
133 backend: String,
135 },
136
137 #[error("software-kek rejected: runtime_current {current} > 0.15")]
140 SoftwareKekProductionRefused {
141 current: String,
143 },
144
145 #[error("version mismatch: runtime_max < runtime_current")]
149 VersionMismatch,
150
151 #[error("unknown field: {0}")]
154 UnknownField(String),
155
156 #[error("invalid value for {field}: {reason}")]
158 InvalidValue {
159 field: &'static str,
161 reason: String,
163 },
164}
165
166pub struct ManifestLoader;
170
171impl ManifestLoader {
172 pub fn load(toml_bytes: &[u8]) -> Result<(ManifestSnapshot, ManifestDigest), ManifestError> {
174 let text = core::str::from_utf8(toml_bytes)
175 .map_err(|e| ManifestError::ParseError(format!("utf-8: {}", e)))?;
176 let snapshot: ManifestSnapshot =
177 toml::from_str(text).map_err(|e| ManifestError::ParseError(e.to_string()))?;
178 Self::validate(&snapshot)?;
179 let digest = Self::canonical_digest(&snapshot)?;
180 Ok((snapshot, digest))
181 }
182
183 pub fn validate(m: &ManifestSnapshot) -> Result<(), ManifestError> {
187 if m.shell.shell_id.len() > 32 {
189 return Err(ManifestError::InvalidValue {
190 field: "shell.shell_id",
191 reason: format!("length {} > 32", m.shell.shell_id.len()),
192 });
193 }
194
195 let current =
197 parse_version(&m.runtime.runtime_current).ok_or(ManifestError::InvalidValue {
198 field: "runtime.runtime_current",
199 reason: format!(
200 "not a 'M.N' or 'M.N.P' semver: {}",
201 m.runtime.runtime_current
202 ),
203 })?;
204 let max = parse_version(&m.runtime.runtime_max).ok_or(ManifestError::InvalidValue {
205 field: "runtime.runtime_max",
206 reason: format!("not a 'M.N' or 'M.N.P' semver: {}", m.runtime.runtime_max),
207 })?;
208 if max < current {
209 return Err(ManifestError::VersionMismatch);
210 }
211
212 match (m.audit.compliance_tier, m.audit.dek_backend.as_str()) {
214 (0, "software-kek") => {}
215 (0, other) => {
216 return Err(ManifestError::TierBackendMismatch {
217 tier: 0,
218 backend: other.to_string(),
219 });
220 }
221 (1 | 2, "software-kek") => {
222 return Err(ManifestError::TierBackendMismatch {
223 tier: m.audit.compliance_tier,
224 backend: "software-kek".to_string(),
225 });
226 }
227 (1 | 2, _) => {}
228 _ => {
229 return Err(ManifestError::InvalidValue {
230 field: "audit.compliance_tier",
231 reason: format!("unsupported tier {}", m.audit.compliance_tier),
232 });
233 }
234 }
235
236 if m.audit.dek_backend == "software-kek" && (current.0, current.1) > (0, 15) {
238 return Err(ManifestError::SoftwareKekProductionRefused {
239 current: m.runtime.runtime_current.clone(),
240 });
241 }
242
243 if RuntimeSignatureClass::from_manifest_str(&m.audit.signature_class).is_none() {
247 return Err(ManifestError::InvalidValue {
248 field: "audit.signature_class",
249 reason: format!("unknown signature class {}", m.audit.signature_class),
250 });
251 }
252
253 Ok(())
254 }
255
256 pub fn canonical_digest(m: &ManifestSnapshot) -> Result<ManifestDigest, ManifestError> {
286 let canonical = toml::to_string(m)
287 .map_err(|e| ManifestError::ParseError(format!("toml serialize: {}", e)))?;
288 let key = blake3::derive_key(DIGEST_DOMAIN, &[]);
289 let hash = blake3::keyed_hash(&key, canonical.as_bytes());
290 let mut out = [0u8; 32];
291 out.copy_from_slice(hash.as_bytes());
292 Ok(out)
293 }
294}
295
296pub fn emit_runtime_bootstrap(
303 digest: &ManifestDigest,
304 l0_semver: SemVer,
305 runtime_semver: SemVer,
306 typecode_pins: Vec<TypeCode>,
307 bootstrap_tick: Tick,
308 ctx: &mut ActionContext<'_>,
309) -> Result<(), ActionError> {
310 let event = RuntimeBootstrap {
311 schema_version: 1,
312 l0_semver,
313 runtime_semver,
314 manifest_digest: *digest,
315 typecode_pins,
316 bootstrap_tick,
317 };
318 ctx.emit_event(&event)
319}
320
321fn parse_version(s: &str) -> Option<(u16, u16, u16)> {
327 let mut parts = s.splitn(3, '.');
328 let major: u16 = parts.next()?.parse().ok()?;
329 let minor: u16 = parts.next()?.parse().ok()?;
330 let patch: u16 = match parts.next() {
331 Some(p) => p.parse().ok()?,
332 None => 0,
333 };
334 Some((major, minor, patch))
335}
336
337#[cfg(test)]
340#[allow(clippy::unwrap_used, clippy::expect_used)]
341mod tests {
342 use super::*;
343 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
344
345 pub(super) const TIER0_DEV_TOML: &str = r#"
346schema_version = 1
347
348[shell]
349shell_id = "shell.dev.example"
350display_name = "Dev Sandbox"
351
352[runtime]
353runtime_max = "0.15"
354runtime_current = "0.13"
355
356[audit]
357pii_cipher = "xchacha20-poly1305"
358dek_backend = "software-kek"
359kms_auto_promote = "manual"
360signature_class = "ed25519"
361compliance_tier = 0
362"#;
363
364 const TIER1_PROD_TOML: &str = r#"
365schema_version = 1
366
367[shell]
368shell_id = "shell.prod.example"
369display_name = "Prod Shell"
370
371[runtime]
372runtime_max = "0.20"
373runtime_current = "0.13"
374
375[audit]
376pii_cipher = "aes-256-gcm-siv"
377dek_backend = "aws-kms"
378kms_auto_promote = "after_60min"
379signature_class = "hybrid"
380compliance_tier = 1
381
382[frontend]
383tls_required = true
384alpha_credential_rotation_required = true
385"#;
386
387 #[test]
388 fn load_valid_tier0_dev_manifest() {
389 let (snap, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
390 assert_eq!(snap.schema_version, 1);
391 assert_eq!(snap.audit.compliance_tier, 0);
392 assert_eq!(snap.audit.dek_backend, "software-kek");
393 assert_eq!(digest.len(), 32);
394 assert!(snap.frontend.tls_required); }
396
397 #[test]
398 fn load_valid_tier1_prod_manifest() {
399 let (snap, digest) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
400 assert_eq!(snap.audit.compliance_tier, 1);
401 assert_eq!(snap.audit.dek_backend, "aws-kms");
402 assert_eq!(digest.len(), 32);
403 }
404
405 #[test]
406 fn digest_is_deterministic_across_loads() {
407 let (_, d1) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
408 let (_, d2) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
409 assert_eq!(d1, d2);
410 }
411
412 #[test]
413 fn digest_is_whitespace_and_comment_invariant() {
414 let a = TIER0_DEV_TOML;
415 let b = r#"
417# comment block
418schema_version = 1
419
420[shell]
421shell_id = "shell.dev.example" # trailing comment
422display_name = "Dev Sandbox"
423
424[runtime]
425runtime_max = "0.15"
426runtime_current = "0.13"
427
428[audit]
429pii_cipher = "xchacha20-poly1305"
430dek_backend = "software-kek"
431kms_auto_promote = "manual"
432signature_class = "ed25519"
433compliance_tier = 0
434"#;
435 let (_, da) = ManifestLoader::load(a.as_bytes()).unwrap();
436 let (_, db) = ManifestLoader::load(b.as_bytes()).unwrap();
437 assert_eq!(da, db, "comments / whitespace must not affect digest");
438 }
439
440 #[test]
441 fn digest_differs_for_semantically_different_manifest() {
442 let (_, d0) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
443 let (_, d1) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
444 assert_ne!(d0, d1);
445 }
446
447 #[test]
448 fn print_tier0_dev_digest_for_pin() {
449 let (_, d) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
454 eprintln!(
455 "tier0_dev digest = {:?}",
456 d.iter().map(|b| format!("0x{b:02x}")).collect::<Vec<_>>()
457 );
458 }
459
460 #[test]
461 fn tier0_with_kms_backend_rejected() {
462 let bad = TIER0_DEV_TOML.replace("software-kek", "aws-kms");
463 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
464 assert!(matches!(
465 err,
466 ManifestError::TierBackendMismatch { tier: 0, .. }
467 ));
468 }
469
470 #[test]
471 fn tier1_with_software_kek_rejected() {
472 let bad = TIER1_PROD_TOML.replace("aws-kms", "software-kek");
473 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
474 assert!(matches!(
475 err,
476 ManifestError::TierBackendMismatch { tier: 1, .. }
477 ));
478 }
479
480 #[test]
481 fn software_kek_past_0_15_refused() {
482 let bad = TIER0_DEV_TOML
483 .replace("runtime_current = \"0.13\"", "runtime_current = \"0.16\"")
484 .replace("runtime_max = \"0.15\"", "runtime_max = \"0.20\"");
486 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
487 assert!(matches!(
488 err,
489 ManifestError::SoftwareKekProductionRefused { .. }
490 ));
491 }
492
493 #[test]
494 fn runtime_max_less_than_current_rejected() {
495 let bad = TIER0_DEV_TOML.replace("runtime_max = \"0.15\"", "runtime_max = \"0.5\"");
496 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
497 assert!(matches!(err, ManifestError::VersionMismatch));
498 }
499
500 #[test]
501 fn parse_error_on_malformed_toml() {
502 let err = ManifestLoader::load(b"this is not toml == invalid").unwrap_err();
503 assert!(matches!(err, ManifestError::ParseError(_)));
504 }
505
506 #[test]
507 fn unknown_top_level_field_rejected() {
508 let bad = format!("{}\nunknown_field = 42\n", TIER0_DEV_TOML);
509 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
510 assert!(matches!(err, ManifestError::ParseError(_)));
512 }
513
514 #[test]
515 fn bad_semver_rejected() {
516 let bad = TIER0_DEV_TOML.replace(
517 "runtime_current = \"0.13\"",
518 "runtime_current = \"not-a-version\"",
519 );
520 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
521 assert!(matches!(
522 err,
523 ManifestError::InvalidValue {
524 field: "runtime.runtime_current",
525 ..
526 }
527 ));
528 }
529
530 #[test]
531 fn manifest_rejects_unknown_signature_class() {
532 let bad = TIER0_DEV_TOML.replace(
533 "signature_class = \"ed25519\"",
534 "signature_class = \"dilithium\"",
535 );
536 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
537 assert!(matches!(
538 err,
539 ManifestError::InvalidValue {
540 field: "audit.signature_class",
541 ..
542 }
543 ));
544 }
545
546 #[test]
547 fn manifest_accepts_each_valid_signature_class() {
548 for class in ["none", "ed25519", "ml-dsa-65", "hybrid"] {
549 let good = TIER0_DEV_TOML.replace(
550 "signature_class = \"ed25519\"",
551 &format!("signature_class = \"{class}\""),
552 );
553 assert!(
554 ManifestLoader::load(good.as_bytes()).is_ok(),
555 "signature_class {class} must be accepted"
556 );
557 }
558 }
559
560 #[test]
561 fn shell_id_over_32_bytes_rejected() {
562 let long_id = "a".repeat(33);
563 let bad = TIER0_DEV_TOML.replace("shell.dev.example", &long_id);
564 let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
565 assert!(matches!(
566 err,
567 ManifestError::InvalidValue {
568 field: "shell.shell_id",
569 ..
570 }
571 ));
572 }
573
574 #[test]
575 fn emit_runtime_bootstrap_appends_event() {
576 let (_, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
577 let mut ctx = ActionContext::new(
578 [0x11u8; 32],
579 InstanceId::new(1).unwrap(),
580 Tick(1),
581 Principal::System,
582 CapabilityMask::SYSTEM,
583 );
584 emit_runtime_bootstrap(
585 &digest,
586 SemVer::new(0, 11, 0),
587 SemVer::new(0, 11, 0),
588 vec![TypeCode(0x0003_0001), TypeCode(0x0003_0002)],
589 Tick(1),
590 &mut ctx,
591 )
592 .unwrap();
593 let events = ctx.drain_events();
594 assert_eq!(events.len(), 1);
595 assert_eq!(events[0].type_code, 0x0003_0F01);
596 assert_eq!(events[0].tick, Tick(1));
597
598 let bootstrap: RuntimeBootstrap = postcard::from_bytes(&events[0].payload).unwrap();
600 assert_eq!(bootstrap.manifest_digest, digest);
601 assert_eq!(bootstrap.l0_semver, SemVer::new(0, 11, 0));
602 }
603
604 #[test]
605 fn frontend_defaults_when_omitted() {
606 let (snap, _) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
607 assert!(snap.frontend.tls_required);
609 assert!(snap.frontend.alpha_credential_rotation_required);
610 }
611
612 #[test]
613 fn parse_version_accepts_two_or_three_components() {
614 assert_eq!(parse_version("0.13"), Some((0, 13, 0)));
615 assert_eq!(parse_version("0.13.3"), Some((0, 13, 3)));
616 assert_eq!(parse_version(""), None);
617 assert_eq!(parse_version("0.13.3.4"), None); assert_eq!(parse_version("abc.def"), None);
619 }
620}
621
622#[cfg(test)]
631#[allow(clippy::unwrap_used, clippy::expect_used)]
632mod digest_invariant {
633 use super::tests::TIER0_DEV_TOML;
634 use super::ManifestLoader;
635
636 const TIER0_DEV_DIGEST_V0_11: [u8; 32] = [
640 0xa1, 0xbf, 0xe8, 0x2a, 0x57, 0xe1, 0xde, 0x55, 0x05, 0x7e, 0x47, 0x51, 0xd0, 0xeb, 0xed,
641 0x3d, 0x48, 0x82, 0xdb, 0xd5, 0x95, 0x2d, 0x83, 0xcd, 0x52, 0x10, 0x6c, 0x0f, 0xae, 0x3b,
642 0xab, 0x3c,
643 ];
644
645 #[test]
646 fn tier0_dev_digest_matches_v0_11_sentinel() {
647 let (_, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes())
648 .expect("TIER0_DEV_TOML must parse cleanly");
649 assert_eq!(
650 digest, TIER0_DEV_DIGEST_V0_11,
651 "manifest canonical_digest drifted from the pinned sentinel — check toml crate \
652 version, ManifestSnapshot field order, and DIGEST_DOMAIN. Update the sentinel + \
653 write a manifest schema micro-patch if the change is intentional."
654 );
655 }
656}