Skip to main content

appcore_dnt/
header.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: header.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/02 00:04:12 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/02 12:07:11 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Canonical DNT header encoding.
12
13use crate::{CodecId, ContentType, DntCompression, DntError, DntResult, KeyId};
14use appcore_contracts::ApplicationId;
15use appcore_types::TenantId;
16
17/// DNT magic bytes. File extensions are conventions only.
18pub const DNT_MAGIC: [u8; 8] = *b"APDNT\0\0\x01";
19/// Current DNT envelope version.
20pub const DNT_ENVELOPE_VERSION_V1: u16 = 1;
21/// Maximum accepted header size.
22pub const DNT_MAX_HEADER_BYTES: usize = 64 * 1024;
23/// Maximum encrypted metadata accepted by the in-memory V1 envelope.
24pub const DNT_MAX_ENCRYPTED_METADATA_BYTES: usize = 64 * 1024;
25/// Conventional JSON content type.
26pub const DNT_CONTENT_JSON: &str = "application/json";
27/// Conventional arbitrary bytes content type.
28pub const DNT_CONTENT_OCTET_STREAM: &str = "application/octet-stream";
29/// AppCore secret material content type.
30pub const DNT_CONTENT_SECRET: &str = "appcore/secret";
31/// AppCore snapshot content type.
32pub const DNT_CONTENT_SNAPSHOT: &str = "appcore/snapshot";
33/// AppCore sync event content type.
34pub const DNT_CONTENT_SYNC_EVENT: &str = "appcore/sync-event";
35/// AppCore backup content type.
36pub const DNT_CONTENT_BACKUP: &str = "appcore/backup";
37
38const MIN_PREFIX_BYTES: usize = 14;
39const NONCE_BYTES: usize = 24;
40const HASH_BYTES: usize = 32;
41
42/// Authenticated encryption algorithm used by this envelope.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum DntAlgorithm {
46    /// XChaCha20-Poly1305 with a 256-bit key and 192-bit nonce.
47    XChaCha20Poly1305,
48}
49
50impl DntAlgorithm {
51    pub(crate) fn id(self) -> u16 {
52        match self {
53            Self::XChaCha20Poly1305 => 1,
54        }
55    }
56
57    fn from_id(value: u16) -> DntResult<Self> {
58        match value {
59            1 => Ok(Self::XChaCha20Poly1305),
60            _ => Err(DntError::UnsupportedVersion),
61        }
62    }
63}
64
65/// Structurally parsed DNT header.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct DntHeader {
68    /// Envelope version.
69    pub envelope_version: u16,
70    /// Canonical header length in bytes.
71    pub header_length: u32,
72    /// Extension flags reserved for future formats.
73    pub flags: u32,
74    /// Authenticated encryption algorithm.
75    pub algorithm: DntAlgorithm,
76    /// Application that owns this envelope.
77    pub application_id: ApplicationId,
78    /// Optional tenant boundary.
79    pub tenant_id: Option<TenantId>,
80    /// Logical content type.
81    pub content_type: ContentType,
82    /// Payload codec identifier.
83    pub codec_id: CodecId,
84    /// Rotation-aware key identifier.
85    pub key_id: KeyId,
86    /// Payload schema version, independent of envelope version.
87    pub schema_version: u32,
88    /// Creation timestamp in Unix milliseconds.
89    pub created_at_ms: u64,
90    /// Stored encoded payload length, after any authenticated compression.
91    pub payload_length: u64,
92    /// AEAD nonce.
93    pub nonce: [u8; NONCE_BYTES],
94    /// Keyed digest of the stored encoded payload.
95    pub payload_hash: [u8; HASH_BYTES],
96    /// Authenticated public metadata bytes.
97    pub public_metadata: Vec<u8>,
98    /// Encrypted metadata length stored inside AEAD plaintext.
99    pub encrypted_metadata_length: u32,
100}
101
102impl DntHeader {
103    /// Returns the nonce bytes.
104    pub fn nonce(&self) -> &[u8; NONCE_BYTES] {
105        &self.nonce
106    }
107
108    /// Returns the authenticated payload compression mode.
109    pub fn compression(&self) -> DntCompression {
110        if self.flags & crate::DNT_FLAG_PAYLOAD_DEFLATE != 0 {
111            return DntCompression::Deflate;
112        }
113        DntCompression::None
114    }
115}
116
117pub(crate) struct HeaderParts {
118    pub(crate) flags: u32,
119    pub(crate) algorithm: DntAlgorithm,
120    pub(crate) application_id: ApplicationId,
121    pub(crate) tenant_id: Option<TenantId>,
122    pub(crate) content_type: ContentType,
123    pub(crate) codec_id: CodecId,
124    pub(crate) key_id: KeyId,
125    pub(crate) schema_version: u32,
126    pub(crate) created_at_ms: u64,
127    pub(crate) payload_length: u64,
128    pub(crate) nonce: [u8; NONCE_BYTES],
129    pub(crate) payload_hash: [u8; HASH_BYTES],
130    pub(crate) public_metadata: Vec<u8>,
131    pub(crate) encrypted_metadata_length: u32,
132}
133
134pub(crate) fn encode_header(parts: HeaderParts) -> DntResult<Vec<u8>> {
135    if parts.public_metadata.len() > DNT_MAX_HEADER_BYTES
136        || parts.encrypted_metadata_length as usize > DNT_MAX_ENCRYPTED_METADATA_BYTES
137    {
138        return Err(DntError::PayloadTooLarge);
139    }
140    let capacity = 256usize
141        .checked_add(parts.public_metadata.len())
142        .ok_or(DntError::InvalidFormat)?;
143    let mut header = Vec::with_capacity(capacity);
144    header.extend_from_slice(&DNT_MAGIC);
145    put_u16(&mut header, DNT_ENVELOPE_VERSION_V1);
146    put_u32(&mut header, 0);
147    put_u32(&mut header, parts.flags);
148    put_u16(&mut header, parts.algorithm.id());
149    put_u32(&mut header, parts.schema_version);
150    put_u64(&mut header, parts.created_at_ms);
151    put_u64(&mut header, parts.payload_length);
152    header.extend_from_slice(&parts.nonce);
153    header.extend_from_slice(&parts.payload_hash);
154    put_u32(
155        &mut header,
156        checked_u32(parts.public_metadata.len(), DntError::InvalidFormat)?,
157    );
158    put_u32(&mut header, parts.encrypted_metadata_length);
159    put_text(&mut header, parts.application_id.as_str())?;
160    put_optional_text(
161        &mut header,
162        parts.tenant_id.as_ref().map(|value| value.as_str()),
163    )?;
164    put_text(&mut header, parts.content_type.as_str())?;
165    put_text(&mut header, parts.codec_id.as_str())?;
166    put_text(&mut header, parts.key_id.as_str())?;
167    header.extend_from_slice(&parts.public_metadata);
168    if header.len() > DNT_MAX_HEADER_BYTES {
169        return Err(DntError::InvalidFormat);
170    }
171    let length = checked_u32(header.len(), DntError::InvalidFormat)?;
172    header[10..14].copy_from_slice(&length.to_be_bytes());
173    Ok(header)
174}
175
176/// Structurally inspects a DNT header without resolving keys or decrypting.
177pub fn inspect_header(input: &[u8]) -> DntResult<DntHeader> {
178    if input.len() < MIN_PREFIX_BYTES || input[..8] != DNT_MAGIC {
179        return Err(DntError::InvalidFormat);
180    }
181    let version = read_u16(input, 8)?;
182    if version != DNT_ENVELOPE_VERSION_V1 {
183        return Err(DntError::UnsupportedVersion);
184    }
185    let header_length = read_u32(input, 10)?;
186    let header_len = usize::try_from(header_length).map_err(|_| DntError::InvalidFormat)?;
187    if !(MIN_PREFIX_BYTES..=DNT_MAX_HEADER_BYTES).contains(&header_len) || input.len() < header_len
188    {
189        return Err(DntError::InvalidFormat);
190    }
191    parse_header(&input[..header_len], header_length)
192}
193
194fn parse_header(input: &[u8], header_length: u32) -> DntResult<DntHeader> {
195    let mut cursor = 14usize;
196    let flags = take_u32(input, &mut cursor)?;
197    crate::flags::validate_flags(flags)?;
198    let algorithm = DntAlgorithm::from_id(take_u16(input, &mut cursor)?)?;
199    let schema_version = take_u32(input, &mut cursor)?;
200    let created_at_ms = take_u64(input, &mut cursor)?;
201    let payload_length = take_u64(input, &mut cursor)?;
202    let nonce = take_array::<NONCE_BYTES>(input, &mut cursor)?;
203    let payload_hash = take_array::<HASH_BYTES>(input, &mut cursor)?;
204    let public_metadata_length = take_u32(input, &mut cursor)?;
205    let encrypted_metadata_length = take_u32(input, &mut cursor)?;
206    if encrypted_metadata_length as usize > DNT_MAX_ENCRYPTED_METADATA_BYTES {
207        return Err(DntError::PayloadTooLarge);
208    }
209    let application_id =
210        ApplicationId::new(take_text(input, &mut cursor)?).map_err(|_| DntError::InvalidFormat)?;
211    let tenant_text = take_optional_text(input, &mut cursor)?;
212    let tenant_id = tenant_text
213        .map(TenantId::new)
214        .transpose()
215        .map_err(|_| DntError::InvalidFormat)?;
216    let content_type = ContentType::new(take_text(input, &mut cursor)?)?;
217    let codec_id = CodecId::new(take_text(input, &mut cursor)?)?;
218    let key_id = KeyId::new(take_text(input, &mut cursor)?)?;
219    let metadata_len =
220        usize::try_from(public_metadata_length).map_err(|_| DntError::InvalidFormat)?;
221    let public_metadata = take_bytes(input, &mut cursor, metadata_len)?.to_vec();
222    if cursor != input.len() {
223        return Err(DntError::InvalidFormat);
224    }
225    Ok(DntHeader {
226        envelope_version: DNT_ENVELOPE_VERSION_V1,
227        header_length,
228        flags,
229        algorithm,
230        application_id,
231        tenant_id,
232        content_type,
233        codec_id,
234        key_id,
235        schema_version,
236        created_at_ms,
237        payload_length,
238        nonce,
239        payload_hash,
240        public_metadata,
241        encrypted_metadata_length,
242    })
243}
244
245fn put_u16(output: &mut Vec<u8>, value: u16) {
246    output.extend_from_slice(&value.to_be_bytes());
247}
248
249fn put_u32(output: &mut Vec<u8>, value: u32) {
250    output.extend_from_slice(&value.to_be_bytes());
251}
252
253fn put_u64(output: &mut Vec<u8>, value: u64) {
254    output.extend_from_slice(&value.to_be_bytes());
255}
256
257fn put_text(output: &mut Vec<u8>, value: &str) -> DntResult<()> {
258    let bytes = value.as_bytes();
259    let length = u16::try_from(bytes.len()).map_err(|_| DntError::InvalidFormat)?;
260    put_u16(output, length);
261    output.extend_from_slice(bytes);
262    Ok(())
263}
264
265fn put_optional_text(output: &mut Vec<u8>, value: Option<&str>) -> DntResult<()> {
266    match value {
267        Some(value) => put_text(output, value),
268        None => {
269            put_u16(output, 0);
270            Ok(())
271        }
272    }
273}
274
275fn read_u16(input: &[u8], offset: usize) -> DntResult<u16> {
276    let bytes = input
277        .get(offset..offset + 2)
278        .ok_or(DntError::InvalidFormat)?;
279    Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
280}
281
282fn read_u32(input: &[u8], offset: usize) -> DntResult<u32> {
283    let bytes = input
284        .get(offset..offset + 4)
285        .ok_or(DntError::InvalidFormat)?;
286    Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
287}
288
289fn take_u16(input: &[u8], cursor: &mut usize) -> DntResult<u16> {
290    let value = read_u16(input, *cursor)?;
291    *cursor += 2;
292    Ok(value)
293}
294
295fn take_u32(input: &[u8], cursor: &mut usize) -> DntResult<u32> {
296    let value = read_u32(input, *cursor)?;
297    *cursor += 4;
298    Ok(value)
299}
300
301fn take_u64(input: &[u8], cursor: &mut usize) -> DntResult<u64> {
302    let bytes = take_bytes(input, cursor, 8)?;
303    Ok(u64::from_be_bytes([
304        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
305    ]))
306}
307
308fn take_array<const N: usize>(input: &[u8], cursor: &mut usize) -> DntResult<[u8; N]> {
309    let mut output = [0u8; N];
310    output.copy_from_slice(take_bytes(input, cursor, N)?);
311    Ok(output)
312}
313
314fn take_text(input: &[u8], cursor: &mut usize) -> DntResult<String> {
315    let length = take_u16(input, cursor)? as usize;
316    if length == 0 {
317        return Err(DntError::InvalidFormat);
318    }
319    let bytes = take_bytes(input, cursor, length)?;
320    std::str::from_utf8(bytes)
321        .map(str::to_string)
322        .map_err(|_| DntError::InvalidFormat)
323}
324
325fn take_optional_text(input: &[u8], cursor: &mut usize) -> DntResult<Option<String>> {
326    let length = take_u16(input, cursor)? as usize;
327    if length == 0 {
328        return Ok(None);
329    }
330    let bytes = take_bytes(input, cursor, length)?;
331    std::str::from_utf8(bytes)
332        .map(|value| Some(value.to_string()))
333        .map_err(|_| DntError::InvalidFormat)
334}
335
336fn take_bytes<'a>(input: &'a [u8], cursor: &mut usize, length: usize) -> DntResult<&'a [u8]> {
337    let end = cursor.checked_add(length).ok_or(DntError::InvalidFormat)?;
338    let bytes = input.get(*cursor..end).ok_or(DntError::InvalidFormat)?;
339    *cursor = end;
340    Ok(bytes)
341}
342
343fn checked_u32(value: usize, error: DntError) -> DntResult<u32> {
344    u32::try_from(value).map_err(|_| error)
345}