1use std::collections::HashSet;
4
5use crate::error::CellosError;
6use crate::ExecutionCellDocument;
7use url::Url;
8
9fn is_sha256_digest(value: &str) -> bool {
11 let Some(hex) = value.strip_prefix("sha256:") else {
12 return false;
13 };
14 hex.len() == 64
15 && hex
16 .chars()
17 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
18}
19
20pub(crate) fn is_portable_identifier(value: &str) -> bool {
21 let mut chars = value.chars();
22 let Some(first) = chars.next() else {
23 return false;
24 };
25 if !first.is_ascii_alphanumeric() {
26 return false;
27 }
28 if value.len() > 128 || value.contains("..") {
29 return false;
30 }
31 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
32}
33
34fn ensure_portable_identifier(value: &str, field: &str) -> Result<(), CellosError> {
35 if is_portable_identifier(value) {
36 Ok(())
37 } else {
38 Err(CellosError::InvalidSpec(format!(
39 "{field} must match [A-Za-z0-9][A-Za-z0-9._-]{{0,127}} and must not contain '..'"
40 )))
41 }
42}
43
44fn is_kubernetes_namespace(value: &str) -> bool {
45 if value.is_empty() || value.len() > 63 || value.contains("..") {
46 return false;
47 }
48
49 let mut chars = value.chars();
50 let Some(first) = chars.next() else {
51 return false;
52 };
53 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
54 return false;
55 }
56
57 let Some(last) = value.chars().last() else {
58 return false;
59 };
60 if !last.is_ascii_lowercase() && !last.is_ascii_digit() {
61 return false;
62 }
63
64 value
65 .chars()
66 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
67}
68
69fn ensure_kubernetes_namespace(value: &str, field: &str) -> Result<(), CellosError> {
70 if is_kubernetes_namespace(value) {
71 Ok(())
72 } else {
73 Err(CellosError::InvalidSpec(format!(
74 "{field} must match Kubernetes DNS label rules: lowercase alphanumeric plus '-', <=63 chars"
75 )))
76 }
77}
78
79fn is_dns_label(value: &str) -> bool {
82 if value.is_empty() || value.len() > 63 {
83 return false;
84 }
85 let bytes = value.as_bytes();
86 let first_ok = bytes
87 .first()
88 .copied()
89 .is_some_and(|b| b.is_ascii_alphanumeric());
90 let last_ok = bytes
91 .last()
92 .copied()
93 .is_some_and(|b| b.is_ascii_alphanumeric());
94 if !first_ok || !last_ok {
95 return false;
96 }
97 bytes
98 .iter()
99 .all(|b| b.is_ascii_alphanumeric() || *b == b'-')
100}
101
102pub(crate) fn is_fqdn_or_wildcard(value: &str) -> bool {
110 if value.is_empty() || value.len() > 253 {
111 return false;
112 }
113 if value
117 .split('.')
118 .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
119 && value.contains('.')
120 {
121 return false;
122 }
123
124 let labels: Vec<&str> = value.split('.').collect();
125 if labels.len() < 2 {
126 return false;
128 }
129
130 let (first, rest) = labels.split_first().expect("non-empty above");
132 if *first == "*" {
133 if rest.is_empty() {
134 return false;
135 }
136 return rest.iter().all(|label| is_dns_label(label));
137 }
138
139 labels.iter().all(|label| is_dns_label(label))
140}
141
142fn parse_http_base_url(value: &str, field: &str) -> Result<Url, CellosError> {
143 let parsed = Url::parse(value).map_err(|_| {
144 CellosError::InvalidSpec(format!(
145 "{field} must be an absolute http(s) base URL without query or fragment"
146 ))
147 })?;
148 let scheme = parsed.scheme();
149 if scheme != "http" && scheme != "https" {
150 return Err(CellosError::InvalidSpec(format!(
151 "{field} must use http or https"
152 )));
153 }
154 if parsed.host_str().is_none() || parsed.query().is_some() || parsed.fragment().is_some() {
155 return Err(CellosError::InvalidSpec(format!(
156 "{field} must be an absolute http(s) base URL without query or fragment"
157 )));
158 }
159 Ok(parsed)
160}
161
162pub fn check_policy_pack_version(
171 declared: Option<&str>,
172 allow_downgrade: bool,
173) -> Result<(), CellosError> {
174 crate::policy::check_policy_pack_version_compatibility(declared, allow_downgrade)
175}
176
177pub fn validate_tenant_id_for_subject_token(tenant_id: &str) -> Result<(), CellosError> {
190 if tenant_id.is_empty() {
191 return Err(CellosError::InvalidSpec(
192 "spec.correlation.tenantId must be non-empty when present".into(),
193 ));
194 }
195 for ch in tenant_id.chars() {
196 let bad = ch == '.' || ch == '*' || ch == '>' || ch.is_whitespace();
197 if bad {
198 return Err(CellosError::InvalidSpec(format!(
199 "spec.correlation.tenantId contains NATS-reserved char: {ch:?} (in {tenant_id:?})"
200 )));
201 }
202 }
203 Ok(())
204}
205
206pub fn validate_execution_cell_document(doc: &ExecutionCellDocument) -> Result<(), CellosError> {
208 if doc.api_version != "cellos.io/v1" {
209 return Err(CellosError::InvalidSpec(format!(
210 "unsupported apiVersion: {}",
211 doc.api_version
212 )));
213 }
214 if doc.kind != "ExecutionCell" {
215 return Err(CellosError::InvalidSpec(format!(
216 "unsupported kind: {}",
217 doc.kind
218 )));
219 }
220 ensure_portable_identifier(&doc.spec.id, "spec.id")?;
221 if let Some(correlation) = &doc.spec.correlation {
222 if let Some(tenant_id) = correlation.tenant_id.as_deref() {
223 validate_tenant_id_for_subject_token(tenant_id)?;
224 }
225 }
226 let authority_secret_refs = doc
227 .spec
228 .authority
229 .secret_refs
230 .as_ref()
231 .map(|refs| refs.iter().map(String::as_str).collect::<HashSet<_>>())
232 .unwrap_or_default();
233 for secret_ref in &authority_secret_refs {
234 ensure_portable_identifier(secret_ref, "authority.secretRefs[]")?;
235 }
236 if let Some(identity) = &doc.spec.identity {
237 ensure_portable_identifier(&identity.secret_ref, "spec.identity.secretRef")?;
238 if let Some(ttl_seconds) = identity.ttl_seconds {
239 if ttl_seconds > doc.spec.lifetime.ttl_seconds {
240 return Err(CellosError::InvalidSpec(
241 "spec.identity.ttlSeconds must be <= spec.lifetime.ttlSeconds".into(),
242 ));
243 }
244 }
245 if !authority_secret_refs.contains(identity.secret_ref.as_str()) {
246 return Err(CellosError::InvalidSpec(format!(
247 "spec.identity.secretRef {:?} must also appear in authority.secretRefs",
248 identity.secret_ref
249 )));
250 }
251 }
252 if let Some(env) = &doc.spec.environment {
253 if env.image_reference.is_empty() {
254 return Err(CellosError::InvalidSpec(
255 "spec.environment.imageReference must be non-empty".into(),
256 ));
257 }
258 if let Some(digest) = &env.image_digest {
259 if !is_sha256_digest(digest) {
260 return Err(CellosError::InvalidSpec(
261 "spec.environment.imageDigest must be a sha256:<hex64> digest when present"
262 .into(),
263 ));
264 }
265 }
266 if let Some(template_id) = &env.template_id {
267 ensure_portable_identifier(template_id, "spec.environment.templateId")?;
268 }
269 }
270 if let Some(placement) = &doc.spec.placement {
271 if placement.pool_id.is_none()
272 && placement.kubernetes_namespace.is_none()
273 && placement.queue_name.is_none()
274 {
275 return Err(CellosError::InvalidSpec(
276 "spec.placement must set at least one placement hint".into(),
277 ));
278 }
279 if let Some(pool_id) = &placement.pool_id {
280 ensure_portable_identifier(pool_id, "spec.placement.poolId")?;
281 }
282 if let Some(namespace) = &placement.kubernetes_namespace {
283 ensure_kubernetes_namespace(namespace, "spec.placement.kubernetesNamespace")?;
284 }
285 if let Some(queue_name) = &placement.queue_name {
286 ensure_portable_identifier(queue_name, "spec.placement.queueName")?;
287 }
288 }
289 if let Some(ingress) = &doc.spec.ingress {
290 if let Some(git) = &ingress.git {
291 if let Some(secret_ref) = &git.secret_ref {
292 ensure_portable_identifier(secret_ref, "spec.ingress.git.secretRef")?;
293 }
294 }
295 if let Some(image) = &ingress.oci_image {
296 if let Some(secret_ref) = &image.secret_ref {
297 ensure_portable_identifier(secret_ref, "spec.ingress.ociImage.secretRef")?;
298 }
299 }
300 }
301 if let Some(run) = &doc.spec.run {
302 if run.argv.is_empty() || run.argv.iter().any(|s| s.is_empty()) {
306 return Err(CellosError::InvalidSpec(
307 "spec.run.argv must be non-empty with no empty strings".into(),
308 ));
309 }
310 if let Some(idx) = run.argv.iter().position(|s| s.as_bytes().contains(&0)) {
313 return Err(CellosError::InvalidSpec(format!(
314 "spec.run.argv[{}] contains NUL byte (would be silently truncated by execve)",
315 idx
316 )));
317 }
318 check_argv_size_within_kernel_cmdline_limit(&run.argv)?;
319 if let Some(timeout_ms) = run.timeout_ms {
320 let ttl_ms = doc.spec.lifetime.ttl_seconds.saturating_mul(1000);
321 if timeout_ms > ttl_ms {
322 return Err(CellosError::InvalidSpec(
323 "spec.run.timeoutMs must be <= spec.lifetime.ttlSeconds * 1000".into(),
324 ));
325 }
326 }
327 if let Some(limits) = &run.limits {
328 if limits.memory_max_bytes == Some(0) {
329 return Err(CellosError::InvalidSpec(
330 "spec.run.limits.memoryMaxBytes must be > 0".into(),
331 ));
332 }
333 if let Some(cpu_max) = &limits.cpu_max {
334 if cpu_max.quota_micros == 0 {
335 return Err(CellosError::InvalidSpec(
336 "spec.run.limits.cpuMax.quotaMicros must be > 0".into(),
337 ));
338 }
339 if cpu_max.period_micros == Some(0) {
340 return Err(CellosError::InvalidSpec(
341 "spec.run.limits.cpuMax.periodMicros must be > 0".into(),
342 ));
343 }
344 }
345 }
346 }
347 if let Some(rules) = &doc.spec.authority.egress_rules {
348 for r in rules {
349 if r.host.is_empty() {
350 return Err(CellosError::InvalidSpec(
351 "authority.egressRules[].host must be non-empty".into(),
352 ));
353 }
354 }
355 }
356 if let Some(dns_authority) = &doc.spec.authority.dns_authority {
357 validate_dns_authority(dns_authority)?;
358 }
359 if let Some(cdn_authority) = &doc.spec.authority.cdn_authority {
360 validate_cdn_authority(cdn_authority)?;
361 }
362 if let Some(ref token) = doc.spec.authority.authority_derivation {
366 verify_authority_derivation_structural(&doc.spec, token)?;
367 }
368 if let Some(export) = &doc.spec.export {
369 let targets = export.targets.as_deref().unwrap_or(&[]);
370 let mut target_names = HashSet::new();
371 for target in targets {
372 ensure_portable_identifier(target.name(), "spec.export.targets[].name")?;
373 if !target_names.insert(target.name().to_string()) {
374 return Err(CellosError::InvalidSpec(format!(
375 "duplicate export target name {:?}",
376 target.name()
377 )));
378 }
379 if let Some(secret_ref) = target.secret_ref() {
380 if !authority_secret_refs.contains(secret_ref) {
381 return Err(CellosError::InvalidSpec(format!(
382 "export target {:?} secretRef {:?} must appear in authority.secretRefs",
383 target.name(),
384 secret_ref
385 )));
386 }
387 }
388 if let crate::ExportTarget::Http(target) = target {
389 let parsed =
390 parse_http_base_url(&target.base_url, "spec.export.targets[].baseUrl")?;
391 if let Some(rules) = &doc.spec.authority.egress_rules {
392 if !rules.is_empty() {
393 let host = parsed.host_str().expect("checked above");
394 let port = parsed
395 .port_or_known_default()
396 .expect("http/https always has a known default port");
397 let allowed = rules
398 .iter()
399 .any(|rule| rule.port == port && rule.host.eq_ignore_ascii_case(host));
400 if !allowed {
401 return Err(CellosError::InvalidSpec(format!(
402 "http export target {:?} host {}:{} must appear in authority.egressRules",
403 target.name, host, port
404 )));
405 }
406 }
407 }
408 }
409 }
410 if let Some(artifacts) = &export.artifacts {
411 for artifact in artifacts {
412 ensure_portable_identifier(&artifact.name, "spec.export.artifacts[].name")?;
413 match artifact.target.as_deref() {
414 Some(target_name) => {
415 ensure_portable_identifier(target_name, "spec.export.artifacts[].target")?;
416 if !target_names.contains(target_name) {
417 return Err(CellosError::InvalidSpec(format!(
418 "export artifact {:?} references unknown target {:?}",
419 artifact.name, target_name
420 )));
421 }
422 }
423 None if targets.len() > 1 => {
424 return Err(CellosError::InvalidSpec(format!(
425 "export artifact {:?} must set target when multiple export targets exist",
426 artifact.name
427 )));
428 }
429 None => {}
430 }
431 }
432 }
433 }
434 if let Some(telemetry) = &doc.spec.telemetry {
435 validate_telemetry_block(telemetry, doc.spec.authority.egress_rules.as_deref())?;
436 }
437 Ok(())
438}
439
440fn validate_telemetry_block(
459 telemetry: &crate::TelemetrySpec,
460 egress_rules: Option<&[crate::EgressRule]>,
461) -> Result<(), CellosError> {
462 if telemetry.events.is_empty() {
463 return Err(CellosError::InvalidSpec(
464 "spec.telemetry.events must be non-empty".into(),
465 ));
466 }
467 match telemetry.channel {
468 crate::TelemetryChannel::VsockCbor => {}
469 }
470 if !is_semver_shape(&telemetry.agent_version) {
471 return Err(CellosError::InvalidSpec(
472 "spec.telemetry.agentVersion must match MAJOR.MINOR.PATCH semver shape (optional -prerelease suffix)".into()
473 ));
474 }
475 let has_egress = egress_rules.map(|r| !r.is_empty()).unwrap_or(false);
476 for event in &telemetry.events {
477 let trimmed = event.trim();
478 if trimmed.is_empty() {
479 return Err(CellosError::InvalidSpec(
480 "spec.telemetry.events[] entries must be non-empty".into(),
481 ));
482 }
483 if trimmed.to_ascii_lowercase().starts_with("net.") && !has_egress {
484 return Err(CellosError::InvalidSpec(format!(
485 "telemetry_without_egress: spec.telemetry.events[] entry {trimmed:?} requires \
486 at least one authority.egressRules entry — net.* telemetry without declared \
487 egress is an unbacked observation claim"
488 )));
489 }
490 }
491 Ok(())
492}
493
494fn is_semver_shape(value: &str) -> bool {
500 let core = match value.split_once('-') {
501 Some((core, pre)) => {
502 if pre.is_empty()
503 || !pre
504 .chars()
505 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
506 {
507 return false;
508 }
509 core
510 }
511 None => value,
512 };
513 let parts: Vec<&str> = core.split('.').collect();
514 if parts.len() != 3 {
515 return false;
516 }
517 parts.iter().all(|p| {
518 !p.is_empty()
519 && p.chars().all(|c| c.is_ascii_digit())
520 && !(p.len() > 1 && p.starts_with('0'))
521 })
522}
523
524fn verify_authority_derivation_structural(
530 spec: &crate::ExecutionCellSpec,
531 token: &crate::AuthorityDerivationToken,
532) -> Result<(), crate::error::CellosError> {
533 let spec_capability = crate::AuthorityCapability {
534 egress_rules: spec.authority.egress_rules.clone().unwrap_or_default(),
535 secret_refs: spec.authority.secret_refs.clone().unwrap_or_default(),
536 };
537 if !spec_capability.is_superset_of(&token.leaf_capability) {
538 return Err(crate::error::CellosError::InvalidSpec(
539 "spec.authorityDerivation.leafCapability exceeds spec.authority — child authority must be ⊆ declared authority".into()
540 ));
541 }
542 Ok(())
543}
544
545fn validate_dns_authority(dns: &crate::DnsAuthority) -> Result<(), CellosError> {
551 let mut resolver_ids: HashSet<&str> = HashSet::new();
552 for resolver in &dns.resolvers {
553 ensure_portable_identifier(
554 &resolver.resolver_id,
555 "authority.dnsAuthority.resolvers[].resolverId",
556 )?;
557 if !resolver_ids.insert(resolver.resolver_id.as_str()) {
558 return Err(CellosError::InvalidSpec(format!(
559 "authority.dnsAuthority.resolvers[].resolverId duplicates value {:?}",
560 resolver.resolver_id
561 )));
562 }
563 if resolver.endpoint.is_empty() {
564 return Err(CellosError::InvalidSpec(
565 "authority.dnsAuthority.resolvers[].endpoint must be non-empty".into(),
566 ));
567 }
568 if let Some(kid) = &resolver.trust_kid {
569 ensure_portable_identifier(kid, "authority.dnsAuthority.resolvers[].trustKid")?;
570 }
571 }
572
573 for hostname in &dns.hostname_allowlist {
574 if !is_fqdn_or_wildcard(hostname) {
575 return Err(CellosError::InvalidSpec(format!(
576 "authority.dnsAuthority.hostnameAllowlist entry {hostname:?} is not a valid FQDN \
577 (single leading '*.' wildcard allowed; IPs are rejected)"
578 )));
579 }
580 }
581
582 if let Some(refresh) = &dns.refresh_policy {
583 if let (Some(min_ttl), Some(max_stale)) =
584 (refresh.min_ttl_seconds, refresh.max_stale_seconds)
585 {
586 if min_ttl > max_stale {
587 return Err(CellosError::InvalidSpec(format!(
588 "authority.dnsAuthority.refreshPolicy.minTtlSeconds ({min_ttl}) must be <= maxStaleSeconds ({max_stale})"
589 )));
590 }
591 }
592 }
593
594 Ok(())
595}
596
597fn validate_cdn_authority(cdn: &crate::CdnAuthority) -> Result<(), CellosError> {
599 let mut provider_ids: HashSet<&str> = HashSet::new();
600 for provider in &cdn.providers {
601 ensure_portable_identifier(
602 &provider.provider_id,
603 "authority.cdnAuthority.providers[].providerId",
604 )?;
605 if !provider_ids.insert(provider.provider_id.as_str()) {
606 return Err(CellosError::InvalidSpec(format!(
607 "authority.cdnAuthority.providers[].providerId duplicates value {:?}",
608 provider.provider_id
609 )));
610 }
611 if !is_fqdn_or_wildcard(&provider.hostname_pattern) {
612 return Err(CellosError::InvalidSpec(format!(
613 "authority.cdnAuthority.providers[].hostnamePattern {:?} is not a valid FQDN \
614 (single leading '*.' wildcard allowed; IPs are rejected)",
615 provider.hostname_pattern
616 )));
617 }
618 }
619 Ok(())
620}
621
622const MAX_ARGV_ENCODED_BYTES: usize = 3072;
636
637pub(crate) fn check_argv_size_within_kernel_cmdline_limit(
648 argv: &[String],
649) -> Result<(), CellosError> {
650 use base64::engine::general_purpose::STANDARD;
651 use base64::Engine as _;
652
653 let encoded_len = match serde_json::to_string(argv) {
658 Ok(json) => STANDARD.encode(json.as_bytes()).len(),
659 Err(_) => {
660 let json_upper = 2 + argv
664 .iter()
665 .map(|s| s.len().saturating_mul(2).saturating_add(3))
666 .sum::<usize>();
667 json_upper.div_ceil(3).saturating_mul(4)
668 }
669 };
670
671 if encoded_len > MAX_ARGV_ENCODED_BYTES {
672 return Err(CellosError::ArgvTooLarge {
673 encoded_bytes: encoded_len,
674 limit_bytes: MAX_ARGV_ENCODED_BYTES,
675 });
676 }
677 Ok(())
678}
679
680pub fn authority_derivation_signing_payload(
691 token: &crate::AuthorityDerivationToken,
692) -> Result<Vec<u8>, crate::error::CellosError> {
693 let value = serde_json::json!({
694 "roleRoot": token.role_root.to_string(),
695 "leafCapability": &token.leaf_capability,
696 "parentRunId": token.parent_run_id,
697 });
698 serde_json::to_vec(&value).map_err(|e| {
699 crate::error::CellosError::InvalidSpec(format!(
700 "authority derivation signing payload encode failed: {e}"
701 ))
702 })
703}
704
705pub fn verify_authority_derivation(
719 spec: &crate::ExecutionCellSpec,
720 token: &crate::AuthorityDerivationToken,
721 role_keys: &std::collections::HashMap<String, String>,
722) -> Result<(), crate::error::CellosError> {
723 use base64::engine::general_purpose::STANDARD;
724 use base64::Engine as _;
725 use ed25519_dalek::{Signature, VerifyingKey};
726
727 verify_authority_derivation_structural(spec, token)?;
729
730 let role_id = token.role_root.to_string();
732 let verifying_key_b64 = role_keys.get(&role_id).ok_or_else(|| {
733 crate::error::CellosError::InvalidSpec(format!("unknown role: {role_id}"))
734 })?;
735
736 let verifying_key_bytes = STANDARD.decode(verifying_key_b64.as_bytes()).map_err(|e| {
738 crate::error::CellosError::InvalidSpec(format!(
739 "authority derivation verifying key for role {role_id} is not valid base64: {e}"
740 ))
741 })?;
742 let verifying_key_array: [u8; 32] =
743 verifying_key_bytes.as_slice().try_into().map_err(|_| {
744 crate::error::CellosError::InvalidSpec(format!(
745 "authority derivation verifying key for role {role_id} must be 32 bytes (got {})",
746 verifying_key_bytes.len()
747 ))
748 })?;
749 let verifying_key = VerifyingKey::from_bytes(&verifying_key_array).map_err(|e| {
750 crate::error::CellosError::InvalidSpec(format!(
751 "authority derivation verifying key for role {role_id} is not a valid ED25519 point: {e}"
752 ))
753 })?;
754
755 let sig_bytes = STANDARD
757 .decode(token.grantor_signature.bytes.as_bytes())
758 .map_err(|e| {
759 crate::error::CellosError::InvalidSpec(format!(
760 "authority derivation grantor signature is not valid base64: {e}"
761 ))
762 })?;
763 let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
764 crate::error::CellosError::InvalidSpec(format!(
765 "authority derivation grantor signature must be 64 bytes (got {})",
766 sig_bytes.len()
767 ))
768 })?;
769 let signature = Signature::from_bytes(&sig_array);
770
771 let payload = authority_derivation_signing_payload(token)?;
773 verifying_key
774 .verify_strict(&payload, &signature)
775 .map_err(|_| {
776 crate::error::CellosError::InvalidSpec("authority derivation signature invalid".into())
777 })?;
778
779 tracing::debug!(
780 role_root = %token.role_root,
781 "authority derivation token verified (structural + signature)"
782 );
783
784 Ok(())
785}
786
787pub fn enforce_derivation_scope_policy(
811 token: &crate::AuthorityDerivationToken,
812 allow_universal: bool,
813) -> Result<(), crate::error::CellosError> {
814 if token.parent_run_id.is_some() {
815 return Ok(());
816 }
817
818 if allow_universal {
819 tracing::warn!(
820 target: "cellos.supervisor.authority",
821 role_root = %token.role_root,
822 "authority derivation token has parentRunId: null — universal token accepted in permissive mode (unset CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS or set it to 1/true/yes/on to restore the strict-by-default posture)"
823 );
824 return Ok(());
825 }
826
827 Err(crate::error::CellosError::InvalidSpec(
828 "authority derivation token has parentRunId: null — universal tokens are rejected by the strict-by-default policy (set CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS to 0/false/no/off to opt into permissive mode, which emits an audit warning event instead)".into(),
829 ))
830}
831
832pub fn verify_signed_trust_keyset_envelope(
872 envelope: &crate::types::SignedTrustKeysetEnvelope,
873 verifying_keys: &std::collections::HashMap<String, ed25519_dalek::VerifyingKey>,
874 now: std::time::SystemTime,
875) -> Result<Vec<u8>, crate::error::CellosError> {
876 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
877 use base64::Engine as _;
878 use ed25519_dalek::Signature;
879 use std::collections::HashSet;
880
881 if envelope.payload_type != "application/vnd.cellos.trust-keyset-v1+json" {
883 return Err(crate::error::CellosError::InvalidSpec(format!(
884 "signed trust keyset envelope payloadType must be application/vnd.cellos.trust-keyset-v1+json, got '{}'",
885 envelope.payload_type
886 )));
887 }
888
889 let payload_b64 = envelope.payload.trim_end_matches('=');
892 let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|e| {
893 crate::error::CellosError::InvalidSpec(format!(
894 "signed trust keyset envelope payload is not valid base64url: {e}"
895 ))
896 })?;
897 let computed_digest = sha256_hex_prefixed(&payload_bytes);
898 if computed_digest != envelope.payload_digest {
899 return Err(crate::error::CellosError::InvalidSpec(format!(
900 "signed trust keyset envelope payload digest mismatch: declared={}, computed={}",
901 envelope.payload_digest, computed_digest
902 )));
903 }
904
905 if envelope.signatures.is_empty() {
906 return Err(crate::error::CellosError::InvalidSpec(
908 "signed trust keyset envelope has no signatures".into(),
909 ));
910 }
911
912 let required = envelope.required_signer_count.unwrap_or(1).max(1);
916
917 let mut verified_signers: HashSet<&str> = HashSet::new();
921 for sig_entry in &envelope.signatures {
922 if sig_entry.algorithm != "ed25519" {
923 continue;
926 }
927 if verified_signers.contains(sig_entry.signer_kid.as_str()) {
930 continue;
931 }
932 let Some(verifying_key) = verifying_keys.get(&sig_entry.signer_kid) else {
933 continue;
935 };
936
937 if !signature_window_contains(
939 now,
940 sig_entry.not_before.as_deref(),
941 sig_entry.not_after.as_deref(),
942 )? {
943 continue;
944 }
945
946 let sig_b64 = sig_entry.signature.trim_end_matches('=');
948 let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(sig_b64) else {
949 continue;
950 };
951 let Ok(sig_array) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
952 continue;
953 };
954 let signature = Signature::from_bytes(&sig_array);
955
956 if verifying_key
957 .verify_strict(&payload_bytes, &signature)
958 .is_ok()
959 {
960 verified_signers.insert(sig_entry.signer_kid.as_str());
961 }
962 }
963
964 if verified_signers.len() >= required as usize {
965 return Ok(payload_bytes);
966 }
967
968 if required == 1 {
974 Err(crate::error::CellosError::InvalidSpec(
975 "signed trust keyset envelope: no signature verified".into(),
976 ))
977 } else {
978 Err(crate::error::CellosError::InvalidSpec(format!(
979 "signed trust keyset envelope: only {} distinct signers verified, need {}",
980 verified_signers.len(),
981 required
982 )))
983 }
984}
985
986pub fn verify_signed_trust_keyset_chain(
1015 chain: &[crate::types::SignedTrustKeysetEnvelope],
1016 verifying_keys: &std::collections::HashMap<String, ed25519_dalek::VerifyingKey>,
1017 now: std::time::SystemTime,
1018) -> Result<Vec<u8>, crate::error::CellosError> {
1019 if chain.is_empty() {
1020 return Err(crate::error::CellosError::InvalidSpec(
1021 "signed trust keyset chain: empty chain".into(),
1022 ));
1023 }
1024
1025 let mut prev_payload_bytes: Option<Vec<u8>> = None;
1026 for (idx, envelope) in chain.iter().enumerate() {
1027 let payload_bytes = verify_signed_trust_keyset_envelope(envelope, verifying_keys, now)
1028 .map_err(|e| {
1029 crate::error::CellosError::InvalidSpec(format!(
1030 "signed trust keyset chain: envelope at index {idx} failed verification: {e}"
1031 ))
1032 })?;
1033
1034 if let Some(prev_bytes) = prev_payload_bytes.as_deref() {
1035 let expected = sha256_hex_prefixed(prev_bytes);
1037 match envelope.replaces_envelope_digest.as_deref() {
1038 Some(actual) if actual == expected => {}
1039 Some(actual) => {
1040 return Err(crate::error::CellosError::InvalidSpec(format!(
1041 "signed trust keyset chain: envelope at index {idx} replacesEnvelopeDigest mismatch: declared={actual}, expected={expected}"
1042 )));
1043 }
1044 None => {
1045 return Err(crate::error::CellosError::InvalidSpec(format!(
1046 "signed trust keyset chain: envelope at index {idx} missing replacesEnvelopeDigest (only the genesis envelope at index 0 may omit it)"
1047 )));
1048 }
1049 }
1050 }
1051 prev_payload_bytes = Some(payload_bytes);
1055 }
1056
1057 Ok(prev_payload_bytes.expect("chain non-empty checked above"))
1060}
1061
1062fn sha256_hex_prefixed(bytes: &[u8]) -> String {
1065 use sha2::{Digest, Sha256};
1066 use std::fmt::Write as _;
1067 let out = Sha256::new().chain_update(bytes).finalize();
1068 let mut hex = String::with_capacity(7 + 64);
1069 hex.push_str("sha256:");
1070 for b in out.iter() {
1071 let _ = write!(hex, "{b:02x}");
1072 }
1073 hex
1074}
1075
1076fn signature_window_contains(
1080 now: std::time::SystemTime,
1081 not_before: Option<&str>,
1082 not_after: Option<&str>,
1083) -> Result<bool, crate::error::CellosError> {
1084 use chrono::{DateTime, Utc};
1085 let now_chrono: DateTime<Utc> = now.into();
1086 if let Some(nb) = not_before {
1087 let parsed = DateTime::parse_from_rfc3339(nb).map_err(|e| {
1088 crate::error::CellosError::InvalidSpec(format!(
1089 "signed trust keyset envelope notBefore '{nb}' is not RFC3339: {e}"
1090 ))
1091 })?;
1092 if now_chrono < parsed.with_timezone(&Utc) {
1093 return Ok(false);
1094 }
1095 }
1096 if let Some(na) = not_after {
1097 let parsed = DateTime::parse_from_rfc3339(na).map_err(|e| {
1098 crate::error::CellosError::InvalidSpec(format!(
1099 "signed trust keyset envelope notAfter '{na}' is not RFC3339: {e}"
1100 ))
1101 })?;
1102 if now_chrono > parsed.with_timezone(&Utc) {
1103 return Ok(false);
1104 }
1105 }
1106 Ok(true)
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111 use super::*;
1112 use crate::{
1113 AuthorityBundle, EnvironmentSpec, ExecutionCellSpec, ExportArtifact, ExportChannels,
1114 ExportTarget, HttpExportTarget, Lifetime, RunCpuMax, RunLimits, RunSpec, S3ExportTarget,
1115 SecretDeliveryMode, WorkloadIdentity, WorkloadIdentityKind,
1116 };
1117
1118 #[test]
1119 fn rejects_empty_argv_token() {
1120 let doc = ExecutionCellDocument {
1121 api_version: "cellos.io/v1".into(),
1122 kind: "ExecutionCell".into(),
1123 spec: ExecutionCellSpec {
1124 id: "x".into(),
1125 correlation: None,
1126 ingress: None,
1127 environment: None,
1128 placement: None,
1129 policy: None,
1130 identity: None,
1131 run: Some(RunSpec {
1132 argv: vec!["sh".into(), "".into()],
1133 working_directory: None,
1134 timeout_ms: None,
1135 limits: None,
1136 secret_delivery: SecretDeliveryMode::Env,
1137 }),
1138 authority: AuthorityBundle::default(),
1139 lifetime: Lifetime { ttl_seconds: 1 },
1140 export: None,
1141 telemetry: None,
1142 },
1143 };
1144 assert!(validate_execution_cell_document(&doc).is_err());
1145 }
1146
1147 #[test]
1150 fn admits_argv_under_kernel_cmdline_limit() {
1151 let payload = "a".repeat(1024);
1153 let doc = ExecutionCellDocument {
1154 api_version: "cellos.io/v1".into(),
1155 kind: "ExecutionCell".into(),
1156 spec: ExecutionCellSpec {
1157 id: "argv-fits".into(),
1158 correlation: None,
1159 ingress: None,
1160 environment: None,
1161 placement: None,
1162 policy: None,
1163 identity: None,
1164 run: Some(RunSpec {
1165 argv: vec!["sh".into(), payload],
1166 working_directory: None,
1167 timeout_ms: None,
1168 limits: None,
1169 secret_delivery: SecretDeliveryMode::Env,
1170 }),
1171 authority: AuthorityBundle::default(),
1172 lifetime: Lifetime { ttl_seconds: 1 },
1173 export: None,
1174 telemetry: None,
1175 },
1176 };
1177 validate_execution_cell_document(&doc).expect("1 KiB argv must pass admission");
1178 }
1179
1180 #[test]
1185 fn rejects_argv_exceeding_kernel_cmdline_limit() {
1186 let payload = "a".repeat(5 * 1024);
1188 let doc = ExecutionCellDocument {
1189 api_version: "cellos.io/v1".into(),
1190 kind: "ExecutionCell".into(),
1191 spec: ExecutionCellSpec {
1192 id: "argv-too-big".into(),
1193 correlation: None,
1194 ingress: None,
1195 environment: None,
1196 placement: None,
1197 policy: None,
1198 identity: None,
1199 run: Some(RunSpec {
1200 argv: vec!["sh".into(), payload],
1201 working_directory: None,
1202 timeout_ms: None,
1203 limits: None,
1204 secret_delivery: SecretDeliveryMode::Env,
1205 }),
1206 authority: AuthorityBundle::default(),
1207 lifetime: Lifetime { ttl_seconds: 1 },
1208 export: None,
1209 telemetry: None,
1210 },
1211 };
1212 let err = validate_execution_cell_document(&doc).expect_err("5 KiB argv must reject");
1213 match err {
1214 CellosError::ArgvTooLarge {
1215 encoded_bytes,
1216 limit_bytes,
1217 } => {
1218 assert!(
1219 encoded_bytes > limit_bytes,
1220 "encoded_bytes ({encoded_bytes}) must exceed limit_bytes ({limit_bytes})"
1221 );
1222 assert_eq!(limit_bytes, 3072, "FC-17 budget is 3 KiB");
1223 }
1224 other => panic!("expected CellosError::ArgvTooLarge, got: {other:?}"),
1225 }
1226 }
1227
1228 #[test]
1229 fn rejects_identity_ttl_longer_than_cell_ttl() {
1230 let doc = ExecutionCellDocument {
1231 api_version: "cellos.io/v1".into(),
1232 kind: "ExecutionCell".into(),
1233 spec: ExecutionCellSpec {
1234 id: "x".into(),
1235 correlation: None,
1236 ingress: None,
1237 environment: None,
1238 placement: None,
1239 policy: None,
1240 identity: Some(WorkloadIdentity {
1241 kind: WorkloadIdentityKind::FederatedOidc,
1242 provider: "github-actions".into(),
1243 audience: "sts.amazonaws.com".into(),
1244 subject: None,
1245 ttl_seconds: Some(120),
1246 secret_ref: "AWS_WEB_IDENTITY".into(),
1247 }),
1248 run: None,
1249 authority: AuthorityBundle {
1250 filesystem: None,
1251 network: None,
1252 egress_rules: None,
1253 secret_refs: Some(vec!["AWS_WEB_IDENTITY".into()]),
1254 authority_derivation: None,
1255 dns_authority: None,
1256 cdn_authority: None,
1257 },
1258 lifetime: Lifetime { ttl_seconds: 60 },
1259 export: None,
1260 telemetry: None,
1261 },
1262 };
1263 assert!(validate_execution_cell_document(&doc).is_err());
1264 }
1265
1266 #[test]
1267 fn rejects_unknown_export_target_reference() {
1268 let doc = ExecutionCellDocument {
1269 api_version: "cellos.io/v1".into(),
1270 kind: "ExecutionCell".into(),
1271 spec: ExecutionCellSpec {
1272 id: "x".into(),
1273 correlation: None,
1274 ingress: None,
1275 environment: None,
1276 placement: None,
1277 policy: None,
1278 identity: None,
1279 run: None,
1280 authority: AuthorityBundle {
1281 filesystem: None,
1282 network: None,
1283 egress_rules: None,
1284 secret_refs: Some(vec!["AWS_WEB_IDENTITY".into()]),
1285 authority_derivation: None,
1286 dns_authority: None,
1287 cdn_authority: None,
1288 },
1289 lifetime: Lifetime { ttl_seconds: 60 },
1290 export: Some(ExportChannels {
1291 artifacts: Some(vec![ExportArtifact {
1292 name: "junit".into(),
1293 path: "/tmp/junit.xml".into(),
1294 target: Some("missing".into()),
1295 content_type: None,
1296 }]),
1297 targets: Some(vec![ExportTarget::S3(S3ExportTarget {
1298 name: "artifacts".into(),
1299 bucket: "cellos-artifacts".into(),
1300 key_prefix: None,
1301 region: None,
1302 secret_ref: Some("AWS_WEB_IDENTITY".into()),
1303 })]),
1304 }),
1305 telemetry: None,
1306 },
1307 };
1308 assert!(validate_execution_cell_document(&doc).is_err());
1309 }
1310
1311 #[test]
1312 fn rejects_spec_id_with_path_traversal() {
1313 let doc = ExecutionCellDocument {
1314 api_version: "cellos.io/v1".into(),
1315 kind: "ExecutionCell".into(),
1316 spec: ExecutionCellSpec {
1317 id: "../escape".into(),
1318 correlation: None,
1319 ingress: None,
1320 environment: None,
1321 placement: None,
1322 policy: None,
1323 identity: None,
1324 run: None,
1325 authority: AuthorityBundle::default(),
1326 lifetime: Lifetime { ttl_seconds: 60 },
1327 export: None,
1328 telemetry: None,
1329 },
1330 };
1331 assert!(validate_execution_cell_document(&doc).is_err());
1332 }
1333
1334 #[test]
1335 fn rejects_export_artifact_name_with_dotdot() {
1336 let doc = ExecutionCellDocument {
1337 api_version: "cellos.io/v1".into(),
1338 kind: "ExecutionCell".into(),
1339 spec: ExecutionCellSpec {
1340 id: "safe-cell".into(),
1341 correlation: None,
1342 ingress: None,
1343 environment: None,
1344 placement: None,
1345 policy: None,
1346 identity: None,
1347 run: None,
1348 authority: AuthorityBundle::default(),
1349 lifetime: Lifetime { ttl_seconds: 60 },
1350 export: Some(ExportChannels {
1351 artifacts: Some(vec![ExportArtifact {
1352 name: "bad..name".into(),
1353 path: "/tmp/junit.xml".into(),
1354 target: None,
1355 content_type: None,
1356 }]),
1357 targets: None,
1358 }),
1359 telemetry: None,
1360 },
1361 };
1362 assert!(validate_execution_cell_document(&doc).is_err());
1363 }
1364
1365 #[test]
1366 fn rejects_secret_ref_with_separator() {
1367 let doc = ExecutionCellDocument {
1368 api_version: "cellos.io/v1".into(),
1369 kind: "ExecutionCell".into(),
1370 spec: ExecutionCellSpec {
1371 id: "safe-cell".into(),
1372 correlation: None,
1373 ingress: None,
1374 environment: None,
1375 placement: None,
1376 policy: None,
1377 identity: None,
1378 run: None,
1379 authority: AuthorityBundle {
1380 filesystem: None,
1381 network: None,
1382 egress_rules: None,
1383 secret_refs: Some(vec!["bad/ref".into()]),
1384 authority_derivation: None,
1385 dns_authority: None,
1386 cdn_authority: None,
1387 },
1388 lifetime: Lifetime { ttl_seconds: 60 },
1389 export: None,
1390 telemetry: None,
1391 },
1392 };
1393 assert!(validate_execution_cell_document(&doc).is_err());
1394 }
1395
1396 #[test]
1397 fn rejects_http_export_target_with_non_http_base_url() {
1398 let doc = ExecutionCellDocument {
1399 api_version: "cellos.io/v1".into(),
1400 kind: "ExecutionCell".into(),
1401 spec: ExecutionCellSpec {
1402 id: "safe-cell".into(),
1403 correlation: None,
1404 ingress: None,
1405 environment: None,
1406 placement: None,
1407 policy: None,
1408 identity: None,
1409 run: None,
1410 authority: AuthorityBundle {
1411 filesystem: None,
1412 network: None,
1413 egress_rules: None,
1414 secret_refs: Some(vec!["ARTIFACT_API_TOKEN".into()]),
1415 authority_derivation: None,
1416 dns_authority: None,
1417 cdn_authority: None,
1418 },
1419 lifetime: Lifetime { ttl_seconds: 60 },
1420 export: Some(ExportChannels {
1421 artifacts: Some(vec![ExportArtifact {
1422 name: "coverage-summary".into(),
1423 path: "/tmp/coverage.txt".into(),
1424 target: Some("artifact-api".into()),
1425 content_type: Some("text/plain".into()),
1426 }]),
1427 targets: Some(vec![ExportTarget::Http(HttpExportTarget {
1428 name: "artifact-api".into(),
1429 base_url: "ftp://artifacts.example.invalid/upload".into(),
1430 secret_ref: Some("ARTIFACT_API_TOKEN".into()),
1431 })]),
1432 }),
1433 telemetry: None,
1434 },
1435 };
1436 assert!(validate_execution_cell_document(&doc).is_err());
1437 }
1438
1439 use super::is_portable_identifier;
1444 use proptest::prelude::*;
1445
1446 fn minimal_doc_with_placement(placement: crate::PlacementSpec) -> ExecutionCellDocument {
1447 ExecutionCellDocument {
1448 api_version: "cellos.io/v1".into(),
1449 kind: "ExecutionCell".into(),
1450 spec: ExecutionCellSpec {
1451 id: "placement-test-cell".into(),
1452 correlation: None,
1453 ingress: None,
1454 environment: None,
1455 placement: Some(placement),
1456 policy: None,
1457 identity: None,
1458 run: None,
1459 authority: AuthorityBundle::default(),
1460 lifetime: Lifetime { ttl_seconds: 60 },
1461 export: None,
1462 telemetry: None,
1463 },
1464 }
1465 }
1466
1467 #[test]
1468 fn rejects_placement_pool_id_with_separator() {
1469 let doc = minimal_doc_with_placement(crate::PlacementSpec {
1470 pool_id: Some("pool/main".into()),
1471 kubernetes_namespace: None,
1472 queue_name: None,
1473 });
1474 let err = validate_execution_cell_document(&doc).unwrap_err();
1475 assert!(err.to_string().contains("spec.placement.poolId"));
1476 }
1477
1478 #[test]
1479 fn rejects_empty_placement_hints() {
1480 let doc = minimal_doc_with_placement(crate::PlacementSpec::default());
1481 let err = validate_execution_cell_document(&doc).unwrap_err();
1482 assert!(err
1483 .to_string()
1484 .contains("spec.placement must set at least one placement hint"));
1485 }
1486
1487 #[test]
1488 fn rejects_placement_queue_name_with_dotdot() {
1489 let doc = minimal_doc_with_placement(crate::PlacementSpec {
1490 pool_id: None,
1491 kubernetes_namespace: None,
1492 queue_name: Some("ci..high".into()),
1493 });
1494 let err = validate_execution_cell_document(&doc).unwrap_err();
1495 assert!(err.to_string().contains("spec.placement.queueName"));
1496 }
1497
1498 #[test]
1499 fn rejects_placement_namespace_with_uppercase() {
1500 let doc = minimal_doc_with_placement(crate::PlacementSpec {
1501 pool_id: None,
1502 kubernetes_namespace: Some("CellOS-Prod".into()),
1503 queue_name: None,
1504 });
1505 let err = validate_execution_cell_document(&doc).unwrap_err();
1506 assert!(err
1507 .to_string()
1508 .contains("spec.placement.kubernetesNamespace"));
1509 }
1510
1511 #[test]
1512 fn accepts_valid_placement_hints() {
1513 let doc = minimal_doc_with_placement(crate::PlacementSpec {
1514 pool_id: Some("runner-pool-amd64".into()),
1515 kubernetes_namespace: Some("cellos-prod".into()),
1516 queue_name: Some("ci-high".into()),
1517 });
1518 assert!(validate_execution_cell_document(&doc).is_ok());
1519 }
1520
1521 proptest! {
1522 #[test]
1524 fn prop_rejects_slash_in_identifier(
1525 prefix in "[A-Za-z0-9._-]{0,20}",
1526 suffix in "[A-Za-z0-9._-]{0,20}",
1527 ) {
1528 let with_slash = format!("{prefix}/{suffix}");
1529 prop_assert!(!is_portable_identifier(&with_slash), "slash in {with_slash:?}");
1530 }
1531
1532 #[test]
1534 fn prop_rejects_backslash_in_identifier(
1535 prefix in "[A-Za-z0-9._-]{0,20}",
1536 suffix in "[A-Za-z0-9._-]{0,20}",
1537 ) {
1538 let with_bs = format!("{prefix}\\{suffix}");
1539 prop_assert!(!is_portable_identifier(&with_bs), "backslash in {with_bs:?}");
1540 }
1541
1542 #[test]
1544 fn prop_rejects_dotdot_in_identifier(
1545 prefix in "[A-Za-z0-9._-]{0,20}",
1546 suffix in "[A-Za-z0-9._-]{0,20}",
1547 ) {
1548 let with_dotdot = format!("{prefix}..{suffix}");
1549 prop_assert!(!is_portable_identifier(&with_dotdot), ".. in {with_dotdot:?}");
1550 }
1551
1552 #[test]
1554 fn prop_rejects_non_alphanum_first_char(
1555 first in "[._\\-]",
1556 rest in "[A-Za-z0-9._-]{0,20}",
1557 ) {
1558 let s = format!("{first}{rest}");
1559 prop_assert!(!is_portable_identifier(&s), "starts with non-alphanum: {s:?}");
1560 }
1561
1562 #[test]
1564 fn prop_accepts_valid_identifiers(
1565 first in "[A-Za-z0-9]",
1566 rest in "[A-Za-z0-9._-]{0,60}",
1567 ) {
1568 let s = format!("{first}{rest}");
1569 prop_assume!(!s.contains(".."));
1571 prop_assert!(is_portable_identifier(&s), "should accept {s:?}");
1572 }
1573
1574 #[test]
1576 fn prop_invalid_spec_id_rejected_by_validate(
1577 prefix in "[A-Za-z0-9._-]{0,10}",
1578 suffix in "[A-Za-z0-9._-]{0,10}",
1579 ) {
1580 let bad_id = format!("{prefix}/{suffix}");
1582 let doc = ExecutionCellDocument {
1583 api_version: "cellos.io/v1".into(),
1584 kind: "ExecutionCell".into(),
1585 spec: ExecutionCellSpec {
1586 id: bad_id.clone(),
1587 correlation: None,
1588 ingress: None,
1589 environment: None,
1590 placement: None,
1591 policy: None,
1592 identity: None,
1593 run: None,
1594 authority: AuthorityBundle::default(),
1595 lifetime: Lifetime { ttl_seconds: 60 },
1596 export: None,
1597 telemetry: None,
1598 },
1599 };
1600 prop_assert!(
1601 validate_execution_cell_document(&doc).is_err(),
1602 "expected error for id {bad_id:?}"
1603 );
1604 }
1605
1606 #[test]
1608 fn prop_empty_argv_token_rejected(
1609 prefix_args in prop::collection::vec("[A-Za-z0-9/_-]{1,20}", 0..5),
1610 suffix_args in prop::collection::vec("[A-Za-z0-9/_-]{1,20}", 0..5),
1611 ) {
1612 let mut argv: Vec<String> = prefix_args;
1614 argv.push(String::new()); argv.extend(suffix_args);
1616
1617 let doc = ExecutionCellDocument {
1618 api_version: "cellos.io/v1".into(),
1619 kind: "ExecutionCell".into(),
1620 spec: ExecutionCellSpec {
1621 id: "valid-cell".into(),
1622 correlation: None,
1623 ingress: None,
1624 environment: None,
1625 placement: None,
1626 policy: None,
1627 identity: None,
1628 run: Some(RunSpec {
1629 argv,
1630 working_directory: None,
1631 timeout_ms: None,
1632 limits: None,
1633 secret_delivery: SecretDeliveryMode::Env,
1634 }),
1635 authority: AuthorityBundle::default(),
1636 lifetime: Lifetime { ttl_seconds: 60 },
1637 export: None,
1638 telemetry: None,
1639 },
1640 };
1641 prop_assert!(
1642 validate_execution_cell_document(&doc).is_err(),
1643 "empty argv token should be rejected"
1644 );
1645 }
1646 }
1647
1648 #[test]
1649 fn rejects_http_export_target_missing_egress_rule() {
1650 let doc = ExecutionCellDocument {
1651 api_version: "cellos.io/v1".into(),
1652 kind: "ExecutionCell".into(),
1653 spec: ExecutionCellSpec {
1654 id: "safe-cell".into(),
1655 correlation: None,
1656 ingress: None,
1657 environment: None,
1658 placement: None,
1659 policy: None,
1660 identity: None,
1661 run: None,
1662 authority: AuthorityBundle {
1663 filesystem: None,
1664 network: None,
1665 egress_rules: Some(vec![crate::EgressRule {
1666 host: "api.github.com".into(),
1667 port: 443,
1668 protocol: Some("tls".into()),
1669 dns_egress_justification: None,
1670 }]),
1671 secret_refs: Some(vec!["ARTIFACT_API_TOKEN".into()]),
1672 authority_derivation: None,
1673 dns_authority: None,
1674 cdn_authority: None,
1675 },
1676 lifetime: Lifetime { ttl_seconds: 60 },
1677 export: Some(ExportChannels {
1678 artifacts: Some(vec![ExportArtifact {
1679 name: "coverage-summary".into(),
1680 path: "/tmp/coverage.txt".into(),
1681 target: Some("artifact-api".into()),
1682 content_type: Some("text/plain".into()),
1683 }]),
1684 targets: Some(vec![ExportTarget::Http(HttpExportTarget {
1685 name: "artifact-api".into(),
1686 base_url: "https://artifacts.acme.internal/upload".into(),
1687 secret_ref: Some("ARTIFACT_API_TOKEN".into()),
1688 })]),
1689 }),
1690 telemetry: None,
1691 },
1692 };
1693 assert!(validate_execution_cell_document(&doc).is_err());
1694 }
1695
1696 #[test]
1697 fn rejects_run_timeout_longer_than_lifetime() {
1698 let doc = ExecutionCellDocument {
1699 api_version: "cellos.io/v1".into(),
1700 kind: "ExecutionCell".into(),
1701 spec: ExecutionCellSpec {
1702 id: "safe-cell".into(),
1703 correlation: None,
1704 ingress: None,
1705 environment: None,
1706 placement: None,
1707 policy: None,
1708 identity: None,
1709 run: Some(RunSpec {
1710 argv: vec!["/usr/bin/true".into()],
1711 working_directory: None,
1712 timeout_ms: Some(61_000),
1713 limits: None,
1714 secret_delivery: SecretDeliveryMode::Env,
1715 }),
1716 authority: AuthorityBundle::default(),
1717 lifetime: Lifetime { ttl_seconds: 60 },
1718 export: None,
1719 telemetry: None,
1720 },
1721 };
1722 assert!(validate_execution_cell_document(&doc).is_err());
1723 }
1724
1725 #[test]
1729 fn accepts_run_timeout_equal_to_lifetime() {
1730 let doc = ExecutionCellDocument {
1731 api_version: "cellos.io/v1".into(),
1732 kind: "ExecutionCell".into(),
1733 spec: ExecutionCellSpec {
1734 id: "safe-cell-boundary".into(),
1735 correlation: None,
1736 ingress: None,
1737 environment: None,
1738 placement: None,
1739 policy: None,
1740 identity: None,
1741 run: Some(RunSpec {
1742 argv: vec!["/usr/bin/true".into()],
1743 working_directory: None,
1744 timeout_ms: Some(60_000),
1745 limits: None,
1746 secret_delivery: SecretDeliveryMode::Env,
1747 }),
1748 authority: AuthorityBundle::default(),
1749 lifetime: Lifetime { ttl_seconds: 60 },
1750 export: None,
1751 telemetry: None,
1752 },
1753 };
1754 assert!(
1755 validate_execution_cell_document(&doc).is_ok(),
1756 "timeout_ms == ttl_seconds * 1000 must be accepted (boundary)"
1757 );
1758 }
1759
1760 #[test]
1764 fn rejects_run_timeout_one_ms_over_lifetime() {
1765 let doc = ExecutionCellDocument {
1766 api_version: "cellos.io/v1".into(),
1767 kind: "ExecutionCell".into(),
1768 spec: ExecutionCellSpec {
1769 id: "safe-cell-overshoot".into(),
1770 correlation: None,
1771 ingress: None,
1772 environment: None,
1773 placement: None,
1774 policy: None,
1775 identity: None,
1776 run: Some(RunSpec {
1777 argv: vec!["/usr/bin/true".into()],
1778 working_directory: None,
1779 timeout_ms: Some(60_001),
1780 limits: None,
1781 secret_delivery: SecretDeliveryMode::Env,
1782 }),
1783 authority: AuthorityBundle::default(),
1784 lifetime: Lifetime { ttl_seconds: 60 },
1785 export: None,
1786 telemetry: None,
1787 },
1788 };
1789 let err = validate_execution_cell_document(&doc).expect_err("must reject");
1790 let msg = format!("{err}");
1791 assert!(
1792 msg.contains("timeoutMs"),
1793 "error must mention timeoutMs; got {msg:?}"
1794 );
1795 assert!(
1796 msg.contains("ttlSeconds"),
1797 "error must mention ttlSeconds; got {msg:?}"
1798 );
1799 }
1800
1801 #[test]
1802 fn accepts_run_limits_and_timeout_within_lifetime() {
1803 let doc = ExecutionCellDocument {
1804 api_version: "cellos.io/v1".into(),
1805 kind: "ExecutionCell".into(),
1806 spec: ExecutionCellSpec {
1807 id: "safe-cell".into(),
1808 correlation: None,
1809 ingress: None,
1810 environment: None,
1811 placement: None,
1812 policy: None,
1813 identity: None,
1814 run: Some(RunSpec {
1815 argv: vec!["/usr/bin/true".into()],
1816 working_directory: None,
1817 timeout_ms: Some(5_000),
1818 limits: Some(RunLimits {
1819 memory_max_bytes: Some(268_435_456),
1820 cpu_max: Some(RunCpuMax {
1821 quota_micros: 50_000,
1822 period_micros: Some(100_000),
1823 }),
1824 graceful_shutdown_seconds: None,
1825 }),
1826 secret_delivery: SecretDeliveryMode::Env,
1827 }),
1828 authority: AuthorityBundle::default(),
1829 lifetime: Lifetime { ttl_seconds: 60 },
1830 export: None,
1831 telemetry: None,
1832 },
1833 };
1834 assert!(validate_execution_cell_document(&doc).is_ok());
1835 }
1836
1837 fn minimal_doc_with_env(env: EnvironmentSpec) -> ExecutionCellDocument {
1840 ExecutionCellDocument {
1841 api_version: "cellos.io/v1".into(),
1842 kind: "ExecutionCell".into(),
1843 spec: ExecutionCellSpec {
1844 id: "env-test-cell".into(),
1845 correlation: None,
1846 ingress: None,
1847 environment: Some(env),
1848 placement: None,
1849 policy: None,
1850 identity: None,
1851 run: None,
1852 authority: AuthorityBundle::default(),
1853 lifetime: Lifetime { ttl_seconds: 60 },
1854 export: None,
1855 telemetry: None,
1856 },
1857 }
1858 }
1859
1860 #[test]
1861 fn accepts_environment_with_reference_only() {
1862 let doc = minimal_doc_with_env(EnvironmentSpec {
1863 image_reference: "ubuntu:24.04".into(),
1864 image_digest: None,
1865 template_id: None,
1866 });
1867 assert!(validate_execution_cell_document(&doc).is_ok());
1868 }
1869
1870 #[test]
1871 fn accepts_environment_with_digest_and_template() {
1872 let doc = minimal_doc_with_env(EnvironmentSpec {
1873 image_reference: "ubuntu:24.04".into(),
1874 image_digest: Some(
1875 "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
1876 ),
1877 template_id: Some("ubuntu-24-04-build".into()),
1878 });
1879 assert!(validate_execution_cell_document(&doc).is_ok());
1880 }
1881
1882 #[test]
1883 fn rejects_environment_with_empty_image_reference() {
1884 let doc = minimal_doc_with_env(EnvironmentSpec {
1885 image_reference: "".into(),
1886 image_digest: None,
1887 template_id: None,
1888 });
1889 let err = validate_execution_cell_document(&doc).unwrap_err();
1890 assert!(
1891 err.to_string().contains("imageReference"),
1892 "expected imageReference in error, got: {err}"
1893 );
1894 }
1895
1896 #[test]
1897 fn rejects_environment_with_bad_digest_missing_prefix() {
1898 let doc = minimal_doc_with_env(EnvironmentSpec {
1899 image_reference: "ubuntu:24.04".into(),
1900 image_digest: Some(
1901 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
1902 ),
1903 template_id: None,
1904 });
1905 let err = validate_execution_cell_document(&doc).unwrap_err();
1906 assert!(err.to_string().contains("imageDigest"), "{err}");
1907 }
1908
1909 #[test]
1910 fn rejects_environment_with_digest_wrong_length() {
1911 let doc = minimal_doc_with_env(EnvironmentSpec {
1912 image_reference: "ubuntu:24.04".into(),
1913 image_digest: Some("sha256:deadbeef".into()),
1914 template_id: None,
1915 });
1916 let err = validate_execution_cell_document(&doc).unwrap_err();
1917 assert!(err.to_string().contains("imageDigest"), "{err}");
1918 }
1919
1920 #[test]
1921 fn rejects_environment_with_uppercase_digest() {
1922 let doc = minimal_doc_with_env(EnvironmentSpec {
1923 image_reference: "ubuntu:24.04".into(),
1924 image_digest: Some(
1925 "sha256:A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(),
1926 ),
1927 template_id: None,
1928 });
1929 let err = validate_execution_cell_document(&doc).unwrap_err();
1930 assert!(err.to_string().contains("imageDigest"), "{err}");
1931 }
1932
1933 #[test]
1934 fn rejects_environment_with_invalid_template_id() {
1935 let doc = minimal_doc_with_env(EnvironmentSpec {
1936 image_reference: "ubuntu:24.04".into(),
1937 image_digest: None,
1938 template_id: Some("../escape".into()),
1939 });
1940 let err = validate_execution_cell_document(&doc).unwrap_err();
1941 assert!(err.to_string().contains("templateId"), "{err}");
1942 }
1943
1944 #[test]
1945 fn is_sha256_digest_accepts_valid() {
1946 assert!(is_sha256_digest(
1947 "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
1948 ));
1949 }
1950
1951 #[test]
1952 fn is_sha256_digest_rejects_short() {
1953 assert!(!is_sha256_digest("sha256:deadbeef"));
1954 }
1955
1956 #[test]
1957 fn is_sha256_digest_rejects_no_prefix() {
1958 assert!(!is_sha256_digest(
1959 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
1960 ));
1961 }
1962
1963 #[test]
1964 fn is_sha256_digest_rejects_uppercase() {
1965 assert!(!is_sha256_digest(
1966 "sha256:A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"
1967 ));
1968 }
1969
1970 fn derivation_spec_with_authority(
1971 egress: Vec<crate::EgressRule>,
1972 secret_refs: Vec<String>,
1973 ) -> ExecutionCellSpec {
1974 ExecutionCellSpec {
1975 id: "deriv-test".into(),
1976 correlation: None,
1977 ingress: None,
1978 environment: None,
1979 placement: None,
1980 policy: None,
1981 identity: None,
1982 run: None,
1983 authority: AuthorityBundle {
1984 filesystem: None,
1985 network: None,
1986 egress_rules: Some(egress),
1987 secret_refs: Some(secret_refs),
1988 authority_derivation: None,
1989 dns_authority: None,
1990 cdn_authority: None,
1991 },
1992 lifetime: Lifetime { ttl_seconds: 60 },
1993 export: None,
1994 telemetry: None,
1995 }
1996 }
1997
1998 fn token_with_leaf(leaf: crate::AuthorityCapability) -> crate::AuthorityDerivationToken {
1999 crate::AuthorityDerivationToken {
2000 role_root: crate::RoleId("role-test".into()),
2001 parent_run_id: None,
2002 derivation_steps: vec![],
2003 leaf_capability: leaf,
2004 grantor_signature: crate::AuthoritySignature {
2005 algorithm: "ed25519".into(),
2006 bytes: "AA==".into(),
2007 },
2008 }
2009 }
2010
2011 fn sign_token(
2015 signing_key: &ed25519_dalek::SigningKey,
2016 mut token: crate::AuthorityDerivationToken,
2017 ) -> crate::AuthorityDerivationToken {
2018 use base64::engine::general_purpose::STANDARD;
2019 use base64::Engine as _;
2020 use ed25519_dalek::Signer as _;
2021
2022 let payload = authority_derivation_signing_payload(&token).expect("payload encode");
2023 let signature = signing_key.sign(&payload);
2024 token.grantor_signature.bytes = STANDARD.encode(signature.to_bytes());
2025 token
2026 }
2027
2028 fn test_signing_key(seed: u8) -> ed25519_dalek::SigningKey {
2031 ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
2032 }
2033
2034 fn verifying_key_b64(signing_key: &ed25519_dalek::SigningKey) -> String {
2035 use base64::engine::general_purpose::STANDARD;
2036 use base64::Engine as _;
2037 STANDARD.encode(signing_key.verifying_key().to_bytes())
2038 }
2039
2040 #[test]
2041 fn verify_authority_derivation_child_within_parent_passes() {
2042 let spec = derivation_spec_with_authority(
2044 vec![crate::EgressRule {
2045 host: "api.example.com".into(),
2046 port: 443,
2047 protocol: Some("https".into()),
2048 dns_egress_justification: None,
2049 }],
2050 vec!["api-key".into()],
2051 );
2052 let signing_key = test_signing_key(0x42);
2053 let token = sign_token(
2054 &signing_key,
2055 token_with_leaf(crate::AuthorityCapability {
2056 egress_rules: vec![crate::EgressRule {
2057 host: "api.example.com".into(),
2058 port: 443,
2059 protocol: Some("https".into()),
2060 dns_egress_justification: None,
2061 }],
2062 secret_refs: vec!["api-key".into()],
2063 }),
2064 );
2065 let mut keys = std::collections::HashMap::new();
2066 keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2067 assert!(verify_authority_derivation(&spec, &token, &keys).is_ok());
2068 }
2069
2070 #[test]
2071 fn verify_authority_derivation_child_exceeding_parent_fails_egress() {
2072 let spec = derivation_spec_with_authority(
2073 vec![crate::EgressRule {
2074 host: "api.example.com".into(),
2075 port: 443,
2076 protocol: Some("https".into()),
2077 dns_egress_justification: None,
2078 }],
2079 vec![],
2080 );
2081 let token = token_with_leaf(crate::AuthorityCapability {
2082 egress_rules: vec![crate::EgressRule {
2083 host: "evil.example.com".into(),
2084 port: 443,
2085 protocol: Some("https".into()),
2086 dns_egress_justification: None,
2087 }],
2088 secret_refs: vec![],
2089 });
2090 let keys = std::collections::HashMap::new();
2091 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2092 assert!(err.to_string().contains("leafCapability"), "{err}");
2093 }
2094
2095 #[test]
2096 fn verify_authority_derivation_child_exceeding_parent_fails_secrets() {
2097 let spec = derivation_spec_with_authority(vec![], vec!["api-key".into()]);
2098 let token = token_with_leaf(crate::AuthorityCapability {
2099 egress_rules: vec![],
2100 secret_refs: vec!["root-token".into()],
2101 });
2102 let keys = std::collections::HashMap::new();
2103 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2104 assert!(err.to_string().contains("leafCapability"), "{err}");
2105 }
2106
2107 #[test]
2108 fn validate_doc_rejects_spec_with_exceeding_derivation_token() {
2109 let mut spec = derivation_spec_with_authority(
2110 vec![crate::EgressRule {
2111 host: "api.example.com".into(),
2112 port: 443,
2113 protocol: Some("https".into()),
2114 dns_egress_justification: None,
2115 }],
2116 vec![],
2117 );
2118 spec.authority.authority_derivation = Some(token_with_leaf(crate::AuthorityCapability {
2119 egress_rules: vec![crate::EgressRule {
2120 host: "evil.example.com".into(),
2121 port: 443,
2122 protocol: Some("https".into()),
2123 dns_egress_justification: None,
2124 }],
2125 secret_refs: vec![],
2126 }));
2127 let doc = ExecutionCellDocument {
2128 api_version: "cellos.io/v1".into(),
2129 kind: "ExecutionCell".into(),
2130 spec,
2131 };
2132 let err = validate_execution_cell_document(&doc).unwrap_err();
2133 assert!(err.to_string().contains("leafCapability"), "{err}");
2134 }
2135
2136 #[test]
2137 fn validate_doc_accepts_spec_with_valid_derivation_token() {
2138 let mut spec = derivation_spec_with_authority(
2139 vec![crate::EgressRule {
2140 host: "api.example.com".into(),
2141 port: 443,
2142 protocol: Some("https".into()),
2143 dns_egress_justification: None,
2144 }],
2145 vec!["api-key".into()],
2146 );
2147 spec.authority.authority_derivation = Some(token_with_leaf(crate::AuthorityCapability {
2148 egress_rules: vec![crate::EgressRule {
2149 host: "api.example.com".into(),
2150 port: 443,
2151 protocol: Some("https".into()),
2152 dns_egress_justification: None,
2153 }],
2154 secret_refs: vec!["api-key".into()],
2155 }));
2156 let doc = ExecutionCellDocument {
2157 api_version: "cellos.io/v1".into(),
2158 kind: "ExecutionCell".into(),
2159 spec,
2160 };
2161 assert!(validate_execution_cell_document(&doc).is_ok());
2162 }
2163
2164 fn signed_spec_and_token() -> (
2167 crate::ExecutionCellSpec,
2168 crate::AuthorityDerivationToken,
2169 ed25519_dalek::SigningKey,
2170 ) {
2171 let spec = derivation_spec_with_authority(
2172 vec![crate::EgressRule {
2173 host: "api.example.com".into(),
2174 port: 443,
2175 protocol: Some("https".into()),
2176 dns_egress_justification: None,
2177 }],
2178 vec!["api-key".into()],
2179 );
2180 let signing_key = test_signing_key(0x11);
2181 let token = sign_token(
2182 &signing_key,
2183 token_with_leaf(crate::AuthorityCapability {
2184 egress_rules: vec![crate::EgressRule {
2185 host: "api.example.com".into(),
2186 port: 443,
2187 protocol: Some("https".into()),
2188 dns_egress_justification: None,
2189 }],
2190 secret_refs: vec!["api-key".into()],
2191 }),
2192 );
2193 (spec, token, signing_key)
2194 }
2195
2196 #[test]
2197 fn test_good_signature_passes() {
2198 let (spec, token, signing_key) = signed_spec_and_token();
2199 let mut keys = std::collections::HashMap::new();
2200 keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2201 assert!(verify_authority_derivation(&spec, &token, &keys).is_ok());
2202 }
2203
2204 #[test]
2205 fn test_bad_signature_rejected() {
2206 let (spec, mut token, signing_key) = signed_spec_and_token();
2207 use base64::engine::general_purpose::STANDARD;
2209 use base64::Engine as _;
2210 let mut sig_bytes = STANDARD
2211 .decode(token.grantor_signature.bytes.as_bytes())
2212 .expect("decode original signature");
2213 let last = sig_bytes.len() - 1;
2214 sig_bytes[last] ^= 0x01;
2215 token.grantor_signature.bytes = STANDARD.encode(&sig_bytes);
2216
2217 let mut keys = std::collections::HashMap::new();
2218 keys.insert("role-test".to_string(), verifying_key_b64(&signing_key));
2219 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2220 match err {
2221 crate::error::CellosError::InvalidSpec(msg) => {
2222 assert!(
2223 msg.contains("authority derivation signature invalid"),
2224 "unexpected error message: {msg}"
2225 );
2226 }
2227 other => panic!("expected InvalidSpec, got {other:?}"),
2228 }
2229 }
2230
2231 #[test]
2232 fn test_unknown_role_rejected() {
2233 let (spec, token, _signing_key) = signed_spec_and_token();
2234 let other_key = test_signing_key(0x22);
2235 let mut keys = std::collections::HashMap::new();
2236 keys.insert("role-other".to_string(), verifying_key_b64(&other_key));
2238 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2239 match err {
2240 crate::error::CellosError::InvalidSpec(msg) => {
2241 assert!(msg.contains("unknown role"), "unexpected message: {msg}");
2242 assert!(
2243 msg.contains("role-test"),
2244 "expected role id in message: {msg}"
2245 );
2246 }
2247 other => panic!("expected InvalidSpec, got {other:?}"),
2248 }
2249 }
2250
2251 #[test]
2252 fn test_no_token_passes() {
2253 let spec = derivation_spec_with_authority(
2256 vec![crate::EgressRule {
2257 host: "api.example.com".into(),
2258 port: 443,
2259 protocol: Some("https".into()),
2260 dns_egress_justification: None,
2261 }],
2262 vec!["api-key".into()],
2263 );
2264 assert!(spec.authority.authority_derivation.is_none());
2265 let doc = ExecutionCellDocument {
2266 api_version: "cellos.io/v1".into(),
2267 kind: "ExecutionCell".into(),
2268 spec,
2269 };
2270 assert!(validate_execution_cell_document(&doc).is_ok());
2271 }
2272
2273 #[test]
2274 fn test_empty_keys_with_token_fails() {
2275 let (spec, token, _signing_key) = signed_spec_and_token();
2276 let keys: std::collections::HashMap<String, String> = std::collections::HashMap::new();
2277 let err = verify_authority_derivation(&spec, &token, &keys).unwrap_err();
2278 match err {
2279 crate::error::CellosError::InvalidSpec(msg) => {
2280 assert!(msg.contains("unknown role"), "unexpected message: {msg}");
2281 }
2282 other => panic!("expected InvalidSpec, got {other:?}"),
2283 }
2284 }
2285
2286 #[test]
2289 fn enforce_scope_policy_universal_allowed_when_permissive() {
2290 let (_spec, token, _signing_key) = signed_spec_and_token();
2291 assert!(token.parent_run_id.is_none());
2292 assert!(enforce_derivation_scope_policy(&token, true).is_ok());
2294 }
2295
2296 #[test]
2297 fn enforce_scope_policy_universal_rejected_when_strict() {
2298 let (_spec, token, _signing_key) = signed_spec_and_token();
2299 assert!(token.parent_run_id.is_none());
2300 let err = enforce_derivation_scope_policy(&token, false).unwrap_err();
2301 match err {
2302 crate::error::CellosError::InvalidSpec(msg) => {
2303 assert!(
2304 msg.contains("parentRunId: null")
2305 && msg.contains("CELLOS_REQUIRE_SCOPED_DERIVATION_TOKENS"),
2306 "unexpected message: {msg}"
2307 );
2308 }
2309 other => panic!("expected InvalidSpec, got {other:?}"),
2310 }
2311 }
2312
2313 #[test]
2314 fn enforce_scope_policy_scoped_token_passes_in_either_mode() {
2315 let (_spec, mut token, _signing_key) = signed_spec_and_token();
2316 token.parent_run_id = Some("run-2026-04-25-abc".into());
2317 assert!(enforce_derivation_scope_policy(&token, true).is_ok());
2318 assert!(enforce_derivation_scope_policy(&token, false).is_ok());
2319 }
2320
2321 #[test]
2338 #[ignore]
2339 fn gen_signed_ci_runner_fixture() {
2340 let signing_key = test_signing_key(0x42);
2341 let leaf = crate::AuthorityCapability {
2342 egress_rules: vec![
2343 crate::EgressRule {
2344 host: "api.github.com".into(),
2345 port: 443,
2346 protocol: Some("tls".into()),
2347 dns_egress_justification: None,
2348 },
2349 crate::EgressRule {
2350 host: "ghcr.io".into(),
2351 port: 443,
2352 protocol: Some("tls".into()),
2353 dns_egress_justification: None,
2354 },
2355 crate::EgressRule {
2356 host: "artifacts.internal".into(),
2357 port: 443,
2358 protocol: Some("tls".into()),
2359 dns_egress_justification: None,
2360 },
2361 crate::EgressRule {
2362 host: "dns.internal".into(),
2363 port: 53,
2364 protocol: Some("dns-acknowledged".into()),
2365 dns_egress_justification: Some(
2366 "Internal resolver at dns.internal required for artifacts.internal hostname resolution; nameserver is operator-controlled and air-gapped from public internet.".into(),
2367 ),
2368 },
2369 ],
2370 secret_refs: vec!["NPM_TOKEN".into(), "GITHUB_TOKEN".into()],
2371 };
2372 let token = crate::AuthorityDerivationToken {
2373 role_root: crate::RoleId("role-ci-runner".into()),
2374 parent_run_id: Some("run-demo-ref-001".into()),
2375 derivation_steps: vec![],
2376 leaf_capability: leaf,
2377 grantor_signature: crate::AuthoritySignature {
2378 algorithm: "ed25519".into(),
2379 bytes: "AA==".into(),
2380 },
2381 };
2382 let signed = sign_token(&signing_key, token);
2383 eprintln!("FIXTURE seed=0x42 role=role-ci-runner parentRunId=run-demo-ref-001");
2384 eprintln!(
2385 "FIXTURE verifyingKey(b64): {}",
2386 verifying_key_b64(&signing_key)
2387 );
2388 eprintln!(
2389 "FIXTURE grantorSignature(b64): {}",
2390 signed.grantor_signature.bytes
2391 );
2392 }
2393
2394 fn doc_with_authority(authority: AuthorityBundle) -> ExecutionCellDocument {
2397 ExecutionCellDocument {
2398 api_version: "cellos.io/v1".into(),
2399 kind: "ExecutionCell".into(),
2400 spec: ExecutionCellSpec {
2401 id: "dns-cdn-test-cell".into(),
2402 correlation: None,
2403 ingress: None,
2404 environment: None,
2405 placement: None,
2406 policy: None,
2407 identity: None,
2408 run: None,
2409 authority,
2410 lifetime: Lifetime { ttl_seconds: 60 },
2411 export: None,
2412 telemetry: None,
2413 },
2414 }
2415 }
2416
2417 fn full_dns_authority() -> crate::DnsAuthority {
2418 crate::DnsAuthority {
2419 resolvers: vec![crate::DnsResolver {
2420 resolver_id: "internal-doh".into(),
2421 endpoint: "https://1.1.1.1/dns-query".into(),
2422 protocol: crate::DnsResolverProtocol::Doh,
2423 trust_kid: Some("resolver-kid-2026q2".into()),
2424 dnssec: None,
2425 }],
2426 allowed_query_types: vec![crate::DnsQueryType::A, crate::DnsQueryType::AAAA],
2427 hostname_allowlist: vec!["api.example.com".into(), "*.cdn.example.com".into()],
2428 refresh_policy: Some(crate::DnsRefreshPolicy {
2429 min_ttl_seconds: Some(30),
2430 max_stale_seconds: Some(300),
2431 strategy: Some(crate::DnsRefreshStrategy::TtlHonor),
2432 }),
2433 rebinding_policy: None,
2436 block_direct_workload_dns: true,
2437 block_udp_doq: false,
2442 block_udp_http3: false,
2443 }
2444 }
2445
2446 #[test]
2447 fn t13_accepts_full_dns_and_cdn_authority() {
2448 let authority = AuthorityBundle {
2449 filesystem: None,
2450 network: None,
2451 egress_rules: None,
2452 secret_refs: None,
2453 authority_derivation: None,
2454 dns_authority: Some(full_dns_authority()),
2455 cdn_authority: Some(crate::CdnAuthority {
2456 providers: vec![crate::CdnProvider {
2457 provider_id: "cloudfront".into(),
2458 hostname_pattern: "*.cdn.example.com".into(),
2459 accept_fronting: false,
2460 }],
2461 }),
2462 };
2463 let doc = doc_with_authority(authority);
2464 validate_execution_cell_document(&doc).expect("full T13 authority should validate");
2465 }
2466
2467 #[test]
2468 fn t13_rejects_dns_hostname_allowlist_with_ip_literal() {
2469 let mut dns = full_dns_authority();
2470 dns.hostname_allowlist = vec!["10.0.0.1".into()];
2471 let authority = AuthorityBundle {
2472 dns_authority: Some(dns),
2473 ..AuthorityBundle::default()
2474 };
2475 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2476 assert!(
2477 err.to_string()
2478 .contains("authority.dnsAuthority.hostnameAllowlist"),
2479 "{err}"
2480 );
2481 }
2482
2483 #[test]
2484 fn t13_rejects_dns_hostname_allowlist_with_internal_wildcard() {
2485 let mut dns = full_dns_authority();
2486 dns.hostname_allowlist = vec!["api.*.example.com".into()];
2488 let authority = AuthorityBundle {
2489 dns_authority: Some(dns),
2490 ..AuthorityBundle::default()
2491 };
2492 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2493 assert!(
2494 err.to_string()
2495 .contains("authority.dnsAuthority.hostnameAllowlist"),
2496 "{err}"
2497 );
2498 }
2499
2500 #[test]
2501 fn t13_rejects_dns_resolver_with_invalid_resolver_id() {
2502 let mut dns = full_dns_authority();
2503 dns.resolvers[0].resolver_id = "../escape".into();
2504 let authority = AuthorityBundle {
2505 dns_authority: Some(dns),
2506 ..AuthorityBundle::default()
2507 };
2508 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2509 assert!(
2510 err.to_string()
2511 .contains("authority.dnsAuthority.resolvers[].resolverId"),
2512 "{err}"
2513 );
2514 }
2515
2516 #[test]
2517 fn t13_rejects_duplicate_dns_resolver_ids() {
2518 let mut dns = full_dns_authority();
2519 dns.resolvers.push(crate::DnsResolver {
2520 resolver_id: "internal-doh".into(),
2521 endpoint: "https://1.0.0.1/dns-query".into(),
2522 protocol: crate::DnsResolverProtocol::Doh,
2523 trust_kid: None,
2524 dnssec: None,
2525 });
2526 let authority = AuthorityBundle {
2527 dns_authority: Some(dns),
2528 ..AuthorityBundle::default()
2529 };
2530 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2531 assert!(
2532 err.to_string().contains("duplicates value"),
2533 "expected duplicate resolverId rejection, got: {err}"
2534 );
2535 }
2536
2537 #[test]
2538 fn t13_rejects_refresh_policy_min_ttl_greater_than_max_stale() {
2539 let mut dns = full_dns_authority();
2540 dns.refresh_policy = Some(crate::DnsRefreshPolicy {
2541 min_ttl_seconds: Some(600),
2542 max_stale_seconds: Some(60),
2543 strategy: Some(crate::DnsRefreshStrategy::TtlHonor),
2544 });
2545 let authority = AuthorityBundle {
2546 dns_authority: Some(dns),
2547 ..AuthorityBundle::default()
2548 };
2549 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2550 assert!(
2551 err.to_string().contains("minTtlSeconds")
2552 && err.to_string().contains("maxStaleSeconds"),
2553 "{err}"
2554 );
2555 }
2556
2557 #[test]
2558 fn t13_rejects_cdn_provider_with_ip_literal_pattern() {
2559 let authority = AuthorityBundle {
2560 cdn_authority: Some(crate::CdnAuthority {
2561 providers: vec![crate::CdnProvider {
2562 provider_id: "fastly".into(),
2563 hostname_pattern: "192.168.1.1".into(),
2564 accept_fronting: false,
2565 }],
2566 }),
2567 ..AuthorityBundle::default()
2568 };
2569 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2570 assert!(
2571 err.to_string()
2572 .contains("authority.cdnAuthority.providers[].hostnamePattern"),
2573 "{err}"
2574 );
2575 }
2576
2577 #[test]
2578 fn t13_rejects_duplicate_cdn_provider_ids() {
2579 let authority = AuthorityBundle {
2580 cdn_authority: Some(crate::CdnAuthority {
2581 providers: vec![
2582 crate::CdnProvider {
2583 provider_id: "cloudfront".into(),
2584 hostname_pattern: "a.cdn.example.com".into(),
2585 accept_fronting: false,
2586 },
2587 crate::CdnProvider {
2588 provider_id: "cloudfront".into(),
2589 hostname_pattern: "b.cdn.example.com".into(),
2590 accept_fronting: true,
2591 },
2592 ],
2593 }),
2594 ..AuthorityBundle::default()
2595 };
2596 let err = validate_execution_cell_document(&doc_with_authority(authority)).unwrap_err();
2597 assert!(err.to_string().contains("duplicates value"), "{err}");
2598 }
2599
2600 #[test]
2601 fn t13_dns_resolver_protocol_serialises_kebab_case() {
2602 let resolver = crate::DnsResolver {
2603 resolver_id: "p1".into(),
2604 endpoint: "1.1.1.1:53".into(),
2605 protocol: crate::DnsResolverProtocol::Do53Udp,
2606 trust_kid: None,
2607 dnssec: None,
2608 };
2609 let v = serde_json::to_value(&resolver).unwrap();
2610 assert_eq!(v.get("protocol"), Some(&serde_json::json!("do53-udp")));
2611 }
2612
2613 #[test]
2614 fn t13_dns_authority_roundtrips_through_full_document() {
2615 let raw = r#"{
2616 "apiVersion": "cellos.io/v1",
2617 "kind": "ExecutionCell",
2618 "spec": {
2619 "id": "t13-roundtrip",
2620 "authority": {
2621 "dnsAuthority": {
2622 "resolvers": [{
2623 "resolverId": "internal-doh",
2624 "endpoint": "https://1.1.1.1/dns-query",
2625 "protocol": "doh",
2626 "trustKid": "resolver-kid-2026q2"
2627 }],
2628 "allowedQueryTypes": ["A", "AAAA", "HTTPS"],
2629 "hostnameAllowlist": ["api.example.com", "*.cdn.example.com"],
2630 "refreshPolicy": {
2631 "minTtlSeconds": 30,
2632 "maxStaleSeconds": 300,
2633 "strategy": "ttl-honor"
2634 },
2635 "blockDirectWorkloadDns": true
2636 },
2637 "cdnAuthority": {
2638 "providers": [{
2639 "providerId": "cloudfront",
2640 "hostnamePattern": "*.cdn.example.com",
2641 "acceptFronting": false
2642 }]
2643 }
2644 },
2645 "lifetime": { "ttlSeconds": 60 }
2646 }
2647 }"#;
2648 let doc: ExecutionCellDocument =
2649 serde_json::from_str(raw).expect("parse T13 example document");
2650 validate_execution_cell_document(&doc).expect("validation must pass");
2651
2652 let dns = doc.spec.authority.dns_authority.as_ref().unwrap();
2653 assert_eq!(dns.resolvers.len(), 1);
2654 assert!(matches!(
2655 dns.resolvers[0].protocol,
2656 crate::DnsResolverProtocol::Doh
2657 ));
2658 assert!(dns.block_direct_workload_dns);
2659
2660 let cdn = doc.spec.authority.cdn_authority.as_ref().unwrap();
2661 assert_eq!(cdn.providers.len(), 1);
2662 assert!(!cdn.providers[0].accept_fronting);
2663
2664 let serialised = serde_json::to_string(&doc).expect("re-serialise");
2665 let doc2: ExecutionCellDocument =
2666 serde_json::from_str(&serialised).expect("re-parse roundtrip");
2667 assert_eq!(
2668 doc2.spec.authority.dns_authority,
2669 doc.spec.authority.dns_authority
2670 );
2671 assert_eq!(
2672 doc2.spec.authority.cdn_authority,
2673 doc.spec.authority.cdn_authority
2674 );
2675 }
2676
2677 #[test]
2678 fn t13_is_fqdn_or_wildcard_unit_cases() {
2679 assert!(super::is_fqdn_or_wildcard("api.example.com"));
2680 assert!(super::is_fqdn_or_wildcard("*.example.com"));
2681 assert!(super::is_fqdn_or_wildcard("a.b.c.d.example.com"));
2682 assert!(!super::is_fqdn_or_wildcard(""));
2683 assert!(!super::is_fqdn_or_wildcard("example")); assert!(!super::is_fqdn_or_wildcard("10.0.0.1")); assert!(!super::is_fqdn_or_wildcard("2001:db8::1")); assert!(!super::is_fqdn_or_wildcard("api.*.example.com")); assert!(!super::is_fqdn_or_wildcard("-bad.example.com")); assert!(!super::is_fqdn_or_wildcard("under_score.example.com")); }
2690
2691 fn telemetry_doc(
2694 events: Vec<String>,
2695 agent_version: &str,
2696 egress: Vec<crate::EgressRule>,
2697 ) -> ExecutionCellDocument {
2698 let authority = AuthorityBundle {
2699 egress_rules: if egress.is_empty() {
2700 None
2701 } else {
2702 Some(egress)
2703 },
2704 ..AuthorityBundle::default()
2705 };
2706 ExecutionCellDocument {
2707 api_version: "cellos.io/v1".into(),
2708 kind: "ExecutionCell".into(),
2709 spec: ExecutionCellSpec {
2710 id: "telemetry-test-cell".into(),
2711 correlation: None,
2712 ingress: None,
2713 environment: None,
2714 placement: None,
2715 policy: None,
2716 identity: None,
2717 run: None,
2718 authority,
2719 lifetime: Lifetime { ttl_seconds: 60 },
2720 export: None,
2721 telemetry: Some(crate::TelemetrySpec {
2722 channel: crate::TelemetryChannel::VsockCbor,
2723 events,
2724 rate_limits: None,
2725 host_vs_guest_fields: None,
2726 agent_version: agent_version.into(),
2727 }),
2728 },
2729 }
2730 }
2731
2732 #[test]
2733 fn telemetry_rejects_empty_events() {
2734 let doc = telemetry_doc(vec![], "1.0.0", vec![]);
2735 let err = validate_execution_cell_document(&doc).unwrap_err();
2736 assert!(
2737 err.to_string().contains("spec.telemetry.events"),
2738 "got: {err}"
2739 );
2740 }
2741
2742 #[test]
2743 fn telemetry_rejects_bad_semver() {
2744 let doc = telemetry_doc(vec!["process.spawn".into()], "v1", vec![]);
2745 let err = validate_execution_cell_document(&doc).unwrap_err();
2746 assert!(err.to_string().contains("agentVersion"), "got: {err}");
2747 }
2748
2749 #[test]
2750 fn telemetry_rejects_net_event_without_egress() {
2751 let doc = telemetry_doc(vec!["net.connect.attempt".into()], "1.0.0", vec![]);
2752 let err = validate_execution_cell_document(&doc).unwrap_err();
2753 let msg = err.to_string();
2754 assert!(msg.contains("telemetry_without_egress"), "got: {msg}");
2755 }
2756
2757 #[test]
2758 fn telemetry_accepts_net_event_when_egress_declared() {
2759 let doc = telemetry_doc(
2760 vec!["net.connect.attempt".into()],
2761 "1.0.0",
2762 vec![crate::EgressRule {
2763 host: "api.example.com".into(),
2764 port: 443,
2765 protocol: Some("https".into()),
2766 dns_egress_justification: None,
2767 }],
2768 );
2769 validate_execution_cell_document(&doc).expect("valid telemetry+egress spec");
2770 }
2771
2772 #[test]
2773 fn telemetry_accepts_non_net_event_without_egress() {
2774 let doc = telemetry_doc(vec!["process.spawn".into()], "1.0.0", vec![]);
2775 validate_execution_cell_document(&doc).expect("non-net.* event must not require egress");
2776 }
2777
2778 #[test]
2779 fn telemetry_accepts_prerelease_semver() {
2780 let doc = telemetry_doc(vec!["process.spawn".into()], "1.2.3-rc.1", vec![]);
2781 validate_execution_cell_document(&doc).expect("prerelease semver accepted");
2782 }
2783}
2784
2785#[cfg(test)]
2786mod sec25_envelope_tests {
2787 use super::{
2794 sha256_hex_prefixed, verify_signed_trust_keyset_chain, verify_signed_trust_keyset_envelope,
2795 };
2796 use crate::types::{SignedTrustKeysetEnvelope, TrustKeysetSignature};
2797 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2798 use base64::Engine as _;
2799 use ed25519_dalek::{Signer as _, SigningKey, VerifyingKey};
2800 use std::collections::HashMap;
2801 use std::time::{Duration, SystemTime, UNIX_EPOCH};
2802
2803 fn signing_key(seed: u8) -> SigningKey {
2804 SigningKey::from_bytes(&[seed; 32])
2805 }
2806
2807 fn make_envelope(
2810 payload_bytes: &[u8],
2811 signer: &SigningKey,
2812 signer_kid: &str,
2813 not_before: Option<String>,
2814 not_after: Option<String>,
2815 ) -> (SignedTrustKeysetEnvelope, HashMap<String, VerifyingKey>) {
2816 let signature = signer.sign(payload_bytes);
2817 let envelope = SignedTrustKeysetEnvelope {
2818 schema_version: "1.0.0".into(),
2819 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
2820 payload: URL_SAFE_NO_PAD.encode(payload_bytes),
2821 signatures: vec![TrustKeysetSignature {
2822 signer_kid: signer_kid.into(),
2823 algorithm: "ed25519".into(),
2824 signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
2825 not_before,
2826 not_after,
2827 }],
2828 payload_digest: sha256_hex_prefixed(payload_bytes),
2829 produced_at: "2026-05-01T00:00:00Z".into(),
2830 replaces_envelope_digest: None,
2831 required_signer_count: None,
2832 };
2833 let mut keys = HashMap::new();
2834 keys.insert(signer_kid.to_string(), signer.verifying_key());
2835 (envelope, keys)
2836 }
2837
2838 #[test]
2839 fn verifies_well_formed_envelope_with_synthetic_key() {
2840 let signer = signing_key(7);
2841 let payload = br#"{"schemaVersion":"1.0.0","keysetId":"ks-7","keys":[]}"#;
2842 let (env, keys) = make_envelope(payload, &signer, "kid-active-7", None, None);
2843 let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
2844 .expect("envelope should verify");
2845 assert_eq!(returned, payload, "verifier must return raw payload bytes");
2846 }
2847
2848 #[test]
2849 fn rejects_payload_digest_mismatch() {
2850 let signer = signing_key(11);
2851 let payload = b"hello-payload";
2852 let (mut env, keys) = make_envelope(payload, &signer, "kid-active-11", None, None);
2853 let last_idx = env.payload_digest.len() - 1;
2855 let last_char = env.payload_digest.chars().last().unwrap();
2856 let new_char = if last_char == '0' { '1' } else { '0' };
2857 env.payload_digest
2858 .replace_range(last_idx.., &new_char.to_string());
2859
2860 let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
2861 .expect_err("digest mismatch must fail");
2862 assert!(
2863 format!("{err}").contains("payload digest mismatch"),
2864 "expected digest-mismatch error, got: {err}"
2865 );
2866 }
2867
2868 #[test]
2869 fn rejects_unknown_signer_kid() {
2870 let signer = signing_key(13);
2871 let payload = b"unknown-kid-payload";
2872 let (env, _keys) = make_envelope(payload, &signer, "kid-active-13", None, None);
2873 let empty_keys: HashMap<String, VerifyingKey> = HashMap::new();
2874 let err = verify_signed_trust_keyset_envelope(&env, &empty_keys, SystemTime::now())
2875 .expect_err("unknown kid must fail");
2876 assert!(
2877 format!("{err}").contains("no signature verified"),
2878 "expected no-signature-verified error, got: {err}"
2879 );
2880 }
2881
2882 #[test]
2883 fn rejects_signature_outside_not_after_window() {
2884 let signer = signing_key(17);
2885 let payload = b"window-payload";
2886 let (env, keys) = make_envelope(
2888 payload,
2889 &signer,
2890 "kid-active-17",
2891 None,
2892 Some("2000-01-01T00:00:00Z".into()),
2893 );
2894 let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
2895 .expect_err("expired window must fail");
2896 assert!(
2897 format!("{err}").contains("no signature verified"),
2898 "expected no-signature-verified error, got: {err}"
2899 );
2900 }
2901
2902 #[test]
2903 fn rejects_tampered_payload() {
2904 let signer = signing_key(19);
2905 let original = b"original-trust-keyset-payload-bytes";
2906 let (mut env, keys) = make_envelope(original, &signer, "kid-active-19", None, None);
2907
2908 let mut tampered = original.to_vec();
2912 tampered[0] ^= 0x01;
2913 env.payload = URL_SAFE_NO_PAD.encode(&tampered);
2914 env.payload_digest = sha256_hex_prefixed(&tampered);
2915
2916 let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
2917 .expect_err("tampered payload must fail signature check");
2918 assert!(
2919 format!("{err}").contains("no signature verified"),
2920 "expected signature failure, got: {err}"
2921 );
2922 }
2923
2924 #[test]
2925 fn accepts_when_at_least_one_signature_verifies() {
2926 let signer_good = signing_key(23);
2927 let signer_other = signing_key(29);
2928 let payload = b"multi-sig-payload";
2929
2930 let bad_sig = signer_other.sign(payload);
2932 let good_sig = signer_good.sign(payload);
2934
2935 let env = SignedTrustKeysetEnvelope {
2936 schema_version: "1.0.0".into(),
2937 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
2938 payload: URL_SAFE_NO_PAD.encode(payload),
2939 signatures: vec![
2940 TrustKeysetSignature {
2941 signer_kid: "kid-unknown-29".into(),
2942 algorithm: "ed25519".into(),
2943 signature: URL_SAFE_NO_PAD.encode(bad_sig.to_bytes()),
2944 not_before: None,
2945 not_after: None,
2946 },
2947 TrustKeysetSignature {
2948 signer_kid: "kid-active-23".into(),
2949 algorithm: "ed25519".into(),
2950 signature: URL_SAFE_NO_PAD.encode(good_sig.to_bytes()),
2951 not_before: None,
2952 not_after: None,
2953 },
2954 ],
2955 payload_digest: sha256_hex_prefixed(payload),
2956 produced_at: "2026-05-01T00:00:00Z".into(),
2957 replaces_envelope_digest: None,
2958 required_signer_count: None,
2959 };
2960
2961 let mut keys = HashMap::new();
2962 keys.insert("kid-active-23".to_string(), signer_good.verifying_key());
2964
2965 let returned = verify_signed_trust_keyset_envelope(
2966 &env,
2967 &keys,
2968 UNIX_EPOCH + Duration::from_secs(1_800_000_000),
2969 )
2970 .expect("at least one signature should verify");
2971 assert_eq!(returned, payload);
2972 }
2973
2974 fn make_multisig_envelope(
2980 payload_bytes: &[u8],
2981 signers: &[(&str, &SigningKey)],
2982 required_signer_count: Option<u32>,
2983 ) -> (SignedTrustKeysetEnvelope, HashMap<String, VerifyingKey>) {
2984 let signatures = signers
2985 .iter()
2986 .map(|(kid, signer)| {
2987 let sig = signer.sign(payload_bytes);
2988 TrustKeysetSignature {
2989 signer_kid: (*kid).to_string(),
2990 algorithm: "ed25519".into(),
2991 signature: URL_SAFE_NO_PAD.encode(sig.to_bytes()),
2992 not_before: None,
2993 not_after: None,
2994 }
2995 })
2996 .collect();
2997
2998 let envelope = SignedTrustKeysetEnvelope {
2999 schema_version: "1.0.0".into(),
3000 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3001 payload: URL_SAFE_NO_PAD.encode(payload_bytes),
3002 signatures,
3003 payload_digest: sha256_hex_prefixed(payload_bytes),
3004 produced_at: "2026-05-01T00:00:00Z".into(),
3005 replaces_envelope_digest: None,
3006 required_signer_count,
3007 };
3008 let mut keys = HashMap::new();
3009 for (kid, signer) in signers {
3010 keys.insert((*kid).to_string(), signer.verifying_key());
3011 }
3012 (envelope, keys)
3013 }
3014
3015 #[test]
3016 fn verifies_with_two_distinct_signers_when_threshold_2() {
3017 let signer_a = signing_key(41);
3018 let signer_b = signing_key(43);
3019 let payload = b"phase3-multisig-payload-2of2";
3020
3021 let (env, keys) = make_multisig_envelope(
3022 payload,
3023 &[("kid-ops-a", &signer_a), ("kid-ops-b", &signer_b)],
3024 Some(2),
3025 );
3026
3027 let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3028 .expect("two distinct signers + threshold 2 should verify");
3029 assert_eq!(returned, payload);
3030 }
3031
3032 #[test]
3033 fn rejects_when_threshold_2_but_only_one_verifies() {
3034 let signer_a = signing_key(47);
3035 let signer_b = signing_key(53);
3036 let payload = b"phase3-only-one-verifies";
3037
3038 let (env, _full_keys) = make_multisig_envelope(
3042 payload,
3043 &[("kid-ops-a", &signer_a), ("kid-ops-b", &signer_b)],
3044 Some(2),
3045 );
3046 let mut sparse_keys: HashMap<String, VerifyingKey> = HashMap::new();
3047 sparse_keys.insert("kid-ops-a".into(), signer_a.verifying_key());
3048
3049 let err = verify_signed_trust_keyset_envelope(&env, &sparse_keys, SystemTime::now())
3050 .expect_err("threshold 2 with one verifier present must fail");
3051 let msg = format!("{err}");
3052 assert!(
3053 msg.contains("only 1 distinct signers verified, need 2"),
3054 "expected threshold-shortfall error, got: {msg}"
3055 );
3056 }
3057
3058 #[test]
3059 fn counts_distinct_kids_only_duplicate_kid_does_not_inflate() {
3060 let signer = signing_key(59);
3063 let payload = b"phase3-duplicate-kid-payload";
3064 let sig_a = signer.sign(payload);
3065 let sig_b = signer.sign(payload);
3066
3067 let env = SignedTrustKeysetEnvelope {
3068 schema_version: "1.0.0".into(),
3069 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3070 payload: URL_SAFE_NO_PAD.encode(payload),
3071 signatures: vec![
3072 TrustKeysetSignature {
3073 signer_kid: "kid-ops-only".into(),
3074 algorithm: "ed25519".into(),
3075 signature: URL_SAFE_NO_PAD.encode(sig_a.to_bytes()),
3076 not_before: None,
3077 not_after: None,
3078 },
3079 TrustKeysetSignature {
3080 signer_kid: "kid-ops-only".into(),
3081 algorithm: "ed25519".into(),
3082 signature: URL_SAFE_NO_PAD.encode(sig_b.to_bytes()),
3083 not_before: None,
3084 not_after: None,
3085 },
3086 ],
3087 payload_digest: sha256_hex_prefixed(payload),
3088 produced_at: "2026-05-01T00:00:00Z".into(),
3089 replaces_envelope_digest: None,
3090 required_signer_count: Some(2),
3091 };
3092 let mut keys = HashMap::new();
3093 keys.insert("kid-ops-only".into(), signer.verifying_key());
3094
3095 let err = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3096 .expect_err("duplicate kid must not satisfy threshold 2");
3097 let msg = format!("{err}");
3098 assert!(
3099 msg.contains("only 1 distinct signers verified, need 2"),
3100 "expected duplicate-kid threshold failure, got: {msg}"
3101 );
3102 }
3103
3104 #[test]
3105 fn default_threshold_1_preserves_phase1_behavior() {
3106 let signer = signing_key(61);
3109 let payload = b"phase1-default-threshold";
3110
3111 for explicit in [None, Some(1)] {
3112 let (env, keys) =
3113 make_multisig_envelope(payload, &[("kid-default-61", &signer)], explicit);
3114 let returned = verify_signed_trust_keyset_envelope(&env, &keys, SystemTime::now())
3115 .expect("default / explicit-1 threshold should verify");
3116 assert_eq!(
3117 returned, payload,
3118 "raw payload bytes returned (explicit={explicit:?})"
3119 );
3120 }
3121 }
3122
3123 #[test]
3124 fn rejects_when_required_zero_treated_as_one() {
3125 let signer = signing_key(67);
3131 let payload = b"phase3-zero-clamp-payload";
3132 let (env, _keys) = make_multisig_envelope(
3133 payload,
3134 &[("kid-active-67", &signer)],
3135 Some(0), );
3137 let empty: HashMap<String, VerifyingKey> = HashMap::new();
3140 let err = verify_signed_trust_keyset_envelope(&env, &empty, SystemTime::now())
3141 .expect_err("zero threshold must clamp to 1");
3142 let msg = format!("{err}");
3143 assert!(
3144 msg.contains("no signature verified"),
3145 "expected legacy single-signer error after clamping to 1, got: {msg}"
3146 );
3147 }
3148
3149 fn make_chain_envelope(
3151 payload_bytes: &[u8],
3152 signer: &SigningKey,
3153 signer_kid: &str,
3154 replaces: Option<String>,
3155 ) -> SignedTrustKeysetEnvelope {
3156 let signature = signer.sign(payload_bytes);
3157 SignedTrustKeysetEnvelope {
3158 schema_version: "1.0.0".into(),
3159 payload_type: "application/vnd.cellos.trust-keyset-v1+json".into(),
3160 payload: URL_SAFE_NO_PAD.encode(payload_bytes),
3161 signatures: vec![TrustKeysetSignature {
3162 signer_kid: signer_kid.into(),
3163 algorithm: "ed25519".into(),
3164 signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
3165 not_before: None,
3166 not_after: None,
3167 }],
3168 payload_digest: sha256_hex_prefixed(payload_bytes),
3169 produced_at: "2026-05-01T00:00:00Z".into(),
3170 replaces_envelope_digest: replaces,
3171 required_signer_count: None,
3172 }
3173 }
3174
3175 #[test]
3176 fn chain_with_correct_replaces_envelope_digest_succeeds() {
3177 let signer = signing_key(71);
3178 let payload_old = b"phase3-chain-old-payload";
3179 let payload_new = b"phase3-chain-new-payload";
3180
3181 let prev = make_chain_envelope(payload_old, &signer, "kid-active-71", None);
3182 let next = make_chain_envelope(
3183 payload_new,
3184 &signer,
3185 "kid-active-71",
3186 Some(sha256_hex_prefixed(payload_old)),
3187 );
3188
3189 let mut keys = HashMap::new();
3190 keys.insert("kid-active-71".into(), signer.verifying_key());
3191
3192 let head_payload =
3193 verify_signed_trust_keyset_chain(&[prev, next], &keys, SystemTime::now())
3194 .expect("correctly-chained envelopes should verify");
3195 assert_eq!(head_payload, payload_new);
3196 }
3197
3198 #[test]
3199 fn chain_with_mismatched_replaces_envelope_digest_rejected() {
3200 let signer = signing_key(73);
3201 let payload_old = b"phase3-chain-mismatch-old";
3202 let payload_new = b"phase3-chain-mismatch-new";
3203
3204 let prev = make_chain_envelope(payload_old, &signer, "kid-active-73", None);
3205 let bogus_digest = sha256_hex_prefixed(b"this-is-not-the-prior-payload");
3208 let next = make_chain_envelope(payload_new, &signer, "kid-active-73", Some(bogus_digest));
3209
3210 let mut keys = HashMap::new();
3211 keys.insert("kid-active-73".into(), signer.verifying_key());
3212
3213 let err = verify_signed_trust_keyset_chain(&[prev, next], &keys, SystemTime::now())
3214 .expect_err("mismatched replacesEnvelopeDigest must fail");
3215 let msg = format!("{err}");
3216 assert!(
3217 msg.contains("replacesEnvelopeDigest mismatch"),
3218 "expected chain link mismatch error, got: {msg}"
3219 );
3220 assert!(
3221 msg.contains("index 1"),
3222 "expected index 1 (the bad link) in error, got: {msg}"
3223 );
3224 }
3225
3226 #[test]
3227 fn chain_first_envelope_without_replaces_accepted_as_genesis() {
3228 let signer = signing_key(79);
3232 let payload = b"phase3-chain-genesis-only";
3233
3234 let genesis = make_chain_envelope(payload, &signer, "kid-active-79", None);
3235 let mut keys = HashMap::new();
3236 keys.insert("kid-active-79".into(), signer.verifying_key());
3237
3238 let head_payload = verify_signed_trust_keyset_chain(&[genesis], &keys, SystemTime::now())
3239 .expect("genesis-only chain should verify");
3240 assert_eq!(head_payload, payload);
3241 }
3242
3243 #[test]
3244 fn chain_returns_head_payload_bytes() {
3245 let signer = signing_key(83);
3248 let p0 = b"phase3-head-bytes-genesis";
3249 let p1 = b"phase3-head-bytes-middle";
3250 let p2 = b"phase3-head-bytes-HEAD-distinct";
3251
3252 let env0 = make_chain_envelope(p0, &signer, "kid-active-83", None);
3253 let env1 = make_chain_envelope(p1, &signer, "kid-active-83", Some(sha256_hex_prefixed(p0)));
3254 let env2 = make_chain_envelope(p2, &signer, "kid-active-83", Some(sha256_hex_prefixed(p1)));
3255
3256 let mut keys = HashMap::new();
3257 keys.insert("kid-active-83".into(), signer.verifying_key());
3258
3259 let head_payload =
3260 verify_signed_trust_keyset_chain(&[env0, env1, env2], &keys, SystemTime::now())
3261 .expect("3-envelope chain should verify");
3262 assert_eq!(
3263 head_payload, p2,
3264 "verifier must return HEAD payload, not earlier links"
3265 );
3266 }
3267
3268 #[test]
3269 fn chain_empty_rejected() {
3270 let keys: HashMap<String, VerifyingKey> = HashMap::new();
3271 let err = verify_signed_trust_keyset_chain(&[], &keys, SystemTime::now())
3272 .expect_err("empty chain must be rejected");
3273 let msg = format!("{err}");
3274 assert!(
3275 msg.contains("empty chain"),
3276 "expected empty-chain error, got: {msg}"
3277 );
3278 }
3279
3280 #[test]
3281 fn chain_propagates_threshold_failure_per_envelope() {
3282 let signer_a = signing_key(89);
3288 let signer_b = signing_key(97);
3289 let p0 = b"phase3-threshold-prop-genesis";
3290 let p1 = b"phase3-threshold-prop-head";
3291
3292 let env0 = make_chain_envelope(p0, &signer_a, "kid-ops-a", None);
3293 let mut env1 =
3296 make_chain_envelope(p1, &signer_a, "kid-ops-a", Some(sha256_hex_prefixed(p0)));
3297 env1.required_signer_count = Some(2);
3298
3299 let mut keys = HashMap::new();
3300 keys.insert("kid-ops-a".into(), signer_a.verifying_key());
3301 keys.insert("kid-ops-b".into(), signer_b.verifying_key());
3302
3303 let err = verify_signed_trust_keyset_chain(&[env0, env1], &keys, SystemTime::now())
3304 .expect_err("env1 threshold-2 with one signer must fail");
3305 let msg = format!("{err}");
3306 assert!(
3307 msg.contains("envelope at index 1 failed verification"),
3308 "expected indexed envelope failure, got: {msg}"
3309 );
3310 assert!(
3311 msg.contains("only 1 distinct signers verified, need 2"),
3312 "expected per-envelope threshold message in chain error, got: {msg}"
3313 );
3314 }
3315}