Skip to main content

opentdf_protocol/nanotdf/
collection.rs

1//! NanoTDF Collection/Dataset Protocol Types
2//!
3//! This module defines the protocol types for NanoTDF Collections, enabling
4//! multiple encrypted payloads to share a single NanoTDF header (manifest) and DEK.
5//!
6//! # Overview
7//!
8//! A NanoTDF Collection consists of:
9//! - A single manifest (NanoTDF header) containing KAS info, policy, and ephemeral public key
10//! - Multiple collection items, each with a unique counter-based IV
11//!
12//! This supports streaming use cases like NTDF-RTMP where the manifest is sent once
13//! and payloads are encrypted per-frame with minimal overhead.
14//!
15//! # Wire Formats
16//!
17//! ## Container framing (for FLV/RTMP where container provides length):
18//! ```text
19//! [IV (3B, counter-based)][Ciphertext][Auth Tag]
20//! ```
21//!
22//! ## NanoTDF payload framing (spec-compliant with length prefix):
23//! ```text
24//! [Length (3B, big-endian)][IV (3B)][Ciphertext][Auth Tag]
25//! ```
26//!
27//! # IV Management
28//!
29//! - IV 0x000000 is reserved for encrypted policy (never used for items)
30//! - IV range: 1 to 0x00FFFFFF (16,777,215 items max per DEK)
31//! - GCM nonce expansion: `[9 zero bytes][3-byte IV counter]` (JS SDK interop)
32
33use crate::binary::{read_u24_be, write_u24_be};
34use std::io::{self, Read, Write};
35
36/// Maximum IV value (2^24 - 1 = 16,777,215)
37pub const MAX_IV: u32 = 0x00FF_FFFF;
38
39/// Reserved IV for policy encryption (must not be used for collection items)
40pub const RESERVED_POLICY_IV: u32 = 0;
41
42/// Default rotation threshold (conservative: 2^23 = 8,388,608)
43/// This provides an early warning signal before IV exhaustion
44pub const DEFAULT_ROTATION_THRESHOLD: u32 = 0x0080_0000;
45
46/// Collection item containing an encrypted payload with its IV
47///
48/// Each item uses a unique counter-based IV derived from the collection's
49/// IV counter. The ciphertext_and_tag contains the AES-GCM encrypted data
50/// with the authentication tag appended.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct CollectionItem {
53    /// 24-bit IV counter value (1 to MAX_IV)
54    /// This is used to reconstruct the 12-byte GCM nonce: [9 zeros][3-byte IV]
55    pub iv: u32,
56
57    /// Ciphertext with authentication tag appended
58    /// Format: [encrypted_data][auth_tag]
59    /// Tag size depends on the symmetric cipher config in the collection manifest
60    pub ciphertext_and_tag: Vec<u8>,
61}
62
63impl CollectionItem {
64    /// Create a new collection item
65    pub fn new(iv: u32, ciphertext_and_tag: Vec<u8>) -> Self {
66        Self {
67            iv,
68            ciphertext_and_tag,
69        }
70    }
71
72    /// Container framing: [IV (3B)][ciphertext][tag]
73    ///
74    /// Use this format when the container (FLV/RTMP) provides the length.
75    /// This is the minimal wire format for streaming scenarios.
76    pub fn to_bytes(&self) -> Vec<u8> {
77        let mut buf = Vec::with_capacity(3 + self.ciphertext_and_tag.len());
78        // Write IV as 3 bytes big-endian
79        buf.push(((self.iv >> 16) & 0xFF) as u8);
80        buf.push(((self.iv >> 8) & 0xFF) as u8);
81        buf.push((self.iv & 0xFF) as u8);
82        buf.extend_from_slice(&self.ciphertext_and_tag);
83        buf
84    }
85
86    /// NanoTDF payload framing: [length (3B)][IV (3B)][ciphertext][tag]
87    ///
88    /// This is spec-compliant format with a 3-byte length prefix.
89    /// Use this when you need a self-describing payload format.
90    pub fn to_nanotdf_payload_bytes(&self) -> Vec<u8> {
91        let payload_len = 3 + self.ciphertext_and_tag.len();
92        let mut buf = Vec::with_capacity(3 + payload_len);
93
94        // Write length (3 bytes big-endian) - includes IV + ciphertext + tag
95        buf.push(((payload_len >> 16) & 0xFF) as u8);
96        buf.push(((payload_len >> 8) & 0xFF) as u8);
97        buf.push((payload_len & 0xFF) as u8);
98
99        // Write IV as 3 bytes big-endian
100        buf.push(((self.iv >> 16) & 0xFF) as u8);
101        buf.push(((self.iv >> 8) & 0xFF) as u8);
102        buf.push((self.iv & 0xFF) as u8);
103
104        buf.extend_from_slice(&self.ciphertext_and_tag);
105        buf
106    }
107
108    /// Parse from container framing: [IV (3B)][ciphertext][tag]
109    ///
110    /// The caller must know the total length from the container.
111    pub fn from_bytes(bytes: &[u8]) -> io::Result<Self> {
112        if bytes.len() < 3 {
113            return Err(io::Error::new(
114                io::ErrorKind::InvalidData,
115                "Collection item too small: need at least 3 bytes for IV",
116            ));
117        }
118
119        // Read IV from first 3 bytes (big-endian)
120        let iv = ((bytes[0] as u32) << 16) | ((bytes[1] as u32) << 8) | (bytes[2] as u32);
121
122        // Validate IV is in valid range (not reserved, not over max)
123        if iv == RESERVED_POLICY_IV {
124            return Err(io::Error::new(
125                io::ErrorKind::InvalidData,
126                "Collection item has reserved IV 0 (used for encrypted policy)",
127            ));
128        }
129        if iv > MAX_IV {
130            return Err(io::Error::new(
131                io::ErrorKind::InvalidData,
132                format!("Collection item IV {} exceeds MAX_IV {}", iv, MAX_IV),
133            ));
134        }
135
136        let ciphertext_and_tag = bytes[3..].to_vec();
137
138        Ok(Self {
139            iv,
140            ciphertext_and_tag,
141        })
142    }
143
144    /// Parse from NanoTDF payload framing: [length (3B)][IV (3B)][ciphertext][tag]
145    ///
146    /// Reads the length prefix and validates the payload size.
147    pub fn from_nanotdf_payload_bytes(bytes: &[u8]) -> io::Result<Self> {
148        if bytes.len() < 6 {
149            return Err(io::Error::new(
150                io::ErrorKind::InvalidData,
151                "NanoTDF payload too small: need at least 6 bytes (3 length + 3 IV)",
152            ));
153        }
154
155        // Read length from first 3 bytes (big-endian)
156        let length = ((bytes[0] as usize) << 16) | ((bytes[1] as usize) << 8) | (bytes[2] as usize);
157
158        // Validate we have enough data
159        if bytes.len() < 3 + length {
160            return Err(io::Error::new(
161                io::ErrorKind::InvalidData,
162                format!(
163                    "NanoTDF payload truncated: expected {} bytes, got {}",
164                    3 + length,
165                    bytes.len()
166                ),
167            ));
168        }
169
170        // Parse the payload portion (after length prefix)
171        Self::from_bytes(&bytes[3..3 + length])
172    }
173
174    /// Read from a reader using container framing
175    ///
176    /// Reads exactly `total_len` bytes (IV + ciphertext + tag).
177    pub fn read_from<R: Read>(reader: &mut R, total_len: usize) -> io::Result<Self> {
178        if total_len < 3 {
179            return Err(io::Error::new(
180                io::ErrorKind::InvalidData,
181                "Collection item too small: need at least 3 bytes for IV",
182            ));
183        }
184
185        let mut bytes = vec![0u8; total_len];
186        reader.read_exact(&mut bytes)?;
187        Self::from_bytes(&bytes)
188    }
189
190    /// Read from a reader using NanoTDF payload framing
191    ///
192    /// Reads the length prefix first, then the payload.
193    pub fn read_nanotdf_payload_from<R: Read>(reader: &mut R) -> io::Result<Self> {
194        // Read length (3 bytes big-endian)
195        let length = read_u24_be(reader)? as usize;
196
197        if length < 3 {
198            return Err(io::Error::new(
199                io::ErrorKind::InvalidData,
200                "NanoTDF payload length too small: need at least 3 bytes for IV",
201            ));
202        }
203
204        let mut payload = vec![0u8; length];
205        reader.read_exact(&mut payload)?;
206        Self::from_bytes(&payload)
207    }
208
209    /// Write to a writer using container framing
210    pub fn write_to<W: Write>(&self, writer: &mut W) -> io::Result<()> {
211        // Write IV as 3 bytes big-endian
212        writer.write_all(&[
213            ((self.iv >> 16) & 0xFF) as u8,
214            ((self.iv >> 8) & 0xFF) as u8,
215            (self.iv & 0xFF) as u8,
216        ])?;
217        writer.write_all(&self.ciphertext_and_tag)
218    }
219
220    /// Write to a writer using NanoTDF payload framing
221    pub fn write_nanotdf_payload_to<W: Write>(&self, writer: &mut W) -> io::Result<()> {
222        let payload_len = (3 + self.ciphertext_and_tag.len()) as u32;
223        write_u24_be(writer, payload_len)?;
224        self.write_to(writer)
225    }
226
227    /// Get the total size in container framing format
228    pub fn container_size(&self) -> usize {
229        3 + self.ciphertext_and_tag.len()
230    }
231
232    /// Get the total size in NanoTDF payload framing format
233    pub fn nanotdf_payload_size(&self) -> usize {
234        3 + 3 + self.ciphertext_and_tag.len()
235    }
236
237    /// Convert the IV counter to a 3-byte array
238    pub fn iv_bytes(&self) -> [u8; 3] {
239        [
240            ((self.iv >> 16) & 0xFF) as u8,
241            ((self.iv >> 8) & 0xFF) as u8,
242            (self.iv & 0xFF) as u8,
243        ]
244    }
245
246    /// Convert the IV to a 12-byte GCM nonce
247    ///
248    /// Per NanoTDF spec and JS SDK interop: [9 zero bytes][3-byte IV counter]
249    pub fn to_gcm_nonce(&self) -> [u8; 12] {
250        let mut nonce = [0u8; 12];
251        nonce[9] = ((self.iv >> 16) & 0xFF) as u8;
252        nonce[10] = ((self.iv >> 8) & 0xFF) as u8;
253        nonce[11] = (self.iv & 0xFF) as u8;
254        nonce
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use std::io::Cursor;
262
263    #[test]
264    fn test_collection_item_container_roundtrip() {
265        let item = CollectionItem::new(1, vec![0x01, 0x02, 0x03, 0x04]);
266        let bytes = item.to_bytes();
267
268        // Should be 3 (IV) + 4 (data) = 7 bytes
269        assert_eq!(bytes.len(), 7);
270
271        let parsed = CollectionItem::from_bytes(&bytes).unwrap();
272        assert_eq!(parsed.iv, 1);
273        assert_eq!(parsed.ciphertext_and_tag, vec![0x01, 0x02, 0x03, 0x04]);
274    }
275
276    #[test]
277    fn test_collection_item_nanotdf_payload_roundtrip() {
278        let item = CollectionItem::new(0x123456, vec![0xAA, 0xBB, 0xCC]);
279        let bytes = item.to_nanotdf_payload_bytes();
280
281        // Should be 3 (length) + 3 (IV) + 3 (data) = 9 bytes
282        assert_eq!(bytes.len(), 9);
283
284        let parsed = CollectionItem::from_nanotdf_payload_bytes(&bytes).unwrap();
285        assert_eq!(parsed.iv, 0x123456);
286        assert_eq!(parsed.ciphertext_and_tag, vec![0xAA, 0xBB, 0xCC]);
287    }
288
289    #[test]
290    fn test_iv_to_gcm_nonce() {
291        let item = CollectionItem::new(0x010203, vec![]);
292        let nonce = item.to_gcm_nonce();
293
294        // First 9 bytes should be zeros
295        assert_eq!(&nonce[0..9], &[0u8; 9]);
296        // Last 3 bytes should be the IV
297        assert_eq!(&nonce[9..12], &[0x01, 0x02, 0x03]);
298    }
299
300    #[test]
301    fn test_reserved_iv_rejected() {
302        let bytes = [0x00, 0x00, 0x00, 0x01, 0x02]; // IV = 0 (reserved)
303        let result = CollectionItem::from_bytes(&bytes);
304        assert!(result.is_err());
305        assert!(result.unwrap_err().to_string().contains("reserved"));
306    }
307
308    #[test]
309    fn test_max_iv() {
310        let item = CollectionItem::new(MAX_IV, vec![0x42]);
311        let bytes = item.to_bytes();
312        let parsed = CollectionItem::from_bytes(&bytes).unwrap();
313        assert_eq!(parsed.iv, MAX_IV);
314    }
315
316    #[test]
317    fn test_reader_writer() {
318        let item = CollectionItem::new(42, vec![0x11, 0x22, 0x33, 0x44, 0x55]);
319
320        // Test container framing with reader/writer
321        let mut buf = Vec::new();
322        item.write_to(&mut buf).unwrap();
323
324        let mut cursor = Cursor::new(&buf);
325        let parsed = CollectionItem::read_from(&mut cursor, buf.len()).unwrap();
326        assert_eq!(parsed, item);
327
328        // Test NanoTDF payload framing with reader/writer
329        let mut buf2 = Vec::new();
330        item.write_nanotdf_payload_to(&mut buf2).unwrap();
331
332        let mut cursor2 = Cursor::new(&buf2);
333        let parsed2 = CollectionItem::read_nanotdf_payload_from(&mut cursor2).unwrap();
334        assert_eq!(parsed2, item);
335    }
336
337    #[test]
338    fn test_constants() {
339        assert_eq!(MAX_IV, 0x00FF_FFFF);
340        assert_eq!(RESERVED_POLICY_IV, 0);
341        assert_eq!(DEFAULT_ROTATION_THRESHOLD, 0x0080_0000);
342    }
343}