Skip to main content

opentdf_protocol/
manifest.rs

1//! TDF Manifest structures
2//!
3//! This module contains the data structures for TDF manifests, including:
4//! - Payload information
5//! - Encryption configuration
6//! - Key access objects
7//! - Integrity information (segments and root signature)
8//! - Assertions (statement + binding)
9//!
10//! Note: Cryptographic operations (HMAC, policy binding generation) are in the crypto crate.
11
12use serde::{Deserialize, Serialize};
13
14/// The TDF spec version written into `schemaVersion` by every writer.
15pub const TDF_SPEC_VERSION: &str = "4.3.0";
16
17/// TDF manifest structure
18#[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    /// OpenTDF specification version. Omitted by default so existing
26    /// manifests serialize unchanged; the `gguf-tdf/1` writer sets it.
27    #[serde(rename = "tdf_spec_version", skip_serializing_if = "Option::is_none")]
28    pub tdf_spec_version: Option<String>,
29    /// `gguf-tdf/1` hybrid index. Absent for every other TDF profile.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub gguf: Option<crate::GgufIndex>,
32    /// Optional top-level `assertions` array (spec: assertion.md). Empty
33    /// when absent; omitted from JSON when empty so existing manifests
34    /// serialize unchanged.
35    #[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
43/// Accept `"assertions": null` (Jackson-style writers emit it for an empty
44/// list) as an empty vector. `#[serde(default)]` alone covers only a missing key.
45fn 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/// A verifiable statement about the TDF or its payload (spec: assertion.md).
53///
54/// Field names and JSON shape match the OpenTDF spec and the Go SDK
55/// (`sdk/assertion.go`).
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct Assertion {
58    /// Unique identifier for this assertion within the manifest.
59    pub id: String,
60    /// Categorizes the assertion's purpose, e.g. `handling` or `other`.
61    #[serde(rename = "type")]
62    pub assertion_type: String,
63    /// What the assertion applies to: `tdo` or `payload`.
64    pub scope: String,
65    /// Whether the statement applies to `encrypted` or `unencrypted` data.
66    /// Optional in the spec (default `encrypted`); omitted when unset.
67    #[serde(rename = "appliesToState", skip_serializing_if = "Option::is_none")]
68    pub applies_to_state: Option<String>,
69    /// The assertion content (spec: assertion_statement.md).
70    pub statement: AssertionStatement,
71    /// Cryptographic binding of the assertion to this TDF
72    /// (spec: assertion_binding.md). Omitted when unsigned.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub binding: Option<AssertionBinding>,
75}
76
77/// The `statement` object of an assertion (spec: assertion_statement.md).
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct AssertionStatement {
80    /// How `value` is encoded: `json-structured`, `base64binary`, `string`, ...
81    #[serde(default, skip_serializing_if = "String::is_empty")]
82    pub format: String,
83    /// Optional URI identifying the schema of `value`.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub schema: Option<String>,
86    /// The assertion content. Kept as raw JSON (string *or* structured
87    /// object/array/number/bool) so it round-trips byte-for-byte for
88    /// JCS hashing and never gets re-typed.
89    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
90    pub value: serde_json::Value,
91}
92
93/// The `binding` object of an assertion (spec: assertion_binding.md).
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95///
96/// Both fields default to empty so that `"binding": {}`, which the Go SDK
97/// emits for an unsigned assertion (non-pointer struct with `omitempty`
98/// string fields), parses and re-serializes unchanged.
99pub struct AssertionBinding {
100    /// Signature method, e.g. `jws`.
101    #[serde(default, skip_serializing_if = "String::is_empty")]
102    pub method: String,
103    /// The Base64URL-encoded signature (e.g. a JWS compact serialization).
104    #[serde(default, skip_serializing_if = "String::is_empty")]
105    pub signature: String,
106}
107
108/// Payload reference in TDF manifest
109#[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            // Match go/Swift: version lives on root `schemaVersion`, not payload.
132            // Emitting payload.tdf_spec_version breaks strict readers (e.g. otdf-python
133            // ManifestPayload, which has no such field).
134            tdf_spec_version: None,
135        }
136    }
137}
138
139/// Encryption information in TDF manifest
140#[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/// Policy binding structure
153#[derive(Debug, Serialize, Deserialize, Clone)]
154pub struct PolicyBinding {
155    pub alg: String,
156    pub hash: String,
157}
158
159/// HMAC-SHA256: the policy binding algorithm the OpenTDF spec names, and the
160/// one this crate, the Go SDK and the platform KAS implement.
161const POLICY_BINDING_ALG_HS256: &str = "HS256";
162
163/// Length in bytes of an HMAC-SHA256 digest.
164const HMAC_SHA256_LEN: usize = 32;
165
166/// Why [`PolicyBinding::validate`] rejected a policy binding.
167#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
168#[non_exhaustive]
169pub enum PolicyBindingError {
170    /// `alg` is not a recognized policy binding algorithm (only `HS256` is).
171    #[error("unrecognized policy binding algorithm {alg:?}, expected \"HS256\"")]
172    UnsupportedAlgorithm {
173        /// The `alg` value found in the binding.
174        alg: String,
175    },
176    /// `hash` is the empty string.
177    #[error("policy binding hash is empty")]
178    EmptyHash,
179    /// `hash` is not padded standard-alphabet base64.
180    #[error("policy binding hash is not valid base64: {0}")]
181    InvalidBase64(#[from] base64::DecodeError),
182    /// `hash` is base64, but of neither a 32-byte HMAC-SHA256 nor its
183    /// 64-character hex encoding.
184    #[error(
185        "policy binding hash decodes to {actual} bytes, expected 32 (HMAC-SHA256) or 64 (hex of HMAC-SHA256)"
186    )]
187    InvalidHashLength {
188        /// Number of bytes `hash` decoded to.
189        actual: usize,
190    },
191    /// `hash` decodes to 64 bytes that are not all hexadecimal digits, so it
192    /// is not the legacy `Base64(hex(HMAC-SHA256))` form.
193    #[error("policy binding hash decodes to 64 bytes that are not hexadecimal digits")]
194    InvalidHexHash,
195}
196
197impl PolicyBinding {
198    /// Check that the binding is structurally sound, without any key material.
199    ///
200    /// A binding passes when:
201    /// - `alg` is exactly `"HS256"` (HMAC-SHA256), the algorithm the spec names
202    ///   for `keyAccess.policyBinding` and the one this crate and the Go SDK
203    ///   write;
204    /// - `hash` is non-empty, padded standard base64, and decodes to either
205    ///   - 32 bytes: the spec form `Base64(HMAC-SHA256(..))`, 44 characters,
206    ///     which this crate writes; or
207    ///   - 64 hexadecimal digits (either case): the legacy
208    ///     `Base64(hex(HMAC-SHA256(..)))` form, 88 characters, which the Go
209    ///     SDK writes and earlier releases of this crate wrote.
210    ///
211    /// These are the two encodings the OpenTDF platform KAS accepts on rewrap
212    /// (`decodePolicyBinding` in `opentdf/platform`
213    /// `service/kas/access/rewrap.go`). The KAS does not look at `alg`: it
214    /// always verifies an HMAC-SHA256, so any other `alg` is rejected here.
215    ///
216    /// This is a shape check only. It does **not** show that the binding
217    /// matches the policy: that needs the payload key, and is done by the KAS.
218    /// It is never run implicitly; deserializing a manifest does not call it.
219    ///
220    /// # Example
221    ///
222    /// ```
223    /// use opentdf_protocol::manifest::{PolicyBinding, PolicyBindingError};
224    ///
225    /// let binding = PolicyBinding {
226    ///     alg: "HS256".to_string(),
227    ///     hash: "03yakXSzXkEvVt/Om8VVgFBWI3a+vYCxqtK4Ki80sbo=".to_string(),
228    /// };
229    /// assert!(binding.validate().is_ok());
230    ///
231    /// let empty = PolicyBinding {
232    ///     alg: "HS256".to_string(),
233    ///     hash: String::new(),
234    /// };
235    /// assert_eq!(empty.validate(), Err(PolicyBindingError::EmptyHash));
236    /// ```
237    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
264/// Key access type constants for TDF3
265pub mod key_access_type {
266    /// Standard RSA-wrapped key (RSA-OAEP)
267    pub const WRAPPED: &str = "wrapped";
268    /// EC-wrapped key (ECIES: ECDH + HKDF + AES-GCM)
269    pub const EC_WRAPPED: &str = "ec-wrapped";
270    /// Remote key access (key stored on KAS)
271    pub const REMOTE: &str = "remote";
272    /// Remote wrapped key access
273    pub const REMOTE_WRAPPED: &str = "remoteWrapped";
274}
275
276/// Key access object in manifest
277#[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    /// Optional key split (share) identifier (spec: key_access_object.md `sid`).
285    #[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    /// Ephemeral public key for EC-wrapped keys (PEM format)
297    #[serde(rename = "ephemeralPublicKey", skip_serializing_if = "Option::is_none")]
298    pub ephemeral_public_key: Option<String>,
299}
300
301impl KeyAccess {
302    /// Creates a new KeyAccess object with default values (RSA-wrapped)
303    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    /// Creates a new KeyAccess object for EC-wrapped keys
322    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    /// Check if this key access uses EC wrapping
341    pub fn is_ec_wrapped(&self) -> bool {
342        self.access_type == key_access_type::EC_WRAPPED
343    }
344
345    /// Check if this key access uses RSA wrapping
346    pub fn is_rsa_wrapped(&self) -> bool {
347        self.access_type == key_access_type::WRAPPED
348    }
349
350    /// Set the ephemeral public key (for EC-wrapped keys)
351    pub fn set_ephemeral_public_key(&mut self, pem: String) {
352        self.ephemeral_public_key = Some(pem);
353    }
354}
355
356/// Encryption method configuration
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct EncryptionMethod {
359    pub algorithm: String,
360    /// Spec: required. Pre-4.3.0 Java manifests omit it; default false.
361    #[serde(rename = "isStreamable", default)]
362    pub is_streamable: bool,
363    /// Spec prose: required. Every streaming SDK carries per-segment IVs in
364    /// the payload and writes `""` or omits the field (Java), so it defaults
365    /// to empty on read.
366    #[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/// Integrity information including segments and root signature
381#[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,                // 1MB
401            encrypted_segment_size_default: 1024 * 1024 + 28, // +IV+tag
402        }
403    }
404}
405
406impl IntegrityInformation {
407    /// Resolve every segment's `(plaintext_size, encrypted_size)`.
408    ///
409    /// Spec (integrity_information.md): `segmentSize` and
410    /// `encryptedSegmentSize` are optional on a segment and are inferred from
411    /// `segmentSizeDefault` / `encryptedSegmentSizeDefault` when omitted.
412    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/// Root signature for integrity verification
427#[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/// Segment information
443#[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    /// Creates a new TDF manifest with basic structure
457    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                // Omit by default — go/Swift only set root schemaVersion.
466                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, // 1MB default
484                    encrypted_segment_size_default: 1024 * 1024 + 28, // +IV+tag
485                },
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    /// Resolve the spec version a peer wrote, in priority order:
496    /// root `schemaVersion` (what every SDK writes), then root `tdf_spec_version`
497    /// (spec prose), then `payload.tdf_spec_version` (spec JSON schema).
498    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    /// Set the policy for the manifest using a raw string
510    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    /// Get the decoded policy from the manifest as a raw string
516    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    /// Add a segment to the manifest
524    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    /// Serialize to JSON
541    pub fn to_json(&self) -> Result<String, serde_json::Error> {
542        serde_json::to_string(self)
543    }
544
545    /// Deserialize from JSON
546    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
547        serde_json::from_str(json)
548    }
549}
550
551impl KeyAccess {
552    /// Set encrypted metadata
553    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    /// Clear encrypted metadata
559    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        // Existing manifests must serialize unchanged: no new keys appear.
571        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        // The gguf-tdf/1 writer sets both and they round-trip.
586        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    /// Java SDK manifests omit `method.iv` (and the oldest omit `isStreamable`).
693    #[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    /// Manifest carrying `keyAccess[].sid` and one assertion whose
712    /// `statement.value` is a structured JSON object (spec: assertion_statement.md).
713    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        // Typed access documents the intent, not just Value equality.
757        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        // Re-serialize: nothing dropped, nothing invented.
782        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        // Swap the object value for a plain string value.
791        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        // Existing manifests (no sid, no assertions) must not grow new keys.
808        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        // And a peer manifest that omits both still parses (serde default).
825        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    /// Jackson-style writers serialize an empty list as `null`. serde's
834    /// `default` covers only a missing key, so this must be handled explicitly.
835    #[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    /// Go's `Assertion.Binding` is a non-pointer struct with `omitempty` on its
853    /// string fields, so an unsigned assertion serializes as `"binding": {}`.
854    #[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    /// `Base64(HMAC-SHA256(key, Base64(policy)))`: the known answer pinned by
876    /// `opentdf_crypto::calculate_policy_binding`'s own test.
877    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    /// Lowercase hex of the 32 bytes behind `SPEC_HASH`.
887    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        // Any 32 bytes are structurally fine; only a KAS can check the HMAC.
901        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        // The KAS hex decoder takes either case.
915        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        // A freshly constructed KeyAccess has no binding yet.
939        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        // One non-hex character in an otherwise valid legacy binding.
976        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        // 64 raw bytes (e.g. an HMAC-SHA512) are not a hex HMAC-SHA256 either.
983        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        // `validate` is opt-in: a manifest with a malformed binding still parses.
992        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        // Spec (integrity_information.md): segmentSize / encryptedSegmentSize are
1015        // optional and inferred from the *Default values when omitted.
1016        info.segments.push(Segment {
1017            hash: "a".into(),
1018            segment_size: None,
1019            encrypted_segment_size: None,
1020        });
1021        // Explicit values always win (a re-encrypted or short tail segment).
1022        info.segments.push(Segment {
1023            hash: "b".into(),
1024            segment_size: Some(7),
1025            encrypted_segment_size: Some(35),
1026        });
1027        // Mixed: only one side omitted.
1028        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}