Skip to main content

record_descriptor/
lib.rs

1//! Public Bitneedle BRD1 descriptor wire-format and decoding primitives.
2//!
3//! This crate is the authoritative BRD1 carrier-descriptor wire contract. It
4//! describes how the record stream is located and encoded in the PNG carrier.
5//! It does not define BRS1 payload-entry semantics, programme-time revolution
6//! duration, track timing, GAP timing, or the relationship between payload
7//! entries and programme revolutions.
8//!
9//! It contains no JSON descriptor segments, no Brotli compatibility envelopes,
10//! no base64/hex wire representations, and no record-creation policy.
11
12use anyhow::{bail, Context, Result};
13use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
14use chacha20poly1305::aead::{Aead, KeyInit, Payload as AeadPayload};
15use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::convert::TryInto;
19
20pub const RECORD_DESCRIPTOR_MAGIC: &[u8; 4] = b"BRD1";
21pub const RECORD_DESCRIPTOR_VERSION: u8 = 2;
22pub const RECORD_DESCRIPTOR_PREFIX_LENGTH: usize = 19;
23
24pub const METADATA_GRAYSCALE_NIBBLE_BASE: u8 = 120;
25
26/// Fixed by the BRD1 v2 format: release commitments are always SHA-256,
27/// signatures are always Ed25519. No per-reference algorithm selector.
28pub const SIGNED_RELEASE_REFERENCE_VERSION: u8 = 2;
29pub const SIGNED_RELEASE_REFERENCE_HASH_LENGTH: usize = 32;
30pub const SIGNED_RELEASE_REFERENCE_SIGNATURE_LENGTH: usize = 64;
31pub const SIGNED_RELEASE_REFERENCE_MAX_KEY_ID_LENGTH: usize = u16::MAX as usize;
32
33pub const CACHE_ENCRYPTION_DESCRIPTOR_VERSION: u8 = 1;
34pub const CACHE_ENCRYPTION_ALGORITHM_XCHACHA20POLY1305: &str = "xchacha20-poly1305";
35pub const CACHE_KEY_DERIVATION_HKDF_SHA256: &str = "hkdf-sha256";
36pub const CACHE_ENCRYPTION_SECRET_LENGTH: usize = 32;
37pub const CACHE_ENCRYPTION_RECORD_BINDING_HASH_LENGTH: usize = 32;
38pub const CACHE_ENCRYPTION_NONCE_LENGTH: usize = 24;
39pub const CACHE_ENCRYPTION_TAG_LENGTH: usize = 16;
40pub const CACHE_ENCRYPTION_ENVELOPE_MAGIC: &[u8; 4] = b"BCE1";
41pub const CACHE_ENCRYPTION_ENVELOPE_VERSION: u8 = 1;
42pub const CACHE_ENCRYPTION_ENVELOPE_ALGORITHM_XCHACHA20POLY1305: u8 = 1;
43pub const CACHE_ENCRYPTION_INFO: &[u8] = b"bitneedle-cache-encryption-v1";
44pub const CACHE_ENCRYPTION_NONCE_INFO: &[u8] = b"bitneedle-cache-encryption-nonce-v1";
45pub const CACHE_ENCRYPTION_AAD_DOMAIN: &[u8] = b"bitneedle-cache-encryption-aad-v1";
46pub const CACHE_ENCRYPTION_NONCE_DOMAIN: &[u8] = b"bitneedle-cache-nonce-v1";
47
48pub const RECORD_PROFILE_SINGLE45_CODE: u8 = 0;
49pub const RECORD_PROFILE_LP_CODE: u8 = 1;
50pub const RECORD_PROFILE_SINGLE45: &str = "single45";
51pub const RECORD_PROFILE_LP: &str = "lp";
52
53pub const RELEASE_ID_LENGTH: usize = 16;
54
55pub const SEGMENT_DESCRIPTOR_CRC32: u8 = 1;
56pub const SEGMENT_STREAM_BYTE_LENGTH: u8 = 2;
57pub const SEGMENT_RECORD_PROFILE: u8 = 4;
58pub const SEGMENT_TITLE: u8 = 5;
59pub const SEGMENT_ARTIST: u8 = 6;
60pub const SEGMENT_PAYLOAD_ENCODING: u8 = 7;
61pub const SEGMENT_RELEASE_ID: u8 = 8;
62pub const SEGMENT_CATALOG_NUMBER: u8 = 9;
63pub const SEGMENT_LABEL: u8 = 10;
64pub const SEGMENT_ARTWORK_CREDIT: u8 = 11;
65pub const SEGMENT_CANONICAL_URL: u8 = 13;
66pub const SEGMENT_CREATED_AT: u8 = 14;
67pub const SEGMENT_SIGNED_RELEASE_REFERENCE: u8 = 16;
68pub const SEGMENT_BSC_POINTER: u8 = 21;
69pub const SEGMENT_TONED_CARRIER_MAP: u8 = 22;
70pub const SEGMENT_CACHE_ENCRYPTION: u8 = 23;
71pub const SEGMENT_COPYRIGHT_YEAR: u8 = 24;
72pub const SEGMENT_COPYRIGHT_HOLDER: u8 = 25;
73
74pub const PAYLOAD_ENCODING_RGB: &str = "rgb";
75pub const PAYLOAD_ENCODING_TONED_V1: &str = "toned-v1";
76pub const PAYLOAD_ENCODING_RGB_CODE: u8 = 0;
77pub const PAYLOAD_ENCODING_TONED_V1_CODE: u8 = 1;
78
79pub const TONED_CARRIER_MAP_VERSION: u8 = 1;
80pub const TONED_ORDERING_BASE_PROXIMITY: u8 = 0;
81pub const TONED_ORDERING_CHROMA_PROXIMITY: u8 = 1;
82pub const TONED_MIN_BITS_PER_PIXEL: u8 = 1;
83pub const TONED_MAX_BITS_PER_PIXEL: u8 = 24;
84pub const TONED_MAX_SPAN_COUNT: usize = u16::MAX as usize;
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87pub enum CacheEncryptionAlgorithm {
88    #[serde(rename = "xchacha20-poly1305")]
89    XChaCha20Poly1305,
90}
91
92impl CacheEncryptionAlgorithm {
93    pub fn wire_code(self) -> u8 {
94        match self {
95            Self::XChaCha20Poly1305 => CACHE_ENCRYPTION_ENVELOPE_ALGORITHM_XCHACHA20POLY1305,
96        }
97    }
98
99    pub fn from_wire_code(code: u8) -> Result<Self> {
100        match code {
101            CACHE_ENCRYPTION_ENVELOPE_ALGORITHM_XCHACHA20POLY1305 => Ok(Self::XChaCha20Poly1305),
102            _ => bail!("unsupported cache encryption algorithm code {code}"),
103        }
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108pub enum CacheKeyDerivation {
109    #[serde(rename = "hkdf-sha256")]
110    HkdfSha256,
111}
112
113impl CacheKeyDerivation {
114    pub fn wire_code(self) -> u8 {
115        match self {
116            Self::HkdfSha256 => 1,
117        }
118    }
119
120    pub fn from_wire_code(code: u8) -> Result<Self> {
121        match code {
122            1 => Ok(Self::HkdfSha256),
123            _ => bail!("unsupported cache key derivation code {code}"),
124        }
125    }
126}
127
128fn serialize_secret_base64url<S>(secret: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
129where
130    S: serde::Serializer,
131{
132    serializer.serialize_str(&URL_SAFE_NO_PAD.encode(secret))
133}
134
135fn deserialize_secret_base64url<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
136where
137    D: serde::Deserializer<'de>,
138{
139    let text = String::deserialize(deserializer)?;
140    URL_SAFE_NO_PAD
141        .decode(text.as_bytes())
142        .map_err(serde::de::Error::custom)
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct CacheEncryptionDescriptor {
148    pub version: u8,
149    pub algorithm: CacheEncryptionAlgorithm,
150    pub key_derivation: CacheKeyDerivation,
151    #[serde(
152        serialize_with = "serialize_secret_base64url",
153        deserialize_with = "deserialize_secret_base64url"
154    )]
155    pub secret: Vec<u8>,
156}
157
158impl CacheEncryptionDescriptor {
159    pub fn validate(&self) -> Result<()> {
160        if self.version != CACHE_ENCRYPTION_DESCRIPTOR_VERSION {
161            bail!(
162                "unsupported cache encryption descriptor version: {}",
163                self.version
164            );
165        }
166        match self.algorithm {
167            CacheEncryptionAlgorithm::XChaCha20Poly1305 => {}
168        }
169        match self.key_derivation {
170            CacheKeyDerivation::HkdfSha256 => {}
171        }
172        if self.secret.len() != CACHE_ENCRYPTION_SECRET_LENGTH {
173            bail!(
174                "cache encryption secret must be exactly {} bytes",
175                CACHE_ENCRYPTION_SECRET_LENGTH
176            );
177        }
178        Ok(())
179    }
180
181    pub fn secret(&self) -> &[u8] {
182        self.secret.as_slice()
183    }
184
185    pub fn from_secret_base64url(secret: &str) -> Result<Self> {
186        let secret = URL_SAFE_NO_PAD
187            .decode(secret.as_bytes())
188            .context("cache encryption secret is not valid base64url")?;
189        let descriptor = Self {
190            version: CACHE_ENCRYPTION_DESCRIPTOR_VERSION,
191            algorithm: CacheEncryptionAlgorithm::XChaCha20Poly1305,
192            key_derivation: CacheKeyDerivation::HkdfSha256,
193            secret,
194        };
195        descriptor.validate()?;
196        Ok(descriptor)
197    }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct CacheEncryptionContext {
203    pub protocol_version: u8,
204    pub cache_format_version: u8,
205    pub cache_store_name: String,
206    pub cache_key: String,
207    pub chunk_index: u64,
208    pub packet_offset: u64,
209    pub plaintext_length: usize,
210    pub codec_identifier: String,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct CacheEncryptionEnvelope {
215    pub version: u8,
216    pub algorithm: u8,
217    pub flags: u16,
218    pub record_binding_hash: [u8; CACHE_ENCRYPTION_RECORD_BINDING_HASH_LENGTH],
219    pub chunk_index: u64,
220    pub packet_offset: u64,
221    pub plaintext_length: u32,
222    pub nonce: [u8; CACHE_ENCRYPTION_NONCE_LENGTH],
223    pub ciphertext: Vec<u8>,
224}
225
226impl CacheEncryptionEnvelope {
227    pub const HEADER_LENGTH: usize = 4
228        + 1
229        + 1
230        + 2
231        + CACHE_ENCRYPTION_RECORD_BINDING_HASH_LENGTH
232        + 8
233        + 8
234        + 4
235        + CACHE_ENCRYPTION_NONCE_LENGTH;
236
237    pub fn parse(bytes: &[u8]) -> Result<Self> {
238        if bytes.len() < Self::HEADER_LENGTH + CACHE_ENCRYPTION_TAG_LENGTH {
239            bail!("invalid BCE1 envelope: truncated header or ciphertext");
240        }
241        if bytes.get(0..4) != Some(CACHE_ENCRYPTION_ENVELOPE_MAGIC.as_slice()) {
242            bail!("invalid BCE1 envelope: magic mismatch");
243        }
244
245        let version = bytes[4];
246        if version != CACHE_ENCRYPTION_ENVELOPE_VERSION {
247            bail!("unsupported BCE1 envelope version {version}");
248        }
249
250        let algorithm = bytes[5];
251        if algorithm != CACHE_ENCRYPTION_ENVELOPE_ALGORITHM_XCHACHA20POLY1305 {
252            bail!("unsupported BCE1 envelope algorithm {algorithm}");
253        }
254
255        let flags = u16::from_be_bytes(bytes[6..8].try_into().expect("slice length"));
256        let record_binding_hash = bytes[8..40].try_into().expect("slice length");
257        let chunk_index = u64::from_be_bytes(bytes[40..48].try_into().expect("slice length"));
258        let packet_offset = u64::from_be_bytes(bytes[48..56].try_into().expect("slice length"));
259        let plaintext_length = u32::from_be_bytes(bytes[56..60].try_into().expect("slice length"));
260        let nonce = bytes[60..84].try_into().expect("slice length");
261        let ciphertext = bytes[84..].to_vec();
262
263        if plaintext_length == 0 {
264            bail!("invalid BCE1 envelope: empty plaintext length");
265        }
266        if ciphertext.len() != plaintext_length as usize + CACHE_ENCRYPTION_TAG_LENGTH {
267            bail!("invalid BCE1 envelope: ciphertext length mismatch");
268        }
269
270        Ok(Self {
271            version,
272            algorithm,
273            flags,
274            record_binding_hash,
275            chunk_index,
276            packet_offset,
277            plaintext_length,
278            nonce,
279            ciphertext,
280        })
281    }
282
283    pub fn encode(&self) -> Result<Vec<u8>> {
284        if self.version != CACHE_ENCRYPTION_ENVELOPE_VERSION {
285            bail!("unsupported BCE1 envelope version {}", self.version);
286        }
287        if self.algorithm != CACHE_ENCRYPTION_ENVELOPE_ALGORITHM_XCHACHA20POLY1305 {
288            bail!("unsupported BCE1 envelope algorithm {}", self.algorithm);
289        }
290        if self.plaintext_length == 0 {
291            bail!("invalid BCE1 envelope: empty plaintext length");
292        }
293        if self.ciphertext.len() != self.plaintext_length as usize + CACHE_ENCRYPTION_TAG_LENGTH {
294            bail!("invalid BCE1 envelope: ciphertext length mismatch");
295        }
296
297        let mut out = Vec::with_capacity(Self::HEADER_LENGTH + self.ciphertext.len());
298        out.extend_from_slice(CACHE_ENCRYPTION_ENVELOPE_MAGIC);
299        out.push(self.version);
300        out.push(self.algorithm);
301        out.extend_from_slice(&self.flags.to_be_bytes());
302        out.extend_from_slice(&self.record_binding_hash);
303        out.extend_from_slice(&self.chunk_index.to_be_bytes());
304        out.extend_from_slice(&self.packet_offset.to_be_bytes());
305        out.extend_from_slice(&self.plaintext_length.to_be_bytes());
306        out.extend_from_slice(&self.nonce);
307        out.extend_from_slice(&self.ciphertext);
308        Ok(out)
309    }
310}
311
312fn push_u8(out: &mut Vec<u8>, value: u8) {
313    out.push(value);
314}
315
316fn push_u16(out: &mut Vec<u8>, value: u16) {
317    out.extend_from_slice(&value.to_be_bytes());
318}
319
320fn push_u32(out: &mut Vec<u8>, value: u32) {
321    out.extend_from_slice(&value.to_be_bytes());
322}
323
324fn push_u64(out: &mut Vec<u8>, value: u64) {
325    out.extend_from_slice(&value.to_be_bytes());
326}
327
328#[allow(dead_code)]
329fn push_len_prefixed_bytes(out: &mut Vec<u8>, tag: u8, bytes: &[u8]) {
330    out.push(tag);
331    push_u32(out, u32::try_from(bytes.len()).unwrap_or(u32::MAX));
332    out.extend_from_slice(bytes);
333}
334
335fn push_len_prefixed_string(out: &mut Vec<u8>, tag: u8, value: Option<&str>) {
336    out.push(tag);
337    match value {
338        Some(value) => {
339            let bytes = value.as_bytes();
340            push_u32(out, u32::try_from(bytes.len()).unwrap_or(u32::MAX));
341            out.extend_from_slice(bytes);
342        }
343        None => push_u32(out, 0),
344    }
345}
346
347fn push_len_prefixed_u8_slice<const N: usize>(out: &mut Vec<u8>, tag: u8, value: Option<&[u8; N]>) {
348    out.push(tag);
349    match value {
350        Some(value) => {
351            push_u32(out, N as u32);
352            out.extend_from_slice(value);
353        }
354        None => push_u32(out, 0),
355    }
356}
357
358fn cache_encryption_identity_bytes(descriptor: &RecordDescriptor) -> Result<Vec<u8>> {
359    let mut out = Vec::new();
360    out.extend_from_slice(b"bitneedle.record-descriptor.cache-identity.v1");
361    push_u8(&mut out, descriptor.version);
362    push_u8(&mut out, u8::from(descriptor.checksum_protected));
363    push_u64(&mut out, descriptor.b_value_bits);
364    push_len_prefixed_string(&mut out, 1, Some(&descriptor.record_profile));
365    push_u64(&mut out, descriptor.stream_byte_length as u64);
366    push_len_prefixed_string(&mut out, 2, Some(&descriptor.payload_encoding));
367    push_len_prefixed_string(&mut out, 3, descriptor.title.as_deref());
368    push_len_prefixed_string(&mut out, 4, descriptor.artist.as_deref());
369    push_len_prefixed_u8_slice(&mut out, 5, descriptor.release_id.as_ref());
370    push_len_prefixed_string(&mut out, 6, descriptor.catalog_number.as_deref());
371    push_len_prefixed_string(&mut out, 7, descriptor.label.as_deref());
372    push_len_prefixed_string(&mut out, 8, descriptor.artwork_credit.as_deref());
373    push_len_prefixed_string(&mut out, 9, descriptor.canonical_url.as_deref());
374    out.push(10);
375    match descriptor.created_at {
376        Some(value) => {
377            push_u32(&mut out, 8);
378            push_u64(&mut out, value);
379        }
380        None => push_u32(&mut out, 0),
381    }
382    out.push(12);
383    match descriptor.bsc_pointer.as_ref() {
384        Some(pointer) => {
385            push_u32(
386                &mut out,
387                u32::try_from(pointer.len()).context("BSC pointer exceeds u32")?,
388            );
389            out.extend_from_slice(pointer);
390        }
391        None => push_u32(&mut out, 0),
392    }
393    out.push(13);
394    push_u32(
395        &mut out,
396        u32::try_from(descriptor.tone_spans.len()).context("tone span count exceeds u32")?,
397    );
398    for span in &descriptor.tone_spans {
399        push_u32(
400            &mut out,
401            u32::try_from(span.byte_length).context("tone span byte length exceeds u32")?,
402        );
403        out.extend_from_slice(&span.base);
404        push_u8(&mut out, span.luma_tolerance);
405        push_u8(&mut out, span.bits_per_pixel);
406        push_u8(&mut out, span.ordering.wire_code());
407    }
408    out.push(14);
409    match descriptor.copyright_year {
410        Some(value) => {
411            push_u32(&mut out, 2);
412            push_u16(&mut out, value);
413        }
414        None => push_u32(&mut out, 0),
415    }
416    push_len_prefixed_string(&mut out, 15, descriptor.copyright_holder.as_deref());
417    Ok(out)
418}
419
420pub fn cache_encryption_record_binding_hash(
421    descriptor: &RecordDescriptor,
422) -> Result<[u8; CACHE_ENCRYPTION_RECORD_BINDING_HASH_LENGTH]> {
423    let identity = cache_encryption_identity_bytes(descriptor)?;
424    Ok(Sha256::digest(identity).into())
425}
426
427pub fn derive_cache_encryption_key(descriptor: &RecordDescriptor) -> Result<[u8; 32]> {
428    let cache_encryption = descriptor
429        .cache_encryption
430        .as_ref()
431        .context("record descriptor is missing cache encryption descriptor")?;
432    cache_encryption.validate()?;
433
434    let salt = cache_encryption_record_binding_hash(descriptor)?;
435    Ok(hkdf_sha256_32(
436        &salt,
437        cache_encryption.secret(),
438        CACHE_ENCRYPTION_INFO,
439    ))
440}
441
442/// Subkey used only to derive the per-entry nonce, kept separate from the AEAD
443/// key itself (same salt/secret, distinct HKDF `info` label).
444fn derive_cache_nonce_key(descriptor: &RecordDescriptor) -> Result<[u8; 32]> {
445    let cache_encryption = descriptor
446        .cache_encryption
447        .as_ref()
448        .context("record descriptor is missing cache encryption descriptor")?;
449    cache_encryption.validate()?;
450
451    let salt = cache_encryption_record_binding_hash(descriptor)?;
452    Ok(hkdf_sha256_32(
453        &salt,
454        cache_encryption.secret(),
455        CACHE_ENCRYPTION_NONCE_INFO,
456    ))
457}
458
459/// Deterministic nonce: a PRF over the plaintext (hashed) and enough of the
460/// cache context to disambiguate entries, keyed by a subkey derived from the
461/// record's own cache-encryption secret. The same (record, plaintext, context)
462/// always produces the same nonce, so the same triple always produces
463/// byte-identical ciphertext — this is what makes the resulting BCE1 envelope
464/// safe to use as a content-addressed cache key, and what two independent
465/// writers (e.g. press after issuance, then a player's own first decode)
466/// converge on. Nonce reuse under a fixed key only ever happens for identical
467/// plaintext, which is the intended convergent-encryption property here, not a
468/// confidentiality regression: the plaintext (decoded audio) is already fully
469/// recoverable by anyone holding the record image.
470fn derive_cache_nonce(
471    nonce_key: &[u8; 32],
472    context: &CacheEncryptionContext,
473    plaintext: &[u8],
474) -> [u8; CACHE_ENCRYPTION_NONCE_LENGTH] {
475    let plaintext_hash: [u8; 32] = Sha256::digest(plaintext).into();
476    let mut input =
477        Vec::with_capacity(CACHE_ENCRYPTION_NONCE_DOMAIN.len() + 32 + context.cache_key.len() + 20);
478    input.extend_from_slice(CACHE_ENCRYPTION_NONCE_DOMAIN);
479    input.extend_from_slice(&plaintext_hash);
480    push_len_prefixed_string(&mut input, 1, Some(&context.cache_key));
481    push_u64(&mut input, context.chunk_index);
482    push_u64(&mut input, context.packet_offset);
483    let mac = hmac_sha256(nonce_key, &input);
484    let mut nonce = [0u8; CACHE_ENCRYPTION_NONCE_LENGTH];
485    nonce.copy_from_slice(&mac[..CACHE_ENCRYPTION_NONCE_LENGTH]);
486    nonce
487}
488
489fn hex_encode(bytes: &[u8]) -> String {
490    const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
491    let mut out = String::with_capacity(bytes.len() * 2);
492    for byte in bytes {
493        out.push(HEX_DIGITS[(byte >> 4) as usize] as char);
494        out.push(HEX_DIGITS[(byte & 0x0f) as usize] as char);
495    }
496    out
497}
498
499/// Hex-encoded record binding hash, exposed so callers (e.g. the JS cache
500/// layer, via the wasm bindings) can fold the exact same record-identity
501/// notion the encryption itself uses into a pre-decode, record-scoped cache
502/// lookup key, without reimplementing the identity hash.
503pub fn cache_encryption_record_binding_hash_hex(descriptor: &RecordDescriptor) -> Result<String> {
504    Ok(hex_encode(&cache_encryption_record_binding_hash(
505        descriptor,
506    )?))
507}
508
509fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
510    const BLOCK_SIZE: usize = 64;
511    let mut key_block = [0u8; BLOCK_SIZE];
512    if key.len() > BLOCK_SIZE {
513        let hashed: [u8; 32] = Sha256::digest(key).into();
514        key_block[..hashed.len()].copy_from_slice(&hashed);
515    } else {
516        key_block[..key.len()].copy_from_slice(key);
517    }
518
519    let mut inner_pad = [0u8; BLOCK_SIZE];
520    let mut outer_pad = [0u8; BLOCK_SIZE];
521    for index in 0..BLOCK_SIZE {
522        inner_pad[index] = key_block[index] ^ 0x36;
523        outer_pad[index] = key_block[index] ^ 0x5c;
524    }
525
526    let mut inner = Sha256::new();
527    inner.update(inner_pad);
528    inner.update(data);
529    let inner_digest = inner.finalize();
530
531    let mut outer = Sha256::new();
532    outer.update(outer_pad);
533    outer.update(inner_digest);
534    outer.finalize().into()
535}
536
537fn hkdf_sha256_32(salt: &[u8], ikm: &[u8], info: &[u8]) -> [u8; 32] {
538    let prk = hmac_sha256(salt, ikm);
539    let mut okm_input = Vec::with_capacity(info.len() + 1);
540    okm_input.extend_from_slice(info);
541    okm_input.push(1);
542    hmac_sha256(&prk, &okm_input)
543}
544
545pub fn cache_encryption_aad(
546    descriptor: &RecordDescriptor,
547    context: &CacheEncryptionContext,
548) -> Result<Vec<u8>> {
549    let binding_hash = cache_encryption_record_binding_hash(descriptor)?;
550    let mut out = Vec::new();
551    out.extend_from_slice(CACHE_ENCRYPTION_AAD_DOMAIN);
552    push_u8(&mut out, context.protocol_version);
553    push_u8(&mut out, context.cache_format_version);
554    out.extend_from_slice(&binding_hash);
555    push_len_prefixed_string(&mut out, 1, Some(&context.cache_store_name));
556    push_len_prefixed_string(&mut out, 2, Some(&context.cache_key));
557    push_u64(&mut out, context.chunk_index);
558    push_u64(&mut out, context.packet_offset);
559    push_u64(
560        &mut out,
561        u64::try_from(context.plaintext_length).context("plaintext length exceeds u64")?,
562    );
563    push_len_prefixed_string(&mut out, 3, Some(&context.codec_identifier));
564    Ok(out)
565}
566
567pub fn encrypt_cache_envelope(
568    descriptor: &RecordDescriptor,
569    context: &CacheEncryptionContext,
570    plaintext: &[u8],
571) -> Result<Vec<u8>> {
572    if plaintext.is_empty() {
573        bail!("cache plaintext must not be empty");
574    }
575    if plaintext.len() != context.plaintext_length {
576        bail!("cache plaintext length mismatch");
577    }
578    if !descriptor
579        .cache_encryption
580        .as_ref()
581        .is_some_and(|value| value.validate().is_ok())
582    {
583        return Err(anyhow::anyhow!(
584            "record descriptor is missing a valid cache encryption descriptor"
585        ));
586    }
587
588    let key = derive_cache_encryption_key(descriptor)?;
589    let nonce_key = derive_cache_nonce_key(descriptor)?;
590    let nonce = derive_cache_nonce(&nonce_key, context, plaintext);
591    let aad = cache_encryption_aad(descriptor, context)?;
592    let ciphertext = XChaCha20Poly1305::new(Key::from_slice(&key))
593        .encrypt(
594            XNonce::from_slice(&nonce),
595            AeadPayload {
596                msg: plaintext,
597                aad: &aad,
598            },
599        )
600        .map_err(|_| anyhow::anyhow!("failed to encrypt cache payload"))?;
601
602    let envelope = CacheEncryptionEnvelope {
603        version: CACHE_ENCRYPTION_ENVELOPE_VERSION,
604        algorithm: CACHE_ENCRYPTION_ENVELOPE_ALGORITHM_XCHACHA20POLY1305,
605        flags: 0,
606        record_binding_hash: cache_encryption_record_binding_hash(descriptor)?,
607        chunk_index: context.chunk_index,
608        packet_offset: context.packet_offset,
609        plaintext_length: u32::try_from(plaintext.len()).context("plaintext length exceeds u32")?,
610        nonce,
611        ciphertext,
612    };
613    envelope.encode()
614}
615
616pub fn decrypt_cache_envelope(
617    descriptor: &RecordDescriptor,
618    context: &CacheEncryptionContext,
619    envelope_bytes: &[u8],
620) -> Result<Vec<u8>> {
621    let envelope = CacheEncryptionEnvelope::parse(envelope_bytes)?;
622    let expected_binding_hash = cache_encryption_record_binding_hash(descriptor)?;
623    if envelope.record_binding_hash != expected_binding_hash {
624        bail!("record binding hash mismatch");
625    }
626    let mut resolved_context = context.clone();
627    resolved_context.chunk_index = envelope.chunk_index;
628    resolved_context.packet_offset = envelope.packet_offset;
629    resolved_context.plaintext_length = envelope.plaintext_length as usize;
630    let key = derive_cache_encryption_key(descriptor)?;
631    let aad = cache_encryption_aad(descriptor, &resolved_context)?;
632    XChaCha20Poly1305::new(Key::from_slice(&key))
633        .decrypt(
634            XNonce::from_slice(&envelope.nonce),
635            AeadPayload {
636                msg: &envelope.ciphertext,
637                aad: &aad,
638            },
639        )
640        .map_err(|_| anyhow::anyhow!("cache authentication failed"))
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
644#[serde(rename_all = "camelCase")]
645pub enum ToneOrdering {
646    BaseProximity,
647    ChromaProximity,
648}
649
650impl ToneOrdering {
651    pub fn wire_code(self) -> u8 {
652        match self {
653            Self::BaseProximity => TONED_ORDERING_BASE_PROXIMITY,
654            Self::ChromaProximity => TONED_ORDERING_CHROMA_PROXIMITY,
655        }
656    }
657
658    pub fn from_wire_code(code: u8) -> Result<Self> {
659        match code {
660            TONED_ORDERING_BASE_PROXIMITY => Ok(Self::BaseProximity),
661            TONED_ORDERING_CHROMA_PROXIMITY => Ok(Self::ChromaProximity),
662            _ => bail!("unknown toned carrier ordering code {code}"),
663        }
664    }
665}
666
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668#[serde(rename_all = "camelCase")]
669pub struct ToneSpanDescriptor {
670    pub byte_length: usize,
671    pub base: [u8; 3],
672    pub luma_tolerance: u8,
673    pub bits_per_pixel: u8,
674    pub ordering: ToneOrdering,
675}
676
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678#[serde(rename_all = "camelCase")]
679pub struct ResolvedToneSpan {
680    pub index: usize,
681    pub byte_offset: usize,
682    pub byte_length: usize,
683    pub pixel_offset: usize,
684    pub pixel_count: usize,
685    pub base: [u8; 3],
686    pub luma_tolerance: u8,
687    pub bits_per_pixel: u8,
688    pub ordering: ToneOrdering,
689}
690
691/// One blanket signed-release reference covering the release commitment
692/// (see `record_core::commitment::release_commitment`). SHA-256 and Ed25519
693/// are fixed by `SIGNED_RELEASE_REFERENCE_VERSION`; there is no per-reference
694/// algorithm selector.
695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "camelCase")]
697pub struct SignedReleaseReference {
698    pub version: u8,
699    pub release_commitment_sha256: [u8; SIGNED_RELEASE_REFERENCE_HASH_LENGTH],
700    pub key_id: Vec<u8>,
701    pub signature: Vec<u8>,
702}
703
704impl SignedReleaseReference {
705    pub fn validate(&self) -> Result<()> {
706        if self.version != SIGNED_RELEASE_REFERENCE_VERSION {
707            bail!(
708                "unsupported signed release reference version: {}",
709                self.version
710            );
711        }
712        if self.key_id.is_empty() {
713            bail!("signature key ID must not be empty");
714        }
715        if self.key_id.len() > SIGNED_RELEASE_REFERENCE_MAX_KEY_ID_LENGTH {
716            bail!("signature key ID exceeds u16 length limit");
717        }
718        if self.signature.len() != SIGNED_RELEASE_REFERENCE_SIGNATURE_LENGTH {
719            bail!("signature must be exactly {SIGNED_RELEASE_REFERENCE_SIGNATURE_LENGTH} bytes");
720        }
721        Ok(())
722    }
723}
724
725pub fn encode_cache_encryption_descriptor(
726    cache_encryption: &CacheEncryptionDescriptor,
727) -> Result<Vec<u8>> {
728    cache_encryption.validate()?;
729    let mut out = Vec::with_capacity(4 + CACHE_ENCRYPTION_SECRET_LENGTH);
730    out.push(cache_encryption.version);
731    out.push(cache_encryption.algorithm.wire_code());
732    out.push(cache_encryption.key_derivation.wire_code());
733    out.push(
734        u8::try_from(cache_encryption.secret.len())
735            .context("cache encryption secret exceeds u8")?,
736    );
737    out.extend_from_slice(cache_encryption.secret());
738    Ok(out)
739}
740
741pub fn decode_cache_encryption_descriptor(bytes: &[u8]) -> Result<CacheEncryptionDescriptor> {
742    if bytes.len() < 4 {
743        bail!("cache encryption descriptor is truncated");
744    }
745    let version = bytes[0];
746    let algorithm = CacheEncryptionAlgorithm::from_wire_code(bytes[1])?;
747    let key_derivation = CacheKeyDerivation::from_wire_code(bytes[2])?;
748    let secret_len = usize::from(bytes[3]);
749    let secret = bytes[4..].to_vec();
750    if secret_len != secret.len() {
751        bail!("cache encryption secret length mismatch");
752    }
753    let descriptor = CacheEncryptionDescriptor {
754        version,
755        algorithm,
756        key_derivation,
757        secret,
758    };
759    descriptor.validate()?;
760    Ok(descriptor)
761}
762
763/// Decoded BRD1 carrier descriptor.
764///
765/// `record_profile` identifies the canonical Bitneedle carrier profile used to
766/// decode the raster geometry. BRD1 does not use this field to assign logical
767/// sample counts to BRS1 payload entries; programme timing belongs to BRS1 and
768/// codec-specific validation.
769#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
770#[serde(rename_all = "camelCase")]
771pub struct RecordDescriptor {
772    pub version: u8,
773    pub checksum_protected: bool,
774    pub b_value_bits: u64,
775    pub record_profile: String,
776    pub stream_byte_length: usize,
777    pub payload_encoding: String,
778    pub title: Option<String>,
779    pub artist: Option<String>,
780    pub release_id: Option<[u8; RELEASE_ID_LENGTH]>,
781    pub catalog_number: Option<String>,
782    pub label: Option<String>,
783    pub artwork_credit: Option<String>,
784    pub canonical_url: Option<String>,
785    pub created_at: Option<u64>,
786    /// Phonographic (℗) copyright year — the P-line year shown in credits.
787    pub copyright_year: Option<u16>,
788    /// Phonographic (â„—) copyright holder text (e.g. the artist, optionally with
789    /// a licensing clause). Distinct from `label` (the record label).
790    pub copyright_holder: Option<String>,
791    pub signed_release_reference: Option<SignedReleaseReference>,
792    pub bsc_pointer: Option<Vec<u8>>,
793    pub tone_spans: Vec<ToneSpanDescriptor>,
794    pub cache_encryption: Option<CacheEncryptionDescriptor>,
795}
796
797impl RecordDescriptor {
798    pub fn b_value(&self) -> f64 {
799        f64::from_bits(self.b_value_bits)
800    }
801
802    pub fn cache_encryption(&self) -> Option<&CacheEncryptionDescriptor> {
803        self.cache_encryption.as_ref()
804    }
805
806    pub fn validate_cache_encryption(&self) -> Result<()> {
807        if let Some(cache_encryption) = self.cache_encryption.as_ref() {
808            cache_encryption.validate()?;
809        }
810        Ok(())
811    }
812}
813
814#[derive(Debug, Clone, PartialEq, Eq)]
815pub struct DescriptorPrefix {
816    pub version: u8,
817    pub payload_len: usize,
818    pub segment_count: usize,
819    pub segment_stream_len: usize,
820    pub b_value_bits: u64,
821}
822
823pub fn metadata_pixel_count_for_byte_length(byte_length: usize) -> usize {
824    byte_length.saturating_mul(2)
825}
826
827pub fn metadata_byte_capacity_for_pixel_count(pixel_count: usize) -> usize {
828    pixel_count / 2
829}
830
831pub fn metadata_bytes_from_grayscale_rgba(
832    rgba: &[u8],
833    indices: &[usize],
834    byte_length: usize,
835    label: &str,
836) -> Result<Vec<u8>> {
837    let pixel_count = metadata_pixel_count_for_byte_length(byte_length);
838    if indices.len() < pixel_count {
839        bail!("{label} spiral capacity is too small");
840    }
841
842    let mut bytes = Vec::with_capacity(byte_length);
843    for byte_number in 0..byte_length {
844        let mut nibbles = [0u8; 2];
845        for nibble_index in 0..2 {
846            let pixel_index = indices[byte_number * 2 + nibble_index];
847            let rgba_index = pixel_index
848                .checked_mul(4)
849                .context("metadata RGBA index overflow")?;
850            if rgba_index + 3 >= rgba.len() {
851                bail!("{label} spiral pixel index is outside RGBA buffer");
852            }
853
854            let red = rgba[rgba_index];
855            let green = rgba[rgba_index + 1];
856            let blue = rgba[rgba_index + 2];
857            let alpha = rgba[rgba_index + 3];
858
859            if alpha == 0 {
860                bail!("{label} spiral pixel is empty");
861            }
862            if red != green || green != blue {
863                bail!("{label} metadata pixel is not grayscale");
864            }
865
866            let nibble = red
867                .checked_sub(METADATA_GRAYSCALE_NIBBLE_BASE)
868                .context("metadata pixel is below grayscale nibble range")?;
869            if nibble > 0x0f {
870                bail!("{label} metadata pixel is outside grayscale nibble range");
871            }
872            nibbles[nibble_index] = nibble;
873        }
874        bytes.push((nibbles[0] << 4) | nibbles[1]);
875    }
876    Ok(bytes)
877}
878
879pub fn record_profile_code(record_profile: &str) -> Result<u8> {
880    match record_profile {
881        RECORD_PROFILE_SINGLE45 => Ok(RECORD_PROFILE_SINGLE45_CODE),
882        RECORD_PROFILE_LP => Ok(RECORD_PROFILE_LP_CODE),
883        other => bail!("unsupported canonical record profile {other}"),
884    }
885}
886
887pub fn record_profile_from_code(code: u8) -> Result<String> {
888    match code {
889        RECORD_PROFILE_SINGLE45_CODE => Ok(RECORD_PROFILE_SINGLE45.to_string()),
890        RECORD_PROFILE_LP_CODE => Ok(RECORD_PROFILE_LP.to_string()),
891        other => bail!("unknown record profile code {other}"),
892    }
893}
894
895pub fn payload_encoding_code(payload_encoding: &str) -> Result<u8> {
896    match payload_encoding {
897        PAYLOAD_ENCODING_RGB => Ok(PAYLOAD_ENCODING_RGB_CODE),
898        PAYLOAD_ENCODING_TONED_V1 => Ok(PAYLOAD_ENCODING_TONED_V1_CODE),
899        other => bail!("unsupported canonical payload encoding {other}"),
900    }
901}
902
903pub fn payload_encoding_from_code(code: u8) -> Result<String> {
904    match code {
905        PAYLOAD_ENCODING_RGB_CODE => Ok(PAYLOAD_ENCODING_RGB.to_string()),
906        PAYLOAD_ENCODING_TONED_V1_CODE => Ok(PAYLOAD_ENCODING_TONED_V1.to_string()),
907        other => bail!("unknown payload encoding code {other}"),
908    }
909}
910
911const RELEASE_ID_TAGGED_PREFIX: &str = "rel_";
912const RELEASE_ID_ULID_TEXT_LENGTH: usize = 26;
913const CROCKFORD_BASE32: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
914
915/// Parse a canonical `rel_`-tagged ULID string into its 16 raw bytes.
916pub fn release_id_to_bytes(text: &str) -> Result<[u8; RELEASE_ID_LENGTH]> {
917    let ulid_text = text
918        .strip_prefix(RELEASE_ID_TAGGED_PREFIX)
919        .context("release ID is missing the rel_ prefix")?;
920    if ulid_text.len() != RELEASE_ID_ULID_TEXT_LENGTH {
921        bail!("release ID must be 26 Crockford Base32 characters");
922    }
923
924    let mut bits: u128 = 0;
925    for (index, byte) in ulid_text.bytes().enumerate() {
926        let upper = byte.to_ascii_uppercase();
927        let digit = CROCKFORD_BASE32
928            .iter()
929            .position(|&candidate| candidate == upper)
930            .context("release ID contains a non-canonical Crockford Base32 character")?;
931
932        // A textual ULID contains 130 encoded bits but the value is only
933        // 128 bits. Therefore the first Crockford digit may contain only the
934        // low two bits (0..=7). Rejecting larger values prevents silent u128
935        // truncation and ensures one canonical text representation per value.
936        if index == 0 && digit > 7 {
937            bail!("release ID exceeds the 128-bit ULID range");
938        }
939
940        bits = (bits << 5) | digit as u128;
941    }
942    Ok(bits.to_be_bytes())
943}
944
945/// Format 16 raw release ULID bytes back into the canonical `rel_`-tagged
946/// display string.
947pub fn release_id_to_text(bytes: [u8; RELEASE_ID_LENGTH]) -> String {
948    let mut value = u128::from_be_bytes(bytes);
949    let mut chars = [b'0'; RELEASE_ID_ULID_TEXT_LENGTH];
950    for index in (0..RELEASE_ID_ULID_TEXT_LENGTH).rev() {
951        chars[index] = CROCKFORD_BASE32[(value & 0x1f) as usize];
952        value >>= 5;
953    }
954    let mut text =
955        String::with_capacity(RELEASE_ID_TAGGED_PREFIX.len() + RELEASE_ID_ULID_TEXT_LENGTH);
956    text.push_str(RELEASE_ID_TAGGED_PREFIX);
957    text.push_str(std::str::from_utf8(&chars).expect("Crockford Base32 alphabet is ASCII"));
958    text
959}
960
961pub fn decode_descriptor_prefix(bytes: &[u8]) -> Result<DescriptorPrefix> {
962    if bytes.len() < RECORD_DESCRIPTOR_PREFIX_LENGTH {
963        bail!("record descriptor payload too short");
964    }
965    if &bytes[..4] != RECORD_DESCRIPTOR_MAGIC {
966        bail!("record descriptor magic mismatch");
967    }
968
969    let version = bytes[4];
970    let payload_len = u16::from_be_bytes(bytes[5..7].try_into().expect("slice length")) as usize;
971    let segment_count = u16::from_be_bytes(bytes[7..9].try_into().expect("slice length")) as usize;
972    let segment_stream_len =
973        u16::from_be_bytes(bytes[9..11].try_into().expect("slice length")) as usize;
974    let b_value_bits = u64::from_be_bytes(bytes[11..19].try_into().expect("slice length"));
975
976    if payload_len < RECORD_DESCRIPTOR_PREFIX_LENGTH || payload_len > bytes.len() {
977        bail!("record descriptor payload length is invalid");
978    }
979
980    Ok(DescriptorPrefix {
981        version,
982        payload_len,
983        segment_count,
984        segment_stream_len,
985        b_value_bits,
986    })
987}
988
989pub fn validate_tone_span(span: &ToneSpanDescriptor, index: usize) -> Result<()> {
990    if span.byte_length == 0 {
991        bail!("tone span {index} byte length must be greater than zero");
992    }
993    if !(TONED_MIN_BITS_PER_PIXEL..=TONED_MAX_BITS_PER_PIXEL).contains(&span.bits_per_pixel) {
994        bail!(
995            "tone span {index} bits per pixel must be between {} and {}",
996            TONED_MIN_BITS_PER_PIXEL,
997            TONED_MAX_BITS_PER_PIXEL
998        );
999    }
1000    Ok(())
1001}
1002
1003pub fn resolve_tone_spans(
1004    spans: &[ToneSpanDescriptor],
1005    expected_byte_length: Option<usize>,
1006) -> Result<Vec<ResolvedToneSpan>> {
1007    if spans.is_empty() {
1008        bail!("toned-v1 carrier map must contain at least one span");
1009    }
1010    if spans.len() > TONED_MAX_SPAN_COUNT {
1011        bail!("tone span count exceeds u16 range");
1012    }
1013
1014    let mut byte_offset = 0usize;
1015    let mut pixel_offset = 0usize;
1016    let mut resolved = Vec::with_capacity(spans.len());
1017
1018    for (index, span) in spans.iter().enumerate() {
1019        validate_tone_span(span, index)?;
1020        let bit_length = span
1021            .byte_length
1022            .checked_mul(8)
1023            .context("tone span bit length overflow")?;
1024        let pixel_count = bit_length.div_ceil(usize::from(span.bits_per_pixel));
1025
1026        resolved.push(ResolvedToneSpan {
1027            index,
1028            byte_offset,
1029            byte_length: span.byte_length,
1030            pixel_offset,
1031            pixel_count,
1032            base: span.base,
1033            luma_tolerance: span.luma_tolerance,
1034            bits_per_pixel: span.bits_per_pixel,
1035            ordering: span.ordering,
1036        });
1037
1038        byte_offset = byte_offset
1039            .checked_add(span.byte_length)
1040            .context("tone span total byte length overflow")?;
1041        pixel_offset = pixel_offset
1042            .checked_add(pixel_count)
1043            .context("tone span total pixel count overflow")?;
1044    }
1045
1046    if let Some(expected) = expected_byte_length {
1047        if byte_offset != expected {
1048            bail!("tone spans cover {byte_offset} bytes, expected {expected}");
1049        }
1050    }
1051
1052    Ok(resolved)
1053}
1054
1055pub fn toned_pixel_count(
1056    spans: &[ToneSpanDescriptor],
1057    expected_byte_length: Option<usize>,
1058) -> Result<usize> {
1059    Ok(resolve_tone_spans(spans, expected_byte_length)?
1060        .last()
1061        .map(|span| span.pixel_offset + span.pixel_count)
1062        .unwrap_or(0))
1063}
1064
1065pub fn encode_toned_carrier_map(
1066    spans: &[ToneSpanDescriptor],
1067    expected_byte_length: Option<usize>,
1068) -> Result<Vec<u8>> {
1069    resolve_tone_spans(spans, expected_byte_length)?;
1070
1071    let mut out = Vec::new();
1072    out.push(TONED_CARRIER_MAP_VERSION);
1073    out.extend_from_slice(
1074        &u16::try_from(spans.len())
1075            .context("tone span count exceeds u16")?
1076            .to_be_bytes(),
1077    );
1078
1079    for span in spans {
1080        push_varuint(
1081            &mut out,
1082            u64::try_from(span.byte_length).context("tone span byte length exceeds u64")?,
1083        );
1084        out.extend_from_slice(&span.base);
1085        out.push(span.luma_tolerance);
1086        out.push(span.bits_per_pixel);
1087        out.push(span.ordering.wire_code());
1088    }
1089
1090    Ok(out)
1091}
1092
1093pub fn decode_toned_carrier_map(
1094    bytes: &[u8],
1095    expected_byte_length: Option<usize>,
1096) -> Result<Vec<ToneSpanDescriptor>> {
1097    let mut cursor = ByteCursor::new(bytes);
1098    let version = cursor.read_u8("toned carrier map version")?;
1099    if version != TONED_CARRIER_MAP_VERSION {
1100        bail!("unsupported toned carrier map version {version}");
1101    }
1102
1103    let count = usize::from(cursor.read_u16be("tone span count")?);
1104    if count == 0 {
1105        bail!("toned-v1 carrier map must contain at least one span");
1106    }
1107
1108    let mut spans = Vec::with_capacity(count);
1109    for index in 0..count {
1110        let byte_length = usize::try_from(cursor.read_varuint("tone span byte length")?)
1111            .context("tone span byte length exceeds usize")?;
1112        let base = [
1113            cursor.read_u8("tone span base red")?,
1114            cursor.read_u8("tone span base green")?,
1115            cursor.read_u8("tone span base blue")?,
1116        ];
1117        let luma_tolerance = cursor.read_u8("tone span luma tolerance")?;
1118        let bits_per_pixel = cursor.read_u8("tone span bits per pixel")?;
1119        let ordering = ToneOrdering::from_wire_code(cursor.read_u8("tone span ordering")?)?;
1120
1121        let span = ToneSpanDescriptor {
1122            byte_length,
1123            base,
1124            luma_tolerance,
1125            bits_per_pixel,
1126            ordering,
1127        };
1128        validate_tone_span(&span, index)?;
1129        spans.push(span);
1130    }
1131
1132    if cursor.remaining() != 0 {
1133        bail!(
1134            "toned carrier map contains {} trailing bytes",
1135            cursor.remaining()
1136        );
1137    }
1138
1139    resolve_tone_spans(&spans, expected_byte_length)?;
1140    Ok(spans)
1141}
1142
1143fn push_varuint(out: &mut Vec<u8>, mut value: u64) {
1144    loop {
1145        let mut byte = (value & 0x7f) as u8;
1146        value >>= 7;
1147        if value != 0 {
1148            byte |= 0x80;
1149        }
1150        out.push(byte);
1151        if value == 0 {
1152            break;
1153        }
1154    }
1155}
1156
1157pub fn decode_signed_release_reference(bytes: &[u8]) -> Result<SignedReleaseReference> {
1158    let mut cursor = ByteCursor::new(bytes);
1159
1160    let version = cursor.read_u8("signed release reference version")?;
1161    let release_commitment_sha256 = cursor
1162        .read_bytes(
1163            SIGNED_RELEASE_REFERENCE_HASH_LENGTH,
1164            "release commitment SHA-256",
1165        )?
1166        .try_into()
1167        .expect("length checked");
1168    let key_id_len = cursor.read_u16be("signature key ID length")? as usize;
1169    let key_id = cursor.read_bytes(key_id_len, "signature key ID")?.to_vec();
1170    let signature = cursor
1171        .read_bytes(SIGNED_RELEASE_REFERENCE_SIGNATURE_LENGTH, "signature")?
1172        .to_vec();
1173
1174    if cursor.remaining() != 0 {
1175        bail!(
1176            "signed release reference contains {} trailing bytes",
1177            cursor.remaining()
1178        );
1179    }
1180
1181    let reference = SignedReleaseReference {
1182        version,
1183        release_commitment_sha256,
1184        key_id,
1185        signature,
1186    };
1187    reference.validate()?;
1188    Ok(reference)
1189}
1190
1191pub fn decode_record_descriptor_bytes(bytes: &[u8]) -> Result<RecordDescriptor> {
1192    let prefix = decode_descriptor_prefix(bytes)?;
1193
1194    if prefix.version != RECORD_DESCRIPTOR_VERSION {
1195        bail!("record descriptor version mismatch");
1196    }
1197    if prefix.payload_len != RECORD_DESCRIPTOR_PREFIX_LENGTH + prefix.segment_stream_len {
1198        bail!("record descriptor segment stream length mismatch");
1199    }
1200
1201    let body = &bytes[RECORD_DESCRIPTOR_PREFIX_LENGTH..prefix.payload_len];
1202    let mut offset = 0usize;
1203    let mut parsed_segments = 0usize;
1204
1205    let mut crc32_range = None;
1206    let mut crc32 = None;
1207    let mut stream_byte_length = None;
1208    let mut record_profile = None;
1209    let mut payload_encoding = None;
1210    let mut title = None;
1211    let mut artist = None;
1212    let mut release_id = None;
1213    let mut catalog_number = None;
1214    let mut label = None;
1215    let mut artwork_credit = None;
1216    let mut canonical_url = None;
1217    let mut created_at = None;
1218    let mut copyright_year = None;
1219    let mut copyright_holder = None;
1220    let mut signed_release_reference = None;
1221    let mut bsc_pointer = None;
1222    let mut tone_spans = None;
1223    let mut cache_encryption = None;
1224
1225    while offset < body.len() {
1226        if parsed_segments >= prefix.segment_count {
1227            bail!("record descriptor contains more segments than declared");
1228        }
1229        if offset + 3 > body.len() {
1230            bail!("record descriptor segment is truncated");
1231        }
1232
1233        let kind = body[offset];
1234        let len = u16::from_be_bytes(
1235            body[offset + 1..offset + 3]
1236                .try_into()
1237                .expect("slice length"),
1238        ) as usize;
1239        let payload_start = offset + 3;
1240        let payload_end = payload_start
1241            .checked_add(len)
1242            .context("record descriptor segment length overflow")?;
1243        if payload_end > body.len() {
1244            bail!("record descriptor segment payload is truncated");
1245        }
1246
1247        let payload = &body[payload_start..payload_end];
1248        match kind {
1249            SEGMENT_DESCRIPTOR_CRC32 => {
1250                if crc32.is_some() {
1251                    bail!("duplicate record descriptor CRC32 segment");
1252                }
1253                if payload.len() != 4 {
1254                    bail!("record descriptor CRC32 segment has invalid length");
1255                }
1256                crc32 = Some(u32::from_be_bytes(
1257                    payload.try_into().expect("slice length"),
1258                ));
1259                let absolute_start = RECORD_DESCRIPTOR_PREFIX_LENGTH + payload_start;
1260                crc32_range = Some(absolute_start..absolute_start + payload.len());
1261            }
1262            SEGMENT_STREAM_BYTE_LENGTH => {
1263                if stream_byte_length.is_some() {
1264                    bail!("duplicate stream byte length segment");
1265                }
1266                if payload.len() != 4 {
1267                    bail!("stream byte length segment has invalid length");
1268                }
1269                let raw_len = u32::from_be_bytes(payload.try_into().expect("slice length"));
1270                if raw_len == 0 {
1271                    bail!("stream byte length must not be zero");
1272                }
1273                stream_byte_length = Some(raw_len as usize);
1274            }
1275            SEGMENT_RECORD_PROFILE => {
1276                if payload.len() != 1 {
1277                    bail!("record profile segment has invalid length");
1278                }
1279                assign_once(
1280                    &mut record_profile,
1281                    record_profile_from_code(payload[0])?,
1282                    "record profile",
1283                )?
1284            }
1285            SEGMENT_PAYLOAD_ENCODING => {
1286                if payload.len() != 1 {
1287                    bail!("payload encoding segment has invalid length");
1288                }
1289                assign_once(
1290                    &mut payload_encoding,
1291                    payload_encoding_from_code(payload[0])?,
1292                    "payload encoding",
1293                )?
1294            }
1295            SEGMENT_TITLE => {
1296                assign_once(&mut title, decode_optional_text(payload, "title")?, "title")?
1297            }
1298            SEGMENT_ARTIST => assign_once(
1299                &mut artist,
1300                decode_optional_text(payload, "artist")?,
1301                "artist",
1302            )?,
1303            SEGMENT_RELEASE_ID => {
1304                if payload.len() != RELEASE_ID_LENGTH {
1305                    bail!("release ID segment has invalid length");
1306                }
1307                assign_once(
1308                    &mut release_id,
1309                    <[u8; RELEASE_ID_LENGTH]>::try_from(payload).expect("length checked"),
1310                    "release ID",
1311                )?
1312            }
1313            SEGMENT_CATALOG_NUMBER => assign_once(
1314                &mut catalog_number,
1315                decode_optional_text(payload, "catalog number")?,
1316                "catalog number",
1317            )?,
1318            SEGMENT_LABEL => {
1319                assign_once(&mut label, decode_optional_text(payload, "label")?, "label")?
1320            }
1321            SEGMENT_ARTWORK_CREDIT => assign_once(
1322                &mut artwork_credit,
1323                decode_optional_text(payload, "artwork credit")?,
1324                "artwork credit",
1325            )?,
1326            SEGMENT_CANONICAL_URL => assign_once(
1327                &mut canonical_url,
1328                decode_optional_text(payload, "canonical URL")?,
1329                "canonical URL",
1330            )?,
1331            SEGMENT_CREATED_AT => {
1332                if payload.len() != 8 {
1333                    bail!("created-at segment has invalid length");
1334                }
1335                assign_once(
1336                    &mut created_at,
1337                    u64::from_be_bytes(payload.try_into().expect("slice length")),
1338                    "created-at timestamp",
1339                )?
1340            }
1341            SEGMENT_COPYRIGHT_YEAR => {
1342                if payload.len() != 2 {
1343                    bail!("copyright-year segment has invalid length");
1344                }
1345                assign_once(
1346                    &mut copyright_year,
1347                    u16::from_be_bytes(payload.try_into().expect("slice length")),
1348                    "copyright year",
1349                )?
1350            }
1351            SEGMENT_COPYRIGHT_HOLDER => assign_once(
1352                &mut copyright_holder,
1353                decode_optional_text(payload, "copyright holder")?,
1354                "copyright holder",
1355            )?,
1356            SEGMENT_SIGNED_RELEASE_REFERENCE => {
1357                if signed_release_reference.is_some() {
1358                    bail!("duplicate signed release reference segment");
1359                }
1360                signed_release_reference = Some(decode_signed_release_reference(payload)?);
1361            }
1362            SEGMENT_BSC_POINTER => {
1363                if bsc_pointer.is_some() {
1364                    bail!("duplicate BSC pointer segment");
1365                }
1366                if payload.is_empty() {
1367                    bail!("BSC pointer segment must not be empty");
1368                }
1369                bsc_pointer = Some(payload.to_vec());
1370            }
1371            SEGMENT_TONED_CARRIER_MAP => {
1372                if tone_spans.is_some() {
1373                    bail!("duplicate toned carrier map segment");
1374                }
1375                tone_spans = Some(decode_toned_carrier_map(payload, None)?);
1376            }
1377            SEGMENT_CACHE_ENCRYPTION => {
1378                if cache_encryption.is_some() {
1379                    bail!("duplicate cache encryption segment");
1380                }
1381                cache_encryption = Some(decode_cache_encryption_descriptor(payload)?);
1382            }
1383            _ => bail!("unsupported canonical record descriptor segment type {kind}"),
1384        }
1385
1386        offset = payload_end;
1387        parsed_segments += 1;
1388    }
1389
1390    if parsed_segments != prefix.segment_count {
1391        bail!(
1392            "record descriptor segment count mismatch: declared {}, parsed {}",
1393            prefix.segment_count,
1394            parsed_segments
1395        );
1396    }
1397
1398    let expected = crc32.context("record descriptor CRC32 segment is missing")?;
1399    let range = crc32_range.context("record descriptor CRC32 segment is missing")?;
1400    let mut canonical = bytes[..prefix.payload_len].to_vec();
1401    canonical[range].fill(0);
1402
1403    if compute_descriptor_crc32(&canonical) != expected {
1404        bail!("record descriptor CRC32 mismatch");
1405    }
1406
1407    let b_value = f64::from_bits(prefix.b_value_bits);
1408    if !(b_value.is_finite() && b_value > 0.0) {
1409        bail!("decoded invalid b_value");
1410    }
1411
1412    let record_profile = record_profile.context("record profile segment is missing")?;
1413    let stream_byte_length = stream_byte_length.context("stream byte length segment is missing")?;
1414    let payload_encoding = payload_encoding.context("payload encoding segment is missing")?;
1415    let tone_spans = tone_spans.unwrap_or_default();
1416
1417    match payload_encoding.as_str() {
1418        PAYLOAD_ENCODING_RGB => {
1419            if !tone_spans.is_empty() {
1420                bail!("rgb payload encoding must not include a toned carrier map");
1421            }
1422        }
1423        PAYLOAD_ENCODING_TONED_V1 => {
1424            if tone_spans.is_empty() {
1425                bail!("toned-v1 payload encoding requires a toned carrier map");
1426            }
1427            resolve_tone_spans(&tone_spans, Some(stream_byte_length))?;
1428        }
1429        other => bail!("unsupported canonical payload encoding {other}"),
1430    }
1431
1432    Ok(RecordDescriptor {
1433        version: prefix.version,
1434        checksum_protected: true,
1435        b_value_bits: prefix.b_value_bits,
1436        record_profile,
1437        stream_byte_length,
1438        payload_encoding,
1439        title: title.flatten(),
1440        artist: artist.flatten(),
1441        release_id,
1442        catalog_number: catalog_number.flatten(),
1443        label: label.flatten(),
1444        artwork_credit: artwork_credit.flatten(),
1445        canonical_url: canonical_url.flatten(),
1446        created_at,
1447        copyright_year,
1448        copyright_holder: copyright_holder.flatten(),
1449        signed_release_reference,
1450        bsc_pointer,
1451        tone_spans,
1452        cache_encryption,
1453    })
1454}
1455
1456pub fn compute_descriptor_crc32(bytes: &[u8]) -> u32 {
1457    record_core::crc32_ieee(bytes)
1458}
1459
1460fn decode_optional_text(payload: &[u8], label: &str) -> Result<Option<String>> {
1461    if payload.is_empty() {
1462        return Ok(None);
1463    }
1464    Ok(Some(decode_text(payload, label)?))
1465}
1466
1467fn decode_text(payload: &[u8], label: &str) -> Result<String> {
1468    let value = String::from_utf8(payload.to_vec())
1469        .with_context(|| format!("record descriptor {label} is not valid UTF-8"))?;
1470    if value.chars().any(char::is_control) {
1471        bail!("record descriptor {label} contains control characters");
1472    }
1473    Ok(value)
1474}
1475
1476fn assign_once<T>(destination: &mut Option<T>, value: T, label: &str) -> Result<()> {
1477    if destination.is_some() {
1478        bail!("duplicate {label} segment");
1479    }
1480    *destination = Some(value);
1481    Ok(())
1482}
1483
1484#[derive(Clone, Copy)]
1485struct ByteCursor<'a> {
1486    bytes: &'a [u8],
1487    offset: usize,
1488}
1489
1490impl<'a> ByteCursor<'a> {
1491    fn new(bytes: &'a [u8]) -> Self {
1492        Self { bytes, offset: 0 }
1493    }
1494
1495    fn remaining(self) -> usize {
1496        self.bytes.len().saturating_sub(self.offset)
1497    }
1498
1499    fn read_u8(&mut self, label: &str) -> Result<u8> {
1500        let value = *self
1501            .bytes
1502            .get(self.offset)
1503            .with_context(|| format!("{label} is truncated"))?;
1504        self.offset += 1;
1505        Ok(value)
1506    }
1507
1508    fn read_u16be(&mut self, label: &str) -> Result<u16> {
1509        let end = self
1510            .offset
1511            .checked_add(2)
1512            .with_context(|| format!("{label} offset overflow"))?;
1513        let bytes = self
1514            .bytes
1515            .get(self.offset..end)
1516            .with_context(|| format!("{label} is truncated"))?;
1517        self.offset = end;
1518        Ok(u16::from_be_bytes(
1519            bytes.try_into().expect("length checked"),
1520        ))
1521    }
1522
1523    fn read_varuint(&mut self, label: &str) -> Result<u64> {
1524        let start = self.offset;
1525        let mut value = 0u64;
1526        let mut shift = 0u32;
1527
1528        for byte_index in 0..10 {
1529            let byte = self.read_u8(label)?;
1530            let payload = u64::from(byte & 0x7f);
1531
1532            if shift == 63 && payload > 1 {
1533                bail!("{label} exceeds u64 range");
1534            }
1535
1536            value |= payload
1537                .checked_shl(shift)
1538                .with_context(|| format!("{label} shift overflow"))?;
1539
1540            if byte & 0x80 == 0 {
1541                let consumed = self.offset - start;
1542                if consumed > 1 {
1543                    let minimum = 1u64 << (7 * (consumed - 1));
1544                    if value < minimum {
1545                        bail!("{label} uses non-canonical overlong varuint encoding");
1546                    }
1547                }
1548                return Ok(value);
1549            }
1550
1551            shift += 7;
1552            if byte_index == 9 {
1553                bail!("{label} exceeds ten-byte varuint limit");
1554            }
1555        }
1556
1557        unreachable!()
1558    }
1559
1560    fn read_bytes(&mut self, length: usize, label: &str) -> Result<&'a [u8]> {
1561        let end = self
1562            .offset
1563            .checked_add(length)
1564            .with_context(|| format!("{label} length overflow"))?;
1565        let bytes = self
1566            .bytes
1567            .get(self.offset..end)
1568            .with_context(|| format!("{label} is truncated"))?;
1569        self.offset = end;
1570        Ok(bytes)
1571    }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576    use super::*;
1577    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1578
1579    fn test_descriptor(secret: Vec<u8>) -> RecordDescriptor {
1580        RecordDescriptor {
1581            version: RECORD_DESCRIPTOR_VERSION,
1582            checksum_protected: true,
1583            b_value_bits: 1.0f64.to_bits(),
1584            record_profile: RECORD_PROFILE_SINGLE45.to_string(),
1585            stream_byte_length: 4096,
1586            payload_encoding: PAYLOAD_ENCODING_RGB.to_string(),
1587            title: Some("Title".to_string()),
1588            artist: Some("Artist".to_string()),
1589            release_id: Some([0x11; RELEASE_ID_LENGTH]),
1590            catalog_number: Some("CAT-1".to_string()),
1591            label: Some("Label".to_string()),
1592            artwork_credit: Some("Credit".to_string()),
1593            canonical_url: Some("https://example.invalid/release".to_string()),
1594            created_at: Some(1_700_000_000),
1595            copyright_year: Some(2006),
1596            copyright_holder: Some("Artist".to_string()),
1597            signed_release_reference: None,
1598            bsc_pointer: Some(vec![1, 2, 3, 4]),
1599            tone_spans: Vec::new(),
1600            cache_encryption: Some(CacheEncryptionDescriptor {
1601                version: CACHE_ENCRYPTION_DESCRIPTOR_VERSION,
1602                algorithm: CacheEncryptionAlgorithm::XChaCha20Poly1305,
1603                key_derivation: CacheKeyDerivation::HkdfSha256,
1604                secret,
1605            }),
1606        }
1607    }
1608
1609    fn test_context() -> CacheEncryptionContext {
1610        CacheEncryptionContext {
1611            protocol_version: 1,
1612            cache_format_version: 1,
1613            cache_store_name: "opus-chunks".to_string(),
1614            cache_key: "0123456789abcdef".to_string(),
1615            chunk_index: 7,
1616            packet_offset: 2048,
1617            plaintext_length: 12,
1618            codec_identifier: "soundkit_opus_packets".to_string(),
1619        }
1620    }
1621
1622    #[test]
1623    fn record_profile_codes_round_trip() {
1624        assert_eq!(record_profile_code("single45").unwrap(), 0);
1625        assert_eq!(record_profile_code("lp").unwrap(), 1);
1626        assert_eq!(record_profile_from_code(0).unwrap(), "single45");
1627        assert_eq!(record_profile_from_code(1).unwrap(), "lp");
1628        assert!(record_profile_from_code(2).is_err());
1629    }
1630
1631    #[test]
1632    fn payload_encoding_codes_round_trip() {
1633        assert_eq!(payload_encoding_code("rgb").unwrap(), 0);
1634        assert_eq!(payload_encoding_code("toned-v1").unwrap(), 1);
1635        assert_eq!(payload_encoding_from_code(0).unwrap(), "rgb");
1636        assert_eq!(payload_encoding_from_code(1).unwrap(), "toned-v1");
1637        assert!(payload_encoding_from_code(2).is_err());
1638    }
1639
1640    #[test]
1641    fn release_id_text_round_trips_through_bytes() {
1642        let bytes = [
1643            0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60,
1644            0x70, 0x80,
1645        ];
1646        let text = release_id_to_text(bytes);
1647        assert!(text.starts_with("rel_"));
1648        assert_eq!(text.len(), 4 + 26);
1649        assert_eq!(release_id_to_bytes(&text).unwrap(), bytes);
1650    }
1651
1652    #[test]
1653    fn release_id_rejects_missing_prefix() {
1654        assert!(release_id_to_bytes("01ARZ3NDEKTSV4RRFFQ69G5FAV").is_err());
1655    }
1656
1657    #[test]
1658    fn release_id_rejects_values_above_the_ulid_range() {
1659        assert!(release_id_to_bytes("rel_Z1ARZ3NDEKTSV4RRFFQ69G5FAV").is_err());
1660    }
1661
1662    #[test]
1663    fn release_id_accepts_the_maximum_canonical_ulid() {
1664        let text = "rel_7ZZZZZZZZZZZZZZZZZZZZZZZZZ";
1665        let bytes = release_id_to_bytes(text).unwrap();
1666        assert_eq!(release_id_to_text(bytes), text);
1667    }
1668
1669    #[test]
1670    fn binary_reference_round_trips_through_decoder() {
1671        let mut bytes = Vec::new();
1672        bytes.push(SIGNED_RELEASE_REFERENCE_VERSION);
1673        bytes.extend_from_slice(&[0x11; 32]);
1674        bytes.extend_from_slice(&3u16.to_be_bytes());
1675        bytes.extend_from_slice(b"key");
1676        bytes.extend_from_slice(&[0x22; 64]);
1677
1678        let decoded = decode_signed_release_reference(&bytes).unwrap();
1679        assert_eq!(decoded.release_commitment_sha256, [0x11; 32]);
1680        assert_eq!(decoded.key_id, b"key");
1681        assert_eq!(decoded.signature, vec![0x22; 64]);
1682    }
1683    #[test]
1684    fn toned_carrier_map_round_trips() {
1685        let spans = vec![
1686            ToneSpanDescriptor {
1687                byte_length: 1024,
1688                base: [255, 192, 203],
1689                luma_tolerance: 16,
1690                bits_per_pixel: 21,
1691                ordering: ToneOrdering::ChromaProximity,
1692            },
1693            ToneSpanDescriptor {
1694                byte_length: 513,
1695                base: [20, 40, 80],
1696                luma_tolerance: 8,
1697                bits_per_pixel: 18,
1698                ordering: ToneOrdering::BaseProximity,
1699            },
1700        ];
1701
1702        let bytes = encode_toned_carrier_map(&spans, Some(1537)).unwrap();
1703        let decoded = decode_toned_carrier_map(&bytes, Some(1537)).unwrap();
1704
1705        assert_eq!(decoded, spans);
1706    }
1707
1708    #[test]
1709    fn toned_offsets_are_derived() {
1710        let spans = vec![
1711            ToneSpanDescriptor {
1712                byte_length: 5,
1713                base: [1, 2, 3],
1714                luma_tolerance: 0,
1715                bits_per_pixel: 8,
1716                ordering: ToneOrdering::BaseProximity,
1717            },
1718            ToneSpanDescriptor {
1719                byte_length: 7,
1720                base: [4, 5, 6],
1721                luma_tolerance: 1,
1722                bits_per_pixel: 4,
1723                ordering: ToneOrdering::ChromaProximity,
1724            },
1725        ];
1726
1727        let resolved = resolve_tone_spans(&spans, Some(12)).unwrap();
1728        assert_eq!(resolved[0].byte_offset, 0);
1729        assert_eq!(resolved[1].byte_offset, 5);
1730        assert_eq!(resolved[0].pixel_count, 5);
1731        assert_eq!(resolved[1].pixel_offset, 5);
1732        assert_eq!(resolved[1].pixel_count, 14);
1733    }
1734
1735    #[test]
1736    fn toned_map_rejects_overlong_varuint() {
1737        let bytes = [
1738            TONED_CARRIER_MAP_VERSION,
1739            0,
1740            1,
1741            0x81,
1742            0x00,
1743            0,
1744            0,
1745            0,
1746            0,
1747            8,
1748            TONED_ORDERING_BASE_PROXIMITY,
1749        ];
1750        assert!(decode_toned_carrier_map(&bytes, None).is_err());
1751    }
1752
1753    #[test]
1754    fn cache_encryption_descriptor_round_trips_through_json() {
1755        let descriptor = CacheEncryptionDescriptor {
1756            version: CACHE_ENCRYPTION_DESCRIPTOR_VERSION,
1757            algorithm: CacheEncryptionAlgorithm::XChaCha20Poly1305,
1758            key_derivation: CacheKeyDerivation::HkdfSha256,
1759            secret: vec![7u8; CACHE_ENCRYPTION_SECRET_LENGTH],
1760        };
1761        let json = serde_json::to_string(&descriptor).unwrap();
1762        let decoded: CacheEncryptionDescriptor = serde_json::from_str(&json).unwrap();
1763        assert_eq!(decoded, descriptor);
1764    }
1765
1766    #[test]
1767    fn cache_encryption_secret_must_be_32_bytes() {
1768        let mut descriptor = CacheEncryptionDescriptor {
1769            version: CACHE_ENCRYPTION_DESCRIPTOR_VERSION,
1770            algorithm: CacheEncryptionAlgorithm::XChaCha20Poly1305,
1771            key_derivation: CacheKeyDerivation::HkdfSha256,
1772            secret: vec![0u8; 31],
1773        };
1774        assert!(descriptor.validate().is_err());
1775        descriptor.secret = vec![0u8; 32];
1776        assert!(descriptor.validate().is_ok());
1777    }
1778
1779    #[test]
1780    fn cache_encryption_descriptor_rejects_malformed_base64url() {
1781        let json = r#"{"version":1,"algorithm":"xchacha20-poly1305","keyDerivation":"hkdf-sha256","secret":"not base64"}"#;
1782        assert!(serde_json::from_str::<CacheEncryptionDescriptor>(json).is_err());
1783    }
1784
1785    #[test]
1786    fn cache_encryption_descriptor_rejects_wrong_secret_length() {
1787        let json = format!(
1788            r#"{{"version":1,"algorithm":"xchacha20-poly1305","keyDerivation":"hkdf-sha256","secret":"{}"}}"#,
1789            URL_SAFE_NO_PAD.encode([1u8; 31])
1790        );
1791        let parsed: CacheEncryptionDescriptor = serde_json::from_str(&json).unwrap();
1792        assert!(parsed.validate().is_err());
1793    }
1794
1795    #[test]
1796    fn old_descriptors_without_cache_encryption_still_decode() {
1797        let descriptor = test_descriptor(vec![9u8; CACHE_ENCRYPTION_SECRET_LENGTH]);
1798        let json = serde_json::to_string(&descriptor).unwrap();
1799        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1800        value.as_object_mut().unwrap().remove("cacheEncryption");
1801        let decoded: RecordDescriptor = serde_json::from_value(value).unwrap();
1802        assert!(decoded.cache_encryption.is_none());
1803    }
1804
1805    #[test]
1806    fn cache_encryption_key_derivation_is_stable_and_bindable() {
1807        let descriptor = test_descriptor(vec![1u8; CACHE_ENCRYPTION_SECRET_LENGTH]);
1808        let key_a = derive_cache_encryption_key(&descriptor).unwrap();
1809        let key_b = derive_cache_encryption_key(&descriptor).unwrap();
1810        assert_eq!(key_a, key_b);
1811
1812        let mut other_secret = descriptor.clone();
1813        other_secret.cache_encryption.as_mut().unwrap().secret =
1814            vec![2u8; CACHE_ENCRYPTION_SECRET_LENGTH];
1815        assert_ne!(key_a, derive_cache_encryption_key(&other_secret).unwrap());
1816
1817        let mut other_record = descriptor.clone();
1818        other_record.release_id = Some([0x22; RELEASE_ID_LENGTH]);
1819        assert_ne!(key_a, derive_cache_encryption_key(&other_record).unwrap());
1820    }
1821
1822    #[test]
1823    fn cache_encryption_envelope_round_trips() {
1824        let descriptor = test_descriptor(vec![3u8; CACHE_ENCRYPTION_SECRET_LENGTH]);
1825        let context = test_context();
1826        let plaintext = b"opus-packets";
1827        let envelope = encrypt_cache_envelope(&descriptor, &context, plaintext).unwrap();
1828        let decrypted = decrypt_cache_envelope(&descriptor, &context, &envelope).unwrap();
1829        assert_eq!(decrypted, plaintext);
1830    }
1831
1832    #[test]
1833    fn cache_encryption_envelope_rejects_tampering() {
1834        let descriptor = test_descriptor(vec![5u8; CACHE_ENCRYPTION_SECRET_LENGTH]);
1835        let context = test_context();
1836        let plaintext = b"opus-packets";
1837        let mut envelope = encrypt_cache_envelope(&descriptor, &context, plaintext).unwrap();
1838
1839        envelope[CacheEncryptionEnvelope::HEADER_LENGTH] ^= 1;
1840        assert!(decrypt_cache_envelope(&descriptor, &context, &envelope).is_err());
1841
1842        let mut nonce_tampered = encrypt_cache_envelope(&descriptor, &context, plaintext).unwrap();
1843        nonce_tampered[60] ^= 1;
1844        assert!(decrypt_cache_envelope(&descriptor, &context, &nonce_tampered).is_err());
1845
1846        let mut binding_tampered =
1847            encrypt_cache_envelope(&descriptor, &context, plaintext).unwrap();
1848        binding_tampered[8] ^= 1;
1849        assert!(decrypt_cache_envelope(&descriptor, &context, &binding_tampered).is_err());
1850    }
1851
1852    #[test]
1853    fn cache_encryption_nonce_is_deterministic_and_content_bound() {
1854        let descriptor = test_descriptor(vec![9u8; CACHE_ENCRYPTION_SECRET_LENGTH]);
1855        let context = test_context();
1856        let plaintext = b"opus-packets";
1857
1858        let envelope_a = encrypt_cache_envelope(&descriptor, &context, plaintext).unwrap();
1859        let envelope_b = encrypt_cache_envelope(&descriptor, &context, plaintext).unwrap();
1860        assert_eq!(
1861            envelope_a, envelope_b,
1862            "same (record, plaintext, context) must produce byte-identical envelopes"
1863        );
1864
1865        let other_plaintext = b"opus-packet$";
1866        assert_eq!(other_plaintext.len(), plaintext.len());
1867        let envelope_c = encrypt_cache_envelope(&descriptor, &context, other_plaintext).unwrap();
1868        assert_ne!(
1869            envelope_a, envelope_c,
1870            "different plaintext must not reuse the same nonce/ciphertext"
1871        );
1872    }
1873
1874    #[test]
1875    fn cache_encryption_record_binding_hash_hex_differs_per_record() {
1876        let descriptor_a = test_descriptor(vec![1u8; CACHE_ENCRYPTION_SECRET_LENGTH]);
1877        let mut descriptor_b = descriptor_a.clone();
1878        descriptor_b.release_id = Some([0x33; RELEASE_ID_LENGTH]);
1879
1880        let hash_a = cache_encryption_record_binding_hash_hex(&descriptor_a).unwrap();
1881        let hash_b = cache_encryption_record_binding_hash_hex(&descriptor_b).unwrap();
1882        assert_eq!(hash_a.len(), 64);
1883        assert_ne!(hash_a, hash_b);
1884    }
1885}