1use serde::{Deserialize, Serialize};
13
14pub const TDF_SPEC_VERSION: &str = "4.3.0";
16
17#[derive(Debug, Serialize, Deserialize)]
19pub struct TdfManifest {
20 pub payload: Payload,
21 #[serde(rename = "encryptionInformation")]
22 pub encryption_information: EncryptionInformation,
23 #[serde(rename = "schemaVersion", skip_serializing_if = "Option::is_none")]
24 pub schema_version: Option<String>,
25 #[serde(rename = "tdf_spec_version", skip_serializing_if = "Option::is_none")]
28 pub tdf_spec_version: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub gguf: Option<crate::GgufIndex>,
32 #[serde(
36 default,
37 deserialize_with = "null_as_empty_vec",
38 skip_serializing_if = "Vec::is_empty"
39 )]
40 pub assertions: Vec<Assertion>,
41}
42
43fn null_as_empty_vec<'de, D>(deserializer: D) -> Result<Vec<Assertion>, D::Error>
46where
47 D: serde::Deserializer<'de>,
48{
49 Ok(Option::<Vec<Assertion>>::deserialize(deserializer)?.unwrap_or_default())
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct Assertion {
58 pub id: String,
60 #[serde(rename = "type")]
62 pub assertion_type: String,
63 pub scope: String,
65 #[serde(rename = "appliesToState", skip_serializing_if = "Option::is_none")]
68 pub applies_to_state: Option<String>,
69 pub statement: AssertionStatement,
71 #[serde(skip_serializing_if = "Option::is_none")]
74 pub binding: Option<AssertionBinding>,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct AssertionStatement {
80 #[serde(default, skip_serializing_if = "String::is_empty")]
82 pub format: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub schema: Option<String>,
86 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
90 pub value: serde_json::Value,
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct AssertionBinding {
100 #[serde(default, skip_serializing_if = "String::is_empty")]
102 pub method: String,
103 #[serde(default, skip_serializing_if = "String::is_empty")]
105 pub signature: String,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Payload {
111 #[serde(rename = "type")]
112 pub payload_type: String,
113 pub url: String,
114 pub protocol: String,
115 #[serde(rename = "isEncrypted")]
116 pub is_encrypted: bool,
117 #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
118 pub mime_type: Option<String>,
119 #[serde(rename = "tdf_spec_version", skip_serializing_if = "Option::is_none")]
120 pub tdf_spec_version: Option<String>,
121}
122
123impl Default for Payload {
124 fn default() -> Self {
125 Self {
126 payload_type: "reference".to_string(),
127 url: "0.payload".to_string(),
128 protocol: "zip".to_string(),
129 is_encrypted: true,
130 mime_type: Some("application/octet-stream".to_string()),
131 tdf_spec_version: None,
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct EncryptionInformation {
142 #[serde(rename = "type")]
143 pub encryption_type: String,
144 #[serde(rename = "keyAccess")]
145 pub key_access: Vec<KeyAccess>,
146 pub method: EncryptionMethod,
147 #[serde(rename = "integrityInformation")]
148 pub integrity_information: IntegrityInformation,
149 pub policy: String,
150}
151
152#[derive(Debug, Serialize, Deserialize, Clone)]
154pub struct PolicyBinding {
155 pub alg: String,
156 pub hash: String,
157}
158
159const POLICY_BINDING_ALG_HS256: &str = "HS256";
162
163const HMAC_SHA256_LEN: usize = 32;
165
166#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
168#[non_exhaustive]
169pub enum PolicyBindingError {
170 #[error("unrecognized policy binding algorithm {alg:?}, expected \"HS256\"")]
172 UnsupportedAlgorithm {
173 alg: String,
175 },
176 #[error("policy binding hash is empty")]
178 EmptyHash,
179 #[error("policy binding hash is not valid base64: {0}")]
181 InvalidBase64(#[from] base64::DecodeError),
182 #[error(
185 "policy binding hash decodes to {actual} bytes, expected 32 (HMAC-SHA256) or 64 (hex of HMAC-SHA256)"
186 )]
187 InvalidHashLength {
188 actual: usize,
190 },
191 #[error("policy binding hash decodes to 64 bytes that are not hexadecimal digits")]
194 InvalidHexHash,
195}
196
197impl PolicyBinding {
198 pub fn validate(&self) -> Result<(), PolicyBindingError> {
238 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
239
240 if self.alg != POLICY_BINDING_ALG_HS256 {
241 return Err(PolicyBindingError::UnsupportedAlgorithm {
242 alg: self.alg.clone(),
243 });
244 }
245 if self.hash.is_empty() {
246 return Err(PolicyBindingError::EmptyHash);
247 }
248
249 let decoded = BASE64.decode(&self.hash)?;
250 match decoded.len() {
251 HMAC_SHA256_LEN => Ok(()),
252 len if len == HMAC_SHA256_LEN * 2 => {
253 if decoded.iter().all(u8::is_ascii_hexdigit) {
254 Ok(())
255 } else {
256 Err(PolicyBindingError::InvalidHexHash)
257 }
258 }
259 actual => Err(PolicyBindingError::InvalidHashLength { actual }),
260 }
261 }
262}
263
264pub mod key_access_type {
266 pub const WRAPPED: &str = "wrapped";
268 pub const EC_WRAPPED: &str = "ec-wrapped";
270 pub const REMOTE: &str = "remote";
272 pub const REMOTE_WRAPPED: &str = "remoteWrapped";
274}
275
276#[derive(Debug, Serialize, Deserialize, Clone)]
278pub struct KeyAccess {
279 #[serde(rename = "type")]
280 pub access_type: String,
281 pub url: String,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub kid: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub sid: Option<String>,
287 pub protocol: String,
288 #[serde(rename = "wrappedKey")]
289 pub wrapped_key: String,
290 #[serde(rename = "policyBinding")]
291 pub policy_binding: PolicyBinding,
292 #[serde(rename = "encryptedMetadata", skip_serializing_if = "Option::is_none")]
293 pub encrypted_metadata: Option<String>,
294 #[serde(rename = "schemaVersion", skip_serializing_if = "Option::is_none")]
295 pub schema_version: Option<String>,
296 #[serde(rename = "ephemeralPublicKey", skip_serializing_if = "Option::is_none")]
298 pub ephemeral_public_key: Option<String>,
299}
300
301impl KeyAccess {
302 pub fn new(url: String) -> Self {
304 KeyAccess {
305 access_type: key_access_type::WRAPPED.to_string(),
306 url,
307 kid: None,
308 sid: None,
309 protocol: "kas".to_string(),
310 wrapped_key: String::new(),
311 policy_binding: PolicyBinding {
312 alg: "HS256".to_string(),
313 hash: String::new(),
314 },
315 encrypted_metadata: None,
316 schema_version: Some("1.0".to_string()),
317 ephemeral_public_key: None,
318 }
319 }
320
321 pub fn new_ec_wrapped(url: String) -> Self {
323 KeyAccess {
324 access_type: key_access_type::EC_WRAPPED.to_string(),
325 url,
326 kid: None,
327 sid: None,
328 protocol: "kas".to_string(),
329 wrapped_key: String::new(),
330 policy_binding: PolicyBinding {
331 alg: "HS256".to_string(),
332 hash: String::new(),
333 },
334 encrypted_metadata: None,
335 schema_version: Some("1.0".to_string()),
336 ephemeral_public_key: None,
337 }
338 }
339
340 pub fn is_ec_wrapped(&self) -> bool {
342 self.access_type == key_access_type::EC_WRAPPED
343 }
344
345 pub fn is_rsa_wrapped(&self) -> bool {
347 self.access_type == key_access_type::WRAPPED
348 }
349
350 pub fn set_ephemeral_public_key(&mut self, pem: String) {
352 self.ephemeral_public_key = Some(pem);
353 }
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct EncryptionMethod {
359 pub algorithm: String,
360 #[serde(rename = "isStreamable", default)]
362 pub is_streamable: bool,
363 #[serde(default)]
367 pub iv: String,
368}
369
370impl Default for EncryptionMethod {
371 fn default() -> Self {
372 Self {
373 algorithm: "AES-256-GCM".to_string(),
374 is_streamable: true,
375 iv: String::new(),
376 }
377 }
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct IntegrityInformation {
383 #[serde(rename = "rootSignature")]
384 pub root_signature: RootSignature,
385 #[serde(rename = "segmentHashAlg")]
386 pub segment_hash_alg: String,
387 pub segments: Vec<Segment>,
388 #[serde(rename = "segmentSizeDefault")]
389 pub segment_size_default: u64,
390 #[serde(rename = "encryptedSegmentSizeDefault")]
391 pub encrypted_segment_size_default: u64,
392}
393
394impl Default for IntegrityInformation {
395 fn default() -> Self {
396 Self {
397 root_signature: RootSignature::default(),
398 segment_hash_alg: "GMAC".to_string(),
399 segments: Vec::new(),
400 segment_size_default: 1024 * 1024, encrypted_segment_size_default: 1024 * 1024 + 28, }
403 }
404}
405
406impl IntegrityInformation {
407 pub fn segment_sizes(&self) -> Vec<(u64, u64)> {
413 self.segments
414 .iter()
415 .map(|s| {
416 (
417 s.segment_size.unwrap_or(self.segment_size_default),
418 s.encrypted_segment_size
419 .unwrap_or(self.encrypted_segment_size_default),
420 )
421 })
422 .collect()
423 }
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct RootSignature {
429 pub alg: String,
430 pub sig: String,
431}
432
433impl Default for RootSignature {
434 fn default() -> Self {
435 Self {
436 alg: "HS256".to_string(),
437 sig: String::new(),
438 }
439 }
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct Segment {
445 pub hash: String,
446 #[serde(rename = "segmentSize", skip_serializing_if = "Option::is_none")]
447 pub segment_size: Option<u64>,
448 #[serde(
449 rename = "encryptedSegmentSize",
450 skip_serializing_if = "Option::is_none"
451 )]
452 pub encrypted_segment_size: Option<u64>,
453}
454
455impl TdfManifest {
456 pub fn new(payload_url: String, kas_url: String) -> Self {
458 TdfManifest {
459 payload: Payload {
460 payload_type: "reference".to_string(),
461 url: payload_url,
462 protocol: "zip".to_string(),
463 is_encrypted: true,
464 mime_type: Some("application/octet-stream".to_string()),
465 tdf_spec_version: None,
467 },
468 encryption_information: EncryptionInformation {
469 encryption_type: "split".to_string(),
470 key_access: vec![KeyAccess::new(kas_url)],
471 method: EncryptionMethod {
472 algorithm: "AES-256-GCM".to_string(),
473 is_streamable: true,
474 iv: String::new(),
475 },
476 integrity_information: IntegrityInformation {
477 root_signature: RootSignature {
478 alg: "HS256".to_string(),
479 sig: String::new(),
480 },
481 segment_hash_alg: "GMAC".to_string(),
482 segments: Vec::new(),
483 segment_size_default: 1024 * 1024, encrypted_segment_size_default: 1024 * 1024 + 28, },
486 policy: String::new(),
487 },
488 schema_version: Some(TDF_SPEC_VERSION.to_string()),
489 tdf_spec_version: None,
490 gguf: None,
491 assertions: Vec::new(),
492 }
493 }
494
495 pub fn spec_version(&self) -> Option<&str> {
499 [
500 self.schema_version.as_deref(),
501 self.tdf_spec_version.as_deref(),
502 self.payload.tdf_spec_version.as_deref(),
503 ]
504 .into_iter()
505 .flatten()
506 .find(|v| !v.is_empty())
507 }
508
509 pub fn set_policy_raw(&mut self, policy: &str) {
511 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
512 self.encryption_information.policy = BASE64.encode(policy);
513 }
514
515 pub fn get_policy_raw(&self) -> Result<String, base64::DecodeError> {
517 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
518 let bytes = BASE64.decode(&self.encryption_information.policy)?;
519 String::from_utf8(bytes)
520 .map_err(|err| base64::DecodeError::InvalidByte(err.utf8_error().valid_up_to(), 0))
521 }
522
523 pub fn add_segment(
525 &mut self,
526 hash: String,
527 segment_size: Option<u64>,
528 encrypted_segment_size: Option<u64>,
529 ) {
530 self.encryption_information
531 .integrity_information
532 .segments
533 .push(Segment {
534 hash,
535 segment_size,
536 encrypted_segment_size,
537 });
538 }
539
540 pub fn to_json(&self) -> Result<String, serde_json::Error> {
542 serde_json::to_string(self)
543 }
544
545 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
547 serde_json::from_str(json)
548 }
549}
550
551impl KeyAccess {
552 pub fn set_encrypted_metadata(&mut self, metadata: &str) {
554 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
555 self.encrypted_metadata = Some(BASE64.encode(metadata));
556 }
557
558 pub fn clear_encrypted_metadata(&mut self) {
560 self.encrypted_metadata = None;
561 }
562}
563
564#[cfg(test)]
565mod gguf_index_tests {
566 use super::*;
567
568 #[test]
569 fn gguf_index_round_trips_and_stays_absent_by_default() {
570 let plain = TdfManifest::new(
572 "0.payload".to_string(),
573 "https://kas.example.com".to_string(),
574 );
575 let value: serde_json::Value = serde_json::from_str(&plain.to_json().unwrap()).unwrap();
576 assert!(
577 value.get("gguf").is_none(),
578 "gguf must be absent when unset: {value}"
579 );
580 assert!(
581 value.get("tdf_spec_version").is_none(),
582 "tdf_spec_version must be absent when unset: {value}"
583 );
584
585 let mut m = TdfManifest::new("header".to_string(), "https://kas.example.com".to_string());
587 m.tdf_spec_version = Some("4.3.0".to_string());
588 m.gguf = Some(crate::GgufIndex {
589 profile: crate::GGUF_TDF_PROFILE_V1.to_string(),
590 alignment: 32,
591 header_bytes: 64,
592 virtual_size: 352,
593 max_segment: 128,
594 tensors: vec![crate::GgufTensor {
595 name: "token_embd.weight".to_string(),
596 offset: 64,
597 size: 256,
598 segments: [1, 3],
599 }],
600 segments: vec![crate::GgufSegment {
601 id: 0,
602 kind: crate::GgufSegmentKind::Header,
603 plain: 64,
604 entry: "header".to_string(),
605 }],
606 });
607
608 let json = m.to_json().unwrap();
609 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
610 assert_eq!(value["tdf_spec_version"], "4.3.0");
611 assert_eq!(value["gguf"]["profile"], "gguf-tdf/1");
612 assert_eq!(value["gguf"]["headerBytes"], 64);
613 assert_eq!(value["gguf"]["maxSegment"], 128);
614 assert_eq!(value["gguf"]["segments"][0]["kind"], "header");
615 assert_eq!(value["gguf"]["tensors"][0]["segments"][1], 3);
616
617 let back = TdfManifest::from_json(&json).unwrap();
618 assert_eq!(back.gguf.as_ref().unwrap().virtual_size, 352);
619 assert_eq!(back.tdf_spec_version.as_deref(), Some("4.3.0"));
620 }
621}
622
623#[cfg(test)]
624mod spec_version_tests {
625 use super::*;
626
627 fn minimal_json(top: &str, payload_extra: &str) -> String {
628 format!(
629 r#"{{
630 "payload": {{"type":"reference","url":"0.payload","protocol":"zip","isEncrypted":true{payload_extra}}},
631 "encryptionInformation": {{
632 "type":"split","keyAccess":[],
633 "method":{{"algorithm":"AES-256-GCM","isStreamable":true,"iv":""}},
634 "integrityInformation":{{"rootSignature":{{"alg":"HS256","sig":""}},"segmentHashAlg":"GMAC","segments":[],"segmentSizeDefault":0,"encryptedSegmentSizeDefault":0}},
635 "policy":""
636 }}{top}
637 }}"#
638 )
639 }
640
641 #[test]
642 fn spec_version_prefers_schema_version() {
643 let m = TdfManifest::from_json(&minimal_json(
644 r#","schemaVersion":"4.3.0","tdf_spec_version":"9.9.9""#,
645 r#","tdf_spec_version":"8.8.8""#,
646 ))
647 .unwrap();
648 assert_eq!(m.spec_version(), Some("4.3.0"));
649 }
650
651 #[test]
652 fn spec_version_then_top_level_tdf_spec_version() {
653 let m = TdfManifest::from_json(&minimal_json(
654 r#","tdf_spec_version":"9.9.9""#,
655 r#","tdf_spec_version":"8.8.8""#,
656 ))
657 .unwrap();
658 assert_eq!(m.spec_version(), Some("9.9.9"));
659 }
660
661 #[test]
662 fn spec_version_then_payload_tdf_spec_version() {
663 let m =
664 TdfManifest::from_json(&minimal_json("", r#","tdf_spec_version":"8.8.8""#)).unwrap();
665 assert_eq!(m.spec_version(), Some("8.8.8"));
666 }
667
668 #[test]
669 fn spec_version_absent_is_none() {
670 let m = TdfManifest::from_json(&minimal_json("", "")).unwrap();
671 assert_eq!(m.spec_version(), None);
672 }
673
674 #[test]
675 fn new_manifest_writes_schema_version_4_3_0_and_no_tdf_spec_version() {
676 let m = TdfManifest::new(
677 "0.payload".to_string(),
678 "https://kas.example.com".to_string(),
679 );
680 let v: serde_json::Value = serde_json::from_str(&m.to_json().unwrap()).unwrap();
681 assert_eq!(v["schemaVersion"], TDF_SPEC_VERSION);
682 assert_eq!(v["schemaVersion"], "4.3.0");
683 assert!(v.get("tdf_spec_version").is_none());
684 assert!(v["payload"].get("tdf_spec_version").is_none());
685 }
686}
687
688#[cfg(test)]
689mod peer_method_tolerance_tests {
690 use super::*;
691
692 #[test]
694 fn method_without_iv_or_is_streamable_parses() {
695 let m: EncryptionMethod = serde_json::from_str(r#"{"algorithm":"AES-256-GCM"}"#).unwrap();
696 assert_eq!(m.algorithm, "AES-256-GCM");
697 assert!(!m.is_streamable);
698 assert_eq!(m.iv, "");
699
700 let m: EncryptionMethod =
701 serde_json::from_str(r#"{"algorithm":"AES-256-GCM","isStreamable":true}"#).unwrap();
702 assert!(m.is_streamable);
703 assert_eq!(m.iv, "");
704 }
705}
706
707#[cfg(test)]
708mod sid_and_assertion_tests {
709 use super::*;
710
711 const MANIFEST_WITH_SID_AND_ASSERTION: &str = r#"{
714 "payload": {"type":"reference","url":"0.payload","protocol":"zip","isEncrypted":true,"mimeType":"text/plain"},
715 "encryptionInformation": {
716 "type":"split",
717 "keyAccess":[{
718 "type":"wrapped",
719 "url":"https://kas.example.com",
720 "kid":"r1",
721 "sid":"split-id-1",
722 "protocol":"kas",
723 "wrappedKey":"AAAA",
724 "policyBinding":{"alg":"HS256","hash":"BBBB"}
725 }],
726 "method":{"algorithm":"AES-256-GCM","isStreamable":true,"iv":""},
727 "integrityInformation":{"rootSignature":{"alg":"HS256","sig":""},"segmentHashAlg":"GMAC","segments":[],"segmentSizeDefault":0,"encryptedSegmentSizeDefault":0},
728 "policy":""
729 },
730 "assertions":[{
731 "id":"nato-label-1",
732 "type":"handling",
733 "scope":"payload",
734 "appliesToState":"encrypted",
735 "statement":{
736 "schema":"urn:nato:stanag:4774:confidentialitymetadatalabel:1:0",
737 "format":"json-structured",
738 "value":{
739 "Xmlns":"urn:nato:stanag:4774:confidentialitymetadatalabel:1:0",
740 "CreationTime":"2015-08-29T16:15:00Z",
741 "ConfidentialityInformation":{"PolicyIdentifier":"NATO","Classification":"SECRET","nested":[1,true,null,{"k":"v"}]}
742 }
743 },
744 "binding":{"method":"jws","signature":"eyJhbGciOiJSUzI1NiJ9.e30.sig"}
745 }],
746 "schemaVersion":"4.3.0"
747 }"#;
748
749 #[test]
750 fn manifest_round_trips_key_access_sid_and_assertions() {
751 let input: serde_json::Value =
752 serde_json::from_str(MANIFEST_WITH_SID_AND_ASSERTION).unwrap();
753
754 let m = TdfManifest::from_json(MANIFEST_WITH_SID_AND_ASSERTION).unwrap();
755
756 assert_eq!(
758 m.encryption_information.key_access[0].sid.as_deref(),
759 Some("split-id-1")
760 );
761 assert_eq!(m.assertions.len(), 1);
762 let a = &m.assertions[0];
763 assert_eq!(a.id, "nato-label-1");
764 assert_eq!(a.assertion_type, "handling");
765 assert_eq!(a.scope, "payload");
766 assert_eq!(a.applies_to_state.as_deref(), Some("encrypted"));
767 assert_eq!(a.statement.format, "json-structured");
768 assert_eq!(
769 a.statement.schema.as_deref(),
770 Some("urn:nato:stanag:4774:confidentialitymetadatalabel:1:0")
771 );
772 assert!(
773 a.statement.value.is_object(),
774 "statement.value must stay structured JSON: {:?}",
775 a.statement.value
776 );
777 let b = a.binding.as_ref().expect("binding present");
778 assert_eq!(b.method, "jws");
779 assert_eq!(b.signature, "eyJhbGciOiJSUzI1NiJ9.e30.sig");
780
781 let output: serde_json::Value = serde_json::from_str(&m.to_json().unwrap()).unwrap();
783 assert_eq!(output, input);
784 }
785
786 #[test]
787 fn manifest_with_string_statement_value_round_trips() {
788 let json = MANIFEST_WITH_SID_AND_ASSERTION
789 .replace(r#""format":"json-structured","#, r#""format":"string","#);
790 let mut input: serde_json::Value = serde_json::from_str(&json).unwrap();
792 input["assertions"][0]["statement"]["value"] =
793 serde_json::Value::String("plain text".into());
794
795 let m: TdfManifest = serde_json::from_value(input.clone()).unwrap();
796 assert_eq!(
797 m.assertions[0].statement.value,
798 serde_json::json!("plain text")
799 );
800
801 let output = serde_json::to_value(&m).unwrap();
802 assert_eq!(output, input);
803 }
804
805 #[test]
806 fn manifest_without_sid_or_assertions_serializes_unchanged() {
807 let m = TdfManifest::new(
809 "0.payload".to_string(),
810 "https://kas.example.com".to_string(),
811 );
812 assert!(m.encryption_information.key_access[0].sid.is_none());
813 assert!(m.assertions.is_empty());
814
815 let v: serde_json::Value = serde_json::from_str(&m.to_json().unwrap()).unwrap();
816 assert!(v.get("assertions").is_none(), "{v}");
817 assert!(
818 v["encryptionInformation"]["keyAccess"][0]
819 .get("sid")
820 .is_none(),
821 "{v}"
822 );
823
824 let json = MANIFEST_WITH_SID_AND_ASSERTION.replace(r#""sid":"split-id-1","#, "");
826 let mut without: serde_json::Value = serde_json::from_str(&json).unwrap();
827 without.as_object_mut().unwrap().remove("assertions");
828 let m: TdfManifest = serde_json::from_value(without).unwrap();
829 assert!(m.assertions.is_empty());
830 assert!(m.encryption_information.key_access[0].sid.is_none());
831 }
832
833 #[test]
836 fn assertions_null_parses_as_empty() {
837 let json = r#"{
838 "payload": {"type":"reference","url":"0.payload","protocol":"zip","isEncrypted":true},
839 "encryptionInformation": {
840 "type":"split",
841 "keyAccess":[],
842 "method":{"algorithm":"AES-256-GCM","isStreamable":true,"iv":""},
843 "integrityInformation":{"rootSignature":{"alg":"HS256","sig":""},"segmentHashAlg":"GMAC","segments":[],"segmentSizeDefault":0,"encryptedSegmentSizeDefault":0},
844 "policy":""
845 },
846 "assertions": null
847 }"#;
848 let m = TdfManifest::from_json(json).expect("null assertions must parse");
849 assert!(m.assertions.is_empty());
850 }
851
852 #[test]
855 fn unsigned_assertion_with_empty_binding_object_parses_and_round_trips() {
856 let json = r#"{"id":"a1","type":"handling","scope":"payload","statement":{"format":"string","value":"x"},"binding":{}}"#;
857 let a: Assertion = serde_json::from_str(json).expect("empty binding object must parse");
858 let binding = a.binding.as_ref().expect("binding object is present");
859 assert_eq!(binding.method, "");
860 assert_eq!(binding.signature, "");
861
862 let out = serde_json::to_value(&a).unwrap();
863 assert_eq!(
864 out,
865 serde_json::from_str::<serde_json::Value>(json).unwrap()
866 );
867 }
868}
869
870#[cfg(test)]
871mod policy_binding_validate_tests {
872 use super::*;
873 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
874
875 const SPEC_HASH: &str = "03yakXSzXkEvVt/Om8VVgFBWI3a+vYCxqtK4Ki80sbo=";
878
879 fn binding(alg: &str, hash: &str) -> PolicyBinding {
880 PolicyBinding {
881 alg: alg.to_string(),
882 hash: hash.to_string(),
883 }
884 }
885
886 fn spec_hash_hex() -> String {
888 BASE64
889 .decode(SPEC_HASH)
890 .unwrap()
891 .iter()
892 .map(|b| format!("{b:02x}"))
893 .collect()
894 }
895
896 #[test]
897 fn accepts_spec_base64_of_raw_hmac() {
898 assert_eq!(SPEC_HASH.len(), 44);
899 assert_eq!(binding("HS256", SPEC_HASH).validate(), Ok(()));
900 assert_eq!(
902 binding("HS256", &BASE64.encode([0u8; 32])).validate(),
903 Ok(())
904 );
905 }
906
907 #[test]
908 fn accepts_legacy_base64_of_hex_hmac() {
909 let hex = spec_hash_hex();
910 let legacy = BASE64.encode(&hex);
911 assert_eq!(legacy.len(), 88);
912 assert_eq!(binding("HS256", &legacy).validate(), Ok(()));
913
914 let upper = BASE64.encode(hex.to_uppercase());
916 assert_eq!(binding("HS256", &upper).validate(), Ok(()));
917 }
918
919 #[test]
920 fn rejects_unrecognized_algorithm() {
921 for alg in ["", "hs256", "HS384", "GMAC", "ES256", " HS256"] {
922 assert_eq!(
923 binding(alg, SPEC_HASH).validate(),
924 Err(PolicyBindingError::UnsupportedAlgorithm {
925 alg: alg.to_string()
926 }),
927 "alg {alg:?}"
928 );
929 }
930 }
931
932 #[test]
933 fn rejects_empty_hash() {
934 assert_eq!(
935 binding("HS256", "").validate(),
936 Err(PolicyBindingError::EmptyHash)
937 );
938 let fresh = KeyAccess::new("https://kas.example.com".to_string());
940 assert_eq!(
941 fresh.policy_binding.validate(),
942 Err(PolicyBindingError::EmptyHash)
943 );
944 }
945
946 #[test]
947 fn rejects_hash_that_is_not_padded_standard_base64() {
948 let unpadded = SPEC_HASH.trim_end_matches('=');
949 let url_safe = SPEC_HASH.replace('/', "_").replace('+', "-");
950 let with_newline = format!("{SPEC_HASH}\n");
951 for hash in ["not base64!!", " ", unpadded, &url_safe, &with_newline] {
952 assert!(
953 matches!(
954 binding("HS256", hash).validate(),
955 Err(PolicyBindingError::InvalidBase64(_))
956 ),
957 "hash {hash:?}"
958 );
959 }
960 }
961
962 #[test]
963 fn rejects_hash_of_implausible_length() {
964 for len in [1usize, 3, 16, 31, 33, 48, 63, 65, 128] {
965 assert_eq!(
966 binding("HS256", &BASE64.encode(vec![b'a'; len])).validate(),
967 Err(PolicyBindingError::InvalidHashLength { actual: len }),
968 "{len} bytes"
969 );
970 }
971 }
972
973 #[test]
974 fn rejects_64_bytes_that_are_not_hex() {
975 let mut hex = spec_hash_hex();
977 hex.replace_range(10..11, "g");
978 assert_eq!(
979 binding("HS256", &BASE64.encode(&hex)).validate(),
980 Err(PolicyBindingError::InvalidHexHash)
981 );
982 assert_eq!(
984 binding("HS256", &BASE64.encode([0xffu8; 64])).validate(),
985 Err(PolicyBindingError::InvalidHexHash)
986 );
987 }
988
989 #[test]
990 fn deserializing_does_not_validate() {
991 let parsed: PolicyBinding = serde_json::from_str(r#"{"alg":"none","hash":"BBBB"}"#)
993 .expect("parsing must not validate");
994 assert_eq!(
995 parsed.validate(),
996 Err(PolicyBindingError::UnsupportedAlgorithm {
997 alg: "none".to_string()
998 })
999 );
1000 }
1001}
1002
1003#[cfg(test)]
1004mod segment_size_fallback_tests {
1005 use super::*;
1006
1007 #[test]
1008 fn segment_sizes_fall_back_to_integrity_defaults_when_omitted() {
1009 let mut info = IntegrityInformation {
1010 segment_size_default: 1000,
1011 encrypted_segment_size_default: 1028,
1012 ..IntegrityInformation::default()
1013 };
1014 info.segments.push(Segment {
1017 hash: "a".into(),
1018 segment_size: None,
1019 encrypted_segment_size: None,
1020 });
1021 info.segments.push(Segment {
1023 hash: "b".into(),
1024 segment_size: Some(7),
1025 encrypted_segment_size: Some(35),
1026 });
1027 info.segments.push(Segment {
1029 hash: "c".into(),
1030 segment_size: Some(1000),
1031 encrypted_segment_size: None,
1032 });
1033
1034 assert_eq!(
1035 info.segment_sizes(),
1036 vec![(1000, 1028), (7, 35), (1000, 1028)]
1037 );
1038 }
1039}