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//!
9//! Note: Cryptographic operations (HMAC, policy binding generation) are in the crypto crate.
10
11use serde::{Deserialize, Serialize};
12
13/// TDF manifest structure
14#[derive(Debug, Serialize, Deserialize)]
15pub struct TdfManifest {
16    pub payload: Payload,
17    #[serde(rename = "encryptionInformation")]
18    pub encryption_information: EncryptionInformation,
19    #[serde(rename = "schemaVersion", skip_serializing_if = "Option::is_none")]
20    pub schema_version: Option<String>,
21    /// OpenTDF specification version. Omitted by default so existing
22    /// manifests serialize unchanged; the `gguf-tdf/1` writer sets it.
23    #[serde(rename = "tdf_spec_version", skip_serializing_if = "Option::is_none")]
24    pub tdf_spec_version: Option<String>,
25    /// `gguf-tdf/1` hybrid index. Absent for every other TDF profile.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub gguf: Option<crate::GgufIndex>,
28}
29
30/// Payload reference in TDF manifest
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Payload {
33    #[serde(rename = "type")]
34    pub payload_type: String,
35    pub url: String,
36    pub protocol: String,
37    #[serde(rename = "isEncrypted")]
38    pub is_encrypted: bool,
39    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
40    pub mime_type: Option<String>,
41    #[serde(rename = "tdf_spec_version", skip_serializing_if = "Option::is_none")]
42    pub tdf_spec_version: Option<String>,
43}
44
45impl Default for Payload {
46    fn default() -> Self {
47        Self {
48            payload_type: "reference".to_string(),
49            url: "0.payload".to_string(),
50            protocol: "zip".to_string(),
51            is_encrypted: true,
52            mime_type: Some("application/octet-stream".to_string()),
53            // Match go/Swift: version lives on root `schemaVersion`, not payload.
54            // Emitting payload.tdf_spec_version breaks strict readers (e.g. otdf-python
55            // ManifestPayload, which has no such field).
56            tdf_spec_version: None,
57        }
58    }
59}
60
61/// Encryption information in TDF manifest
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct EncryptionInformation {
64    #[serde(rename = "type")]
65    pub encryption_type: String,
66    #[serde(rename = "keyAccess")]
67    pub key_access: Vec<KeyAccess>,
68    pub method: EncryptionMethod,
69    #[serde(rename = "integrityInformation")]
70    pub integrity_information: IntegrityInformation,
71    pub policy: String,
72}
73
74/// Policy binding structure
75#[derive(Debug, Serialize, Deserialize, Clone)]
76pub struct PolicyBinding {
77    pub alg: String,
78    pub hash: String,
79}
80
81/// Key access type constants for TDF3
82pub mod key_access_type {
83    /// Standard RSA-wrapped key (RSA-OAEP)
84    pub const WRAPPED: &str = "wrapped";
85    /// EC-wrapped key (ECIES: ECDH + HKDF + AES-GCM)
86    pub const EC_WRAPPED: &str = "ec-wrapped";
87    /// Remote key access (key stored on KAS)
88    pub const REMOTE: &str = "remote";
89    /// Remote wrapped key access
90    pub const REMOTE_WRAPPED: &str = "remoteWrapped";
91}
92
93/// Key access object in manifest
94#[derive(Debug, Serialize, Deserialize, Clone)]
95pub struct KeyAccess {
96    #[serde(rename = "type")]
97    pub access_type: String,
98    pub url: String,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub kid: Option<String>,
101    pub protocol: String,
102    #[serde(rename = "wrappedKey")]
103    pub wrapped_key: String,
104    #[serde(rename = "policyBinding")]
105    pub policy_binding: PolicyBinding,
106    #[serde(rename = "encryptedMetadata", skip_serializing_if = "Option::is_none")]
107    pub encrypted_metadata: Option<String>,
108    #[serde(rename = "schemaVersion", skip_serializing_if = "Option::is_none")]
109    pub schema_version: Option<String>,
110    /// Ephemeral public key for EC-wrapped keys (PEM format)
111    #[serde(rename = "ephemeralPublicKey", skip_serializing_if = "Option::is_none")]
112    pub ephemeral_public_key: Option<String>,
113}
114
115impl KeyAccess {
116    /// Creates a new KeyAccess object with default values (RSA-wrapped)
117    pub fn new(url: String) -> Self {
118        KeyAccess {
119            access_type: key_access_type::WRAPPED.to_string(),
120            url,
121            kid: None,
122            protocol: "kas".to_string(),
123            wrapped_key: String::new(),
124            policy_binding: PolicyBinding {
125                alg: "HS256".to_string(),
126                hash: String::new(),
127            },
128            encrypted_metadata: None,
129            schema_version: Some("1.0".to_string()),
130            ephemeral_public_key: None,
131        }
132    }
133
134    /// Creates a new KeyAccess object for EC-wrapped keys
135    pub fn new_ec_wrapped(url: String) -> Self {
136        KeyAccess {
137            access_type: key_access_type::EC_WRAPPED.to_string(),
138            url,
139            kid: None,
140            protocol: "kas".to_string(),
141            wrapped_key: String::new(),
142            policy_binding: PolicyBinding {
143                alg: "HS256".to_string(),
144                hash: String::new(),
145            },
146            encrypted_metadata: None,
147            schema_version: Some("1.0".to_string()),
148            ephemeral_public_key: None,
149        }
150    }
151
152    /// Check if this key access uses EC wrapping
153    pub fn is_ec_wrapped(&self) -> bool {
154        self.access_type == key_access_type::EC_WRAPPED
155    }
156
157    /// Check if this key access uses RSA wrapping
158    pub fn is_rsa_wrapped(&self) -> bool {
159        self.access_type == key_access_type::WRAPPED
160    }
161
162    /// Set the ephemeral public key (for EC-wrapped keys)
163    pub fn set_ephemeral_public_key(&mut self, pem: String) {
164        self.ephemeral_public_key = Some(pem);
165    }
166}
167
168/// Encryption method configuration
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct EncryptionMethod {
171    pub algorithm: String,
172    #[serde(rename = "isStreamable")]
173    pub is_streamable: bool,
174    pub iv: String,
175}
176
177impl Default for EncryptionMethod {
178    fn default() -> Self {
179        Self {
180            algorithm: "AES-256-GCM".to_string(),
181            is_streamable: true,
182            iv: String::new(),
183        }
184    }
185}
186
187/// Integrity information including segments and root signature
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct IntegrityInformation {
190    #[serde(rename = "rootSignature")]
191    pub root_signature: RootSignature,
192    #[serde(rename = "segmentHashAlg")]
193    pub segment_hash_alg: String,
194    pub segments: Vec<Segment>,
195    #[serde(rename = "segmentSizeDefault")]
196    pub segment_size_default: u64,
197    #[serde(rename = "encryptedSegmentSizeDefault")]
198    pub encrypted_segment_size_default: u64,
199}
200
201impl Default for IntegrityInformation {
202    fn default() -> Self {
203        Self {
204            root_signature: RootSignature::default(),
205            segment_hash_alg: "GMAC".to_string(),
206            segments: Vec::new(),
207            segment_size_default: 1024 * 1024,                // 1MB
208            encrypted_segment_size_default: 1024 * 1024 + 28, // +IV+tag
209        }
210    }
211}
212
213/// Root signature for integrity verification
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct RootSignature {
216    pub alg: String,
217    pub sig: String,
218}
219
220impl Default for RootSignature {
221    fn default() -> Self {
222        Self {
223            alg: "HS256".to_string(),
224            sig: String::new(),
225        }
226    }
227}
228
229/// Segment information
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct Segment {
232    pub hash: String,
233    #[serde(rename = "segmentSize", skip_serializing_if = "Option::is_none")]
234    pub segment_size: Option<u64>,
235    #[serde(
236        rename = "encryptedSegmentSize",
237        skip_serializing_if = "Option::is_none"
238    )]
239    pub encrypted_segment_size: Option<u64>,
240}
241
242impl TdfManifest {
243    /// Creates a new TDF manifest with basic structure
244    pub fn new(payload_url: String, kas_url: String) -> Self {
245        TdfManifest {
246            payload: Payload {
247                payload_type: "reference".to_string(),
248                url: payload_url,
249                protocol: "zip".to_string(),
250                is_encrypted: true,
251                mime_type: Some("application/octet-stream".to_string()),
252                // Omit by default — go/Swift only set root schemaVersion.
253                tdf_spec_version: None,
254            },
255            encryption_information: EncryptionInformation {
256                encryption_type: "split".to_string(),
257                key_access: vec![KeyAccess::new(kas_url)],
258                method: EncryptionMethod {
259                    algorithm: "AES-256-GCM".to_string(),
260                    is_streamable: true,
261                    iv: String::new(),
262                },
263                integrity_information: IntegrityInformation {
264                    root_signature: RootSignature {
265                        alg: "HS256".to_string(),
266                        sig: String::new(),
267                    },
268                    segment_hash_alg: "GMAC".to_string(),
269                    segments: Vec::new(),
270                    segment_size_default: 1024 * 1024, // 1MB default
271                    encrypted_segment_size_default: 1024 * 1024 + 28, // +IV+tag
272                },
273                policy: String::new(),
274            },
275            schema_version: Some("3.0.0".to_string()),
276            tdf_spec_version: None,
277            gguf: None,
278        }
279    }
280
281    /// Set the policy for the manifest using a raw string
282    pub fn set_policy_raw(&mut self, policy: &str) {
283        use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
284        self.encryption_information.policy = BASE64.encode(policy);
285    }
286
287    /// Get the decoded policy from the manifest as a raw string
288    pub fn get_policy_raw(&self) -> Result<String, base64::DecodeError> {
289        use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
290        let bytes = BASE64.decode(&self.encryption_information.policy)?;
291        String::from_utf8(bytes)
292            .map_err(|err| base64::DecodeError::InvalidByte(err.utf8_error().valid_up_to(), 0))
293    }
294
295    /// Add a segment to the manifest
296    pub fn add_segment(
297        &mut self,
298        hash: String,
299        segment_size: Option<u64>,
300        encrypted_segment_size: Option<u64>,
301    ) {
302        self.encryption_information
303            .integrity_information
304            .segments
305            .push(Segment {
306                hash,
307                segment_size,
308                encrypted_segment_size,
309            });
310    }
311
312    /// Serialize to JSON
313    pub fn to_json(&self) -> Result<String, serde_json::Error> {
314        serde_json::to_string(self)
315    }
316
317    /// Deserialize from JSON
318    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
319        serde_json::from_str(json)
320    }
321}
322
323impl KeyAccess {
324    /// Set encrypted metadata
325    pub fn set_encrypted_metadata(&mut self, metadata: &str) {
326        use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
327        self.encrypted_metadata = Some(BASE64.encode(metadata));
328    }
329
330    /// Clear encrypted metadata
331    pub fn clear_encrypted_metadata(&mut self) {
332        self.encrypted_metadata = None;
333    }
334}
335
336#[cfg(test)]
337mod gguf_index_tests {
338    use super::*;
339
340    #[test]
341    fn gguf_index_round_trips_and_stays_absent_by_default() {
342        // Existing manifests must serialize unchanged: no new keys appear.
343        let plain = TdfManifest::new(
344            "0.payload".to_string(),
345            "https://kas.example.com".to_string(),
346        );
347        let value: serde_json::Value = serde_json::from_str(&plain.to_json().unwrap()).unwrap();
348        assert!(
349            value.get("gguf").is_none(),
350            "gguf must be absent when unset: {value}"
351        );
352        assert!(
353            value.get("tdf_spec_version").is_none(),
354            "tdf_spec_version must be absent when unset: {value}"
355        );
356
357        // The gguf-tdf/1 writer sets both and they round-trip.
358        let mut m = TdfManifest::new("header".to_string(), "https://kas.example.com".to_string());
359        m.tdf_spec_version = Some("4.3.0".to_string());
360        m.gguf = Some(crate::GgufIndex {
361            profile: crate::GGUF_TDF_PROFILE_V1.to_string(),
362            alignment: 32,
363            header_bytes: 64,
364            virtual_size: 352,
365            max_segment: 128,
366            tensors: vec![crate::GgufTensor {
367                name: "token_embd.weight".to_string(),
368                offset: 64,
369                size: 256,
370                segments: [1, 3],
371            }],
372            segments: vec![crate::GgufSegment {
373                id: 0,
374                kind: crate::GgufSegmentKind::Header,
375                plain: 64,
376                entry: "header".to_string(),
377            }],
378        });
379
380        let json = m.to_json().unwrap();
381        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
382        assert_eq!(value["tdf_spec_version"], "4.3.0");
383        assert_eq!(value["gguf"]["profile"], "gguf-tdf/1");
384        assert_eq!(value["gguf"]["headerBytes"], 64);
385        assert_eq!(value["gguf"]["maxSegment"], 128);
386        assert_eq!(value["gguf"]["segments"][0]["kind"], "header");
387        assert_eq!(value["gguf"]["tensors"][0]["segments"][1], 3);
388
389        let back = TdfManifest::from_json(&json).unwrap();
390        assert_eq!(back.gguf.as_ref().unwrap().virtual_size, 352);
391        assert_eq!(back.tdf_spec_version.as_deref(), Some("4.3.0"));
392    }
393}