Skip to main content

bevy_dlc/
lib.rs

1use base64::Engine as _;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use bevy::prelude::*;
4use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
5
6use secure_gate::{ExposeSecret, dynamic_alias, fixed_alias};
7
8mod asset_loader;
9pub mod encrypt_key_registry;
10mod ext;
11
12#[macro_use]
13mod macros;
14
15mod pack_format;
16
17pub use asset_loader::{DlcLoader, DlcPack, DlcPackLoader, EncryptedAsset, parse_encrypted};
18
19pub use pack_format::{
20    BlockMetadata, CompressionLevel, DEFAULT_BLOCK_SIZE, DLC_PACK_MAGIC, DLC_PACK_VERSION_LATEST,
21    ManifestEntry, V4ManifestEntry, is_data_executable, is_forbidden_extension,
22    pack_encrypted_pack, parse_encrypted_pack,
23};
24
25use serde::{Deserialize, Serialize};
26
27use thiserror::Error;
28
29use crate::asset_loader::DlcPackLoaded;
30
31pub use crate::ext::AppExt;
32
33/// Register an encryption key for a DLC ID in the global registry.
34/// This is used internally by the plugin and can be called by tools/CLI for key management.
35///
36/// # Arguments
37/// * `dlc_id` - The DLC identifier to associate with the key
38/// * `key` - The encryption key to register
39pub fn register_encryption_key(dlc_id: &str, key: EncryptionKey) {
40    encrypt_key_registry::insert(dlc_id, key);
41}
42
43pub mod prelude {
44    pub use crate::ext::*;
45    pub use crate::{
46        DlcError, DlcId, DlcKey, DlcLoader, DlcPack, DlcPackLoader, DlcPlugin, EncryptedAsset,
47        PackItem, Product, SignedLicense, asset_loader::DlcPackEntry, asset_loader::DlcPackLoaded,
48        is_dlc_entry_loaded, is_dlc_loaded,
49    };
50}
51
52/// Bevy plugin that enables DLC!
53///
54/// About `SignedLicense`:
55/// - `SignedLicense` is a zeroized `String` containing a compact
56///   offline-signed token (format: `payload_base64url.signature_base64url`).
57/// - Tokens are produced by `DlcKey::create_signed_license` (private key)
58///   or by platform tooling. When generated by a private `DlcKey` a token
59///   may include an embedded `encrypt_key` used to decrypt DLC assets at
60///   runtime — that symmetric key is not exposed by `VerifiedLicense`.
61/// - Treat `SignedLicense` as sensitive: provision it securely (platform,
62///   secure backend, or out-of-band) and do not leak raw secrets in logs.
63///
64/// Usage: provide the game with the public `DlcKey` and hand the signed
65/// license token (for example, from your platform or the CLI pack output)
66/// to the plugin; the plugin will extract and register encryption keys from it.
67pub struct DlcPlugin {
68    dlc_key: DlcKey,
69
70    signed_license: SignedLicense,
71}
72
73impl DlcPlugin {
74    /// Create the plugin from a `DlcKey` and a `SignedLicense`.
75    ///
76    /// The plugin will extract the encryption key from the signed license
77    /// during `build` and register it in the global key registry.
78    pub fn new(dlc_key: DlcKey, signed_license: SignedLicense) -> Self {
79        Self {
80            dlc_key,
81            signed_license,
82        }
83    }
84}
85
86impl Plugin for DlcPlugin {
87    fn build(&self, app: &mut App) {
88        if let Some(encrypt_key) = extract_encrypt_key_from_license(&self.signed_license) {
89            let dlcs = extract_dlc_ids_from_license(&self.signed_license);
90            for dlc_id in dlcs {
91                let key_for_dlc = encrypt_key.with_secret(|kb| EncryptionKey::new(*kb));
92                encrypt_key_registry::insert(&dlc_id, key_for_dlc);
93            }
94        }
95
96        app.init_resource::<asset_loader::DlcPackRegistrarFactories>();
97
98        app.insert_resource(self.dlc_key.clone())
99            .init_asset_loader::<asset_loader::DlcLoader<Image>>()
100            .init_asset_loader::<asset_loader::DlcLoader<Scene>>()
101            .init_asset_loader::<asset_loader::DlcLoader<Mesh>>()
102            .init_asset_loader::<asset_loader::DlcLoader<Font>>()
103            .init_asset_loader::<asset_loader::DlcLoader<AudioSource>>()
104            .init_asset_loader::<asset_loader::DlcLoader<ColorMaterial>>()
105            .init_asset_loader::<asset_loader::DlcLoader<StandardMaterial>>()
106            .init_asset_loader::<asset_loader::DlcLoader<Gltf>>()
107            .init_asset_loader::<asset_loader::DlcLoader<bevy::gltf::GltfMesh>>()
108            .init_asset_loader::<asset_loader::DlcLoader<Shader>>()
109            .init_asset_loader::<asset_loader::DlcLoader<DynamicScene>>()
110            .init_asset_loader::<asset_loader::DlcLoader<AnimationClip>>()
111            .init_asset_loader::<asset_loader::DlcLoader<AnimationGraph>>();
112
113        let factories = app
114            .world()
115            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
116            .cloned();
117        let pack_loader = asset_loader::DlcPackLoader {
118            registrars: asset_loader::collect_pack_registrars(factories.as_ref()),
119            factories,
120        };
121
122        app.register_asset_loader(pack_loader);
123        app.init_asset::<asset_loader::DlcPack>();
124
125        app.add_systems(Update, trigger_dlc_events);
126    }
127}
128
129/// System that monitors `AssetEvent<DlcPack>` and triggers observer-friendly events.
130fn trigger_dlc_events(
131    mut events: MessageReader<AssetEvent<DlcPack>>,
132    packs: Res<Assets<DlcPack>>,
133    mut commands: Commands,
134) {
135    for event in events.read() {
136        match event {
137            AssetEvent::Added { id } => {
138                if let Some(pack) = packs.get(*id) {
139                    let dlc_id = pack.id().clone();
140                    commands.trigger(DlcPackLoaded::new(dlc_id.clone(), pack.clone()));
141                }
142            }
143            _ => {}
144        }
145    }
146}
147
148/// A Bevy system condition that returns `true` when a DLC pack has been loaded.
149///
150/// A DLC is considered loaded when `DlcPackLoader` has successfully loaded a `.dlcpack` file
151/// containing that `DlcId`.
152///
153/// This is useful for gating systems that should only run when specific DLC content is actually
154/// available.
155///
156/// # Example
157/// ```ignore
158/// use bevy::prelude::*;
159/// use bevy_dlc::is_dlc_loaded;
160///
161/// fn spawn_dlc_content() {}
162///
163/// let mut app = App::new();
164/// app.add_systems(Update, spawn_dlc_content.run_if(is_dlc_loaded("dlcA")));
165/// ```
166pub fn is_dlc_loaded(dlc_id: impl Into<DlcId>) -> impl Fn() -> bool + Send + Sync + 'static {
167    let id_string = dlc_id.into().0;
168    move || !encrypt_key_registry::asset_path_for(&id_string).is_none()
169}
170
171/// A Bevy system condition that returns `true` when a specific DLC pack entry is loaded.
172pub fn is_dlc_entry_loaded(
173    dlc_id: impl Into<DlcId>,
174    entry: impl Into<String>,
175) -> impl Fn(Res<Assets<DlcPack>>) -> bool + Send + Sync + 'static {
176    let id_string = dlc_id.into().0;
177    let entry_name = entry.into();
178    move |dlc_packs: Res<Assets<DlcPack>>| {
179        if !encrypt_key_registry::asset_path_for(&id_string).is_none() {
180            dlc_packs
181                .iter()
182                .filter(|p| p.1.id() == &DlcId::from(id_string.clone()))
183                .any(|pack| pack.1.find_entry(&entry_name).is_some())
184        } else {
185            false
186        }
187    }
188}
189
190/// Strongly-typed DLC identifier (string-backed).
191#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
192#[serde(transparent)]
193pub struct DlcId(pub String);
194
195impl std::fmt::Display for DlcId {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        self.0.fmt(f)
198    }
199}
200
201impl Clone for DlcId {
202    fn clone(&self) -> Self {
203        DlcId(self.0.clone())
204    }
205}
206
207impl DlcId {
208    /// Return the underlying string slice.
209    pub fn as_str(&self) -> &str {
210        self.0.as_str()
211    }
212}
213
214impl From<&str> for DlcId {
215    fn from(s: &str) -> Self {
216        DlcId(s.to_owned())
217    }
218}
219
220impl From<String> for DlcId {
221    fn from(s: String) -> Self {
222        DlcId(s)
223    }
224}
225
226impl AsRef<str> for DlcId {
227    fn as_ref(&self) -> &str {
228        &self.0
229    }
230}
231
232fixed_alias!(pub PrivateKey, 32, "A secure wrapper for a 32-byte Ed25519 signing seed (private key) used to create signed licenses. This should be protected and never exposed in logs or error messages.");
233
234/// PublicKey wrapper (32 bytes)
235#[derive(Clone, Copy, PartialEq, Eq)]
236pub struct PublicKey(pub [u8; 32]);
237
238impl AsRef<[u8]> for PublicKey {
239    fn as_ref(&self) -> &[u8] {
240        &self.0
241    }
242}
243
244impl std::fmt::Debug for PublicKey {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        write!(f, "PublicKey({} bytes)", 32)
247    }
248}
249
250dynamic_alias!(pub SignedLicense, String, "A compact offline-signed token containing DLC ids and an optional embedded encrypt key. Treat as sensitive and do not leak raw secrets in logs.  This ");
251
252/// Extract the embedded encryption key from a signed license's payload (base64url-encoded).
253/// Returns `None` if the token is malformed or contains no encrypt_key.
254pub fn extract_encrypt_key_from_license(license: &SignedLicense) -> Option<EncryptionKey> {
255    license.with_secret(|token_str| {
256        let parts: Vec<&str> = token_str.split('.').collect();
257        if parts.len() != 2 {
258            return None;
259        }
260        let payload = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()).ok()?;
261        let payload_json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
262        let key_b64 = payload_json.get("encrypt_key").and_then(|v| v.as_str())?;
263        URL_SAFE_NO_PAD
264            .decode(key_b64.as_bytes())
265            .ok()
266            .and_then(|key_bytes| {
267                Some(EncryptionKey::new(key_bytes
268                    .try_into()
269                    .ok()?))
270            })
271    })
272}
273
274/// Extract the DLC IDs from a signed license's payload.
275/// Returns an empty vec if the token is malformed or contains no dlcs array.
276pub fn extract_dlc_ids_from_license(license: &SignedLicense) -> Vec<String> {
277    license.with_secret(|token_str| {
278        let parts: Vec<&str> = token_str.split('.').collect();
279        if parts.len() != 2 {
280            return Vec::new();
281        }
282        if let Ok(payload) = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()) {
283            if let Ok(payload_json) = serde_json::from_slice::<serde_json::Value>(&payload) {
284                if let Some(dlcs_array) = payload_json.get("dlcs").and_then(|v| v.as_array()) {
285                    let mut dlcs = Vec::new();
286                    for dlc in dlcs_array {
287                        if let Some(dlc_id) = dlc.as_str() {
288                            dlcs.push(dlc_id.to_string());
289                        }
290                    }
291                    return dlcs;
292                }
293            }
294        }
295        Vec::new()
296    })
297}
298
299/// Extract the `product` field from a signed license's payload.
300/// Returns `None` if the token is malformed or the field is missing.
301pub fn extract_product_from_license(license: &SignedLicense) -> Option<String> {
302    license.with_secret(|token_str| {
303        let parts: Vec<&str> = token_str.split('.').collect();
304        if parts.len() != 2 {
305            return None;
306        }
307        if let Ok(payload) = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()) {
308            if let Ok(payload_json) = serde_json::from_slice::<serde_json::Value>(&payload) {
309                if let Some(prod) = payload_json.get("product").and_then(|v| v.as_str()) {
310                    return Some(prod.to_string());
311                }
312            }
313        }
314        None
315    })
316}
317/// Product: non-secret identifier wrapper (keeps same API surface)
318#[derive(Resource, Clone, PartialEq, Eq, Debug)]
319pub struct Product(String);
320
321impl std::fmt::Display for Product {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        self.0.fmt(f)
324    }
325}
326
327impl AsRef<str> for Product {
328    fn as_ref(&self) -> &str {
329        &self.0
330    }
331}
332
333impl Product {
334    /// Return the underlying string slice.
335    pub fn as_str(&self) -> &str {
336        self.0.as_str()
337    }
338}
339
340impl From<String> for Product {
341    fn from(s: String) -> Self {
342        Product(s)
343    }
344}
345impl From<&str> for Product {
346    fn from(s: &str) -> Self {
347        Product(s.to_owned())
348    }
349}
350
351fixed_alias!(pub EncryptionKey, 32, "A secure encrypt key (symmetric key for encrypting DLC pack entries). This should be protected and never exposed in logs or error messages.");
352
353/// Client-side wrapper for Ed25519 key operations: verify tokens and (when
354/// private) create compact signed tokens.
355#[derive(Resource)]
356pub enum DlcKey {
357    /// Private key (protected seed + public bytes + public key tag)
358    Private {
359        /// Protected signing seed (secure wrapper)
360        privkey: PrivateKey,
361        /// Public key bytes (wrapped)
362        pubkey: PublicKey,
363    },
364
365    /// Public-only key (public bytes)
366    Public {
367        /// Public key bytes (wrapped)
368        pubkey: PublicKey,
369    },
370}
371
372impl DlcKey {
373    pub fn new(pubkey: &str, privkey: &str) -> Result<Self, DlcError> {
374        let decoded_pub = URL_SAFE_NO_PAD
375            .decode(pubkey.as_bytes())
376            .map_err(|e| DlcError::CryptoError(format!("invalid pubkey base64: {}", e)))?;
377        if decoded_pub.len() != 32 {
378            return Err(DlcError::CryptoError("public key must be 32 bytes".into()));
379        }
380        let mut pub_bytes = [0u8; 32];
381        pub_bytes.copy_from_slice(&decoded_pub);
382
383        let decoded_priv = URL_SAFE_NO_PAD
384            .decode(privkey.as_bytes())
385            .map_err(|e| DlcError::CryptoError(format!("invalid privkey base64: {}", e)))?;
386        if decoded_priv.len() != 32 {
387            return Err(DlcError::CryptoError("private key must be 32 bytes".into()));
388        }
389        let mut priv_bytes = [0u8; 32];
390        priv_bytes.copy_from_slice(&decoded_priv);
391
392        Self::from_priv_and_pub(PrivateKey::from(priv_bytes), PublicKey(pub_bytes))
393    }
394
395    /// Construct a `DlcKey::Public` from a base64url-encoded public key string.
396    pub fn public(pubkey: &str) -> Result<Self, DlcError> {
397        let decoded_pub = URL_SAFE_NO_PAD
398            .decode(pubkey.as_bytes())
399            .map_err(|e| DlcError::CryptoError(format!("invalid pubkey base64: {}", e)))?;
400        if decoded_pub.len() != 32 {
401            return Err(DlcError::CryptoError("public key must be 32 bytes".into()));
402        }
403        let mut pub_bytes = [0u8; 32];
404        pub_bytes.copy_from_slice(&decoded_pub);
405
406        Ok(DlcKey::Public {
407            pubkey: PublicKey(pub_bytes),
408        })
409    }
410
411    /// Construct a `DlcKey::Private` from a private key and public key.
412    pub(crate) fn from_priv_and_pub(
413        privkey: PrivateKey,
414        publickey: PublicKey,
415    ) -> Result<Self, DlcError> {
416        let kp = privkey
417            .with_secret(|priv_bytes| {
418                Ed25519KeyPair::from_seed_and_public_key(priv_bytes, &publickey.0)
419            })
420            .map_err(|e| DlcError::CryptoError(format!("invalid seed: {:?}", e)))?;
421        let mut pub_bytes = [0u8; 32];
422        pub_bytes.copy_from_slice(kp.public_key().as_ref());
423
424        privkey
425            .with_secret(|priv_bytes| {
426                Ed25519KeyPair::from_seed_and_public_key(priv_bytes, &pub_bytes)
427            })
428            .map_err(|e| DlcError::CryptoError(format!("keypair validation failed: {:?}", e)))?;
429
430        Ok(DlcKey::Private {
431            privkey,
432            pubkey: PublicKey(pub_bytes),
433        })
434    }
435
436    /// Generate a new `DlcKey::Private` with a random seed and derived public key.
437    ///
438    /// The public key is derived from the generated seed so the keypair is valid.
439    pub fn generate_random() -> Self {
440        let privkey: PrivateKey = PrivateKey::from_random();
441
442        let pair = privkey
443            .with_secret(|priv_bytes| Ed25519KeyPair::from_seed_unchecked(priv_bytes))
444            .expect("derive public key from seed");
445        let mut pub_bytes = [0u8; 32];
446        pub_bytes.copy_from_slice(pair.public_key().as_ref());
447
448        Self::from_priv_and_pub(privkey, PublicKey(pub_bytes))
449            .unwrap_or_else(|e| panic!("generate_complete failed: {:?}", e))
450    }
451
452    pub fn get_public_key(&self) -> &PublicKey {
453        match self {
454            DlcKey::Private { pubkey, .. } => pubkey,
455            DlcKey::Public { pubkey: public } => public,
456        }
457    }
458
459    /// Sign data using the private key.
460    /// Returns a 64-byte signature.
461    pub fn sign(&self, data: &[u8]) -> Result<[u8; 64], DlcError> {
462        match self {
463            DlcKey::Private { privkey, pubkey } => privkey.with_secret(|seed| {
464                let pair = Ed25519KeyPair::from_seed_and_public_key(seed, pubkey.as_ref())
465                    .map_err(|e| DlcError::CryptoError(e.to_string()))?;
466                let sig = pair.sign(data);
467                let mut sig_bytes = [0u8; 64];
468                sig_bytes.copy_from_slice(sig.as_ref());
469                Ok(sig_bytes)
470            }),
471            DlcKey::Public { .. } => Err(DlcError::PrivateKeyRequired),
472        }
473    }
474
475    /// Verify a 64-byte Ed25519 signature against provided data.
476    pub fn verify(&self, data: &[u8], signature: &[u8; 64]) -> Result<(), DlcError> {
477        let pubkey = self.get_public_key();
478        ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, pubkey.as_ref())
479            .verify(data, signature.as_ref())
480            .map_err(|_| DlcError::SignatureInvalid)
481    }
482
483    /// Create a compact offline-signed token that can be verified by this key's public key.
484    ///
485    /// Returns a `SignedLicense` (zeroized on drop). The license payload includes
486    /// the provided DLC ids and product binding.
487    /// Create a compact offline-signed token (SignedLicense).
488    pub fn create_signed_license<D>(
489        &self,
490        dlcs: impl IntoIterator<Item = D>,
491        product: Product,
492    ) -> Result<SignedLicense, DlcError>
493    where
494        D: std::fmt::Display,
495    {
496        let mut payload = serde_json::Map::new();
497        payload.insert(
498            "dlcs".to_string(),
499            serde_json::Value::Array(
500                dlcs.into_iter()
501                    .map(|s| serde_json::Value::String(s.to_string()))
502                    .collect(),
503            ),
504        );
505
506        payload.insert(
507            "product".to_string(),
508            serde_json::Value::String(product.as_ref().to_string()),
509        );
510
511        match self {
512            DlcKey::Private { privkey, .. } => {
513                let sig_token = privkey.with_secret(
514                    |encrypt_key_bytes| -> Result<SignedLicense, DlcError> {
515                        payload.insert(
516                            "encrypt_key".to_string(),
517                            serde_json::Value::String(URL_SAFE_NO_PAD.encode(encrypt_key_bytes)),
518                        );
519
520                        let payload_value = serde_json::Value::Object(payload);
521                        let payload_bytes = serde_json::to_vec(&payload_value)
522                            .map_err(|e| DlcError::TokenCreationFailed(e.to_string()))?;
523
524                        let sig = self.sign(&payload_bytes)?;
525                        Ok(SignedLicense::from(format!(
526                            "{}.{}",
527                            URL_SAFE_NO_PAD.encode(&payload_bytes),
528                            URL_SAFE_NO_PAD.encode(sig.as_ref())
529                        )))
530                    },
531                )?;
532                Ok(sig_token)
533            }
534            DlcKey::Public { .. } => Err(DlcError::PrivateKeyRequired),
535        }
536    }
537
538    /// Extend an existing signed license by merging additional DLC ids while preserving backwards compatibility.
539    ///
540    /// Extracts the DLC ids from an existing (unsigned or unverified) license payload, merges them
541    /// with the provided new DLC ids (with deduplication), and creates a fresh signed license
542    /// with the combined list. The product must match this key's current product.
543    ///
544    /// **Important**: This creates a NEW signed token with a potentially different signature,
545    /// but the payload contains all previous DLC ids plus the new ones. Existing DLC packs
546    /// remain unlocked by the new license.
547    ///
548    /// # Example
549    /// ```ignore
550    /// use bevy_dlc::{DlcKey, SignedLicense, Product};
551    ///
552    /// // in a real program you would obtain a key and product from your build
553    /// // process or configuration.  we use simple constructors here so the
554    /// // example compiles without panicking.
555    /// let dlc_key: DlcKey = DlcKey::generate_random();
556    /// let product: Product = Product::from("my_game");
557    ///
558    /// let old_license = SignedLicense::from("...existing token...");
559    /// let _new_license = dlc_key
560    ///     .extend_signed_license(&old_license, &["new_expansion"], product)
561    ///     .unwrap();
562    /// ```
563    pub fn extend_signed_license<D>(
564        &self,
565        existing: &SignedLicense,
566        new_dlcs: impl IntoIterator<Item = D>,
567        product: Product,
568    ) -> Result<SignedLicense, DlcError>
569    where
570        D: std::fmt::Display,
571    {
572        let mut combined_dlcs: Vec<String> = existing.with_secret(|token_str| {
573            let parts: Vec<&str> = token_str.split('.').collect();
574            if parts.len() != 2 {
575                return Vec::new();
576            }
577            if let Ok(payload) = URL_SAFE_NO_PAD.decode(parts[0].as_bytes()) {
578                if let Ok(payload_json) = serde_json::from_slice::<serde_json::Value>(&payload) {
579                    if let Some(dlcs_array) = payload_json.get("dlcs").and_then(|v| v.as_array()) {
580                        let mut dlcs = Vec::new();
581                        for dlc in dlcs_array {
582                            if let Some(dlc_id) = dlc.as_str() {
583                                dlcs.push(dlc_id.to_string());
584                            }
585                        }
586                        return dlcs;
587                    }
588                }
589            }
590            Vec::new()
591        });
592
593        for new_dlc in new_dlcs {
594            let dlc_str = new_dlc.to_string();
595            if !combined_dlcs.contains(&dlc_str) {
596                combined_dlcs.push(dlc_str);
597            }
598        }
599
600        self.create_signed_license(combined_dlcs, product)
601    }
602
603    /// Verify a compact signed-license (signature + payload) using this key's public key
604    /// Verify a compact signed-license using this key's public key.
605    ///
606    /// Returns `true` if the signature is valid and the token is well-formed,
607    /// `false` otherwise.  No further payload validation is performed.
608    pub fn verify_signed_license(&self, license: &SignedLicense) -> bool {
609        license.with_secret(|full_token| {
610            let parts: Vec<&str> = full_token.split('.').collect();
611            if parts.len() != 2 {
612                return false;
613            }
614
615            let payload = URL_SAFE_NO_PAD.decode(parts[0]);
616            let sig_bytes = URL_SAFE_NO_PAD.decode(parts[1]);
617            if payload.is_err() || sig_bytes.is_err() {
618                return false;
619            }
620            let payload = payload.unwrap();
621            let sig_bytes = sig_bytes.unwrap();
622
623            if sig_bytes.len() != 64 {
624                return false;
625            }
626
627            let public = self.get_public_key().0;
628            let public_key = UnparsedPublicKey::new(&ED25519, public);
629            public_key.verify(&payload, &sig_bytes).is_ok()
630        })
631    }
632}
633
634impl Clone for DlcKey {
635    fn clone(&self) -> Self {
636        match self {
637            DlcKey::Private { privkey, pubkey } => privkey.with_secret(|s| DlcKey::Private {
638                privkey: PrivateKey::new(*s),
639                pubkey: *pubkey,
640            }),
641            DlcKey::Public { pubkey } => DlcKey::Public { pubkey: *pubkey },
642        }
643    }
644}
645
646impl std::fmt::Display for DlcKey {
647    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648        write!(f, "{}", URL_SAFE_NO_PAD.encode(self.get_public_key().0))
649    }
650}
651
652impl From<&DlcKey> for String {
653    fn from(k: &DlcKey) -> Self {
654        k.to_string()
655    }
656}
657
658/// Helper struct for building DLC pack entries with optional metadata.
659/// Provides a builder pattern for creating entries to pack into a `.dlcpack` container.
660#[derive(Clone, Debug)]
661pub struct PackItem {
662    path: String,
663    original_extension: Option<String>,
664    type_path: Option<String>,
665    plaintext: Vec<u8>,
666}
667
668#[allow(dead_code)]
669impl PackItem {
670    pub fn new(path: impl Into<String>, plaintext: impl Into<Vec<u8>>) -> Result<Self, DlcError> {
671        let path = path.into();
672        let bytes = plaintext.into();
673
674        if bytes.len() >= 4 && bytes.starts_with(DLC_PACK_MAGIC) {
675            return Err(DlcError::Other(format!(
676                "cannot pack existing dlcpack container as an item: {}",
677                path
678            )));
679        }
680
681        if pack_format::is_data_executable(&bytes) {
682            return Err(DlcError::Other(format!(
683                "input data looks like an executable payload, which is not allowed: {}",
684                path
685            )));
686        }
687
688        let ext_str = std::path::Path::new(&path)
689            .extension()
690            .and_then(|e| e.to_str());
691
692        if let Some(ext) = ext_str {
693            if pack_format::is_forbidden_extension(ext) {
694                return Err(DlcError::Other(format!(
695                    "input path contains forbidden extension (.{}): {}",
696                    ext, path
697                )));
698            }
699        }
700
701        Ok(Self {
702            path: path.clone(),
703            original_extension: ext_str.map(|s| s.to_string()),
704            type_path: None,
705            plaintext: bytes,
706        })
707    }
708
709    pub fn with_extension(mut self, ext: impl Into<String>) -> Result<Self, DlcError> {
710        let ext_s = ext.into();
711        if pack_format::is_forbidden_extension(&ext_s) {
712            return Err(DlcError::Other(format!(
713                "forbidden extension (.{}): {}",
714                ext_s, self.path
715            )));
716        }
717        self.original_extension = Some(ext_s);
718        Ok(self)
719    }
720
721    pub fn with_type_path(mut self, type_path: impl Into<String>) -> Self {
722        self.type_path = Some(type_path.into());
723        self
724    }
725
726    pub fn with_type<T: Asset>(self) -> Self {
727        self.with_type_path(T::type_path())
728    }
729
730    /// Return the relative path for this item within the pack. This is used by the loader to determine how to register the decrypted asset.
731    pub fn path(&self) -> &str {
732        &self.path
733    }
734
735    /// Return the plaintext bytes for this item. This is the data that will be encrypted and stored in the pack.
736    pub fn plaintext(&self) -> &[u8] {
737        &self.plaintext
738    }
739
740    pub fn ext(&self) -> Option<String> {
741        if let Some(ext) = std::path::Path::new(&self.path)
742            .extension()
743            .and_then(|e| e.to_str())
744            .and_then(|s| {
745                if s.is_empty() {
746                    None
747                } else {
748                    Some(s.to_string())
749                }
750            })
751        {
752            Some(ext)
753        } else {
754            self.original_extension.clone()
755        }
756    }
757
758    pub fn type_path(&self) -> Option<String> {
759        self.type_path.clone()
760    }
761}
762
763impl From<PackItem> for (String, Option<String>, Option<String>, Vec<u8>) {
764    fn from(item: PackItem) -> Self {
765        (
766            item.path,
767            item.original_extension,
768            item.type_path,
769            item.plaintext,
770        )
771    }
772}
773
774/// Parse a `.dlcpack` container and return product, embedded dlc_id, and a list
775/// of `(path, EncryptedAsset)` pairs. For v3 format, also validates the signature
776/// against the authorized product public key.
777///
778/// Returns: (product, dlc_id, entries, signature_bytes_if_v3)
779
780#[derive(Error, Debug, Clone)]
781pub enum DlcError {
782    #[error("invalid public key: {0}")]
783    InvalidPublicKey(String),
784    #[error("malformed private key: {0}")]
785    MalformedLicense(String),
786    #[error("signature verification failed")]
787    SignatureInvalid,
788    #[error("payload parse failed: {0}")]
789    PayloadInvalid(String),
790
791    #[error("private key creation failed: {0}")]
792    TokenCreationFailed(String),
793    #[error("private key required for this operation")]
794    PrivateKeyRequired,
795    #[error("invalid public key")]
796    InvalidPassphrase,
797    #[error("crypto error: {0}")]
798    CryptoError(String),
799
800    #[error("encryption failed: {0}")]
801    EncryptionFailed(String),
802    #[error("{0}")]
803    DecryptionFailed(String),
804    #[error("invalid encrypt key: {0}")]
805    InvalidEncryptKey(String),
806    #[error("invalid nonce: {0}")]
807    InvalidNonce(String),
808
809    #[error("dlc locked: {0}")]
810    DlcLocked(String),
811    #[error("no encrypt key for dlc: {0}")]
812    NoEncryptKey(String),
813
814    /// The DLC pack being loaded is cryptographically valid but the product it is bound to does not match the expected product. This indicates a mismatch between the pack and the game's registered product key.
815    #[error("private key product does not match")]
816    TokenProductMismatch,
817
818    /// The version of the pack being loaded is older than the minimum supported version. The string contains the minimum supported version (e.g. "3").
819    #[error("deprecated version: v{0}")]
820    DeprecatedVersion(String),
821
822    #[error("{0}")]
823    Other(String),
824}
825
826// convenience conversions to reduce boilerplate
827impl From<std::io::Error> for DlcError {
828    fn from(e: std::io::Error) -> Self {
829        DlcError::Other(e.to_string())
830    }
831}
832
833impl From<ring::error::Unspecified> for DlcError {
834    fn from(e: ring::error::Unspecified) -> Self {
835        DlcError::CryptoError(format!("crypto error: {:?}", e))
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use crate::ext::*;
842
843    use super::*;
844
845    #[test]
846    fn pack_encrypted_pack_rejects_nested_dlc() {
847        let mut v = Vec::new();
848        v.extend_from_slice(DLC_PACK_MAGIC);
849        v.extend_from_slice(b"inner");
850        let err = PackItem::new("a.txt", v).unwrap_err();
851        assert!(err.to_string().contains("cannot pack existing dlcpack"));
852    }
853
854    #[test]
855    fn pack_encrypted_pack_rejects_nested_dlcpack() {
856        let mut v = Vec::new();
857        v.extend_from_slice(DLC_PACK_MAGIC);
858        v.extend_from_slice(b"innerpack");
859        let err = PackItem::new("b.dlcpack", v);
860        assert!(err.is_err());
861    }
862
863    #[test]
864    fn is_data_executable_detects_pe_header() {
865        assert!(is_data_executable(&[0x4D, 0x5A, 0, 0]));
866    }
867
868    #[test]
869    fn is_data_executable_detects_shebang() {
870        assert!(is_data_executable(b"#! /bin/sh"));
871    }
872
873    #[test]
874    fn is_data_executable_ignores_plain_text() {
875        assert!(!is_data_executable(b"hello"));
876    }
877
878    #[test]
879    fn packitem_rejects_binary_data() {
880        let mut v = Vec::new();
881        v.extend_from_slice(&[0x4D, 0x5A, 0, 0]);
882
883        let pack_item = PackItem::new("evil.dat", v);
884        assert!(pack_item.is_err());
885    }
886
887    #[test]
888    fn dlc_id_serde_roundtrip() {
889        let id = DlcId::from("expansion_serde");
890        let s = serde_json::to_string(&id).expect("serialize dlc id");
891        assert_eq!(s, "\"expansion_serde\"");
892        let decoded: DlcId = serde_json::from_str(&s).expect("deserialize dlc id");
893        assert_eq!(decoded.to_string(), "expansion_serde");
894    }
895
896    #[test]
897    fn extend_signed_license_merges_dlc_ids() {
898        let product = Product::from("test_product");
899        let dlc_key = DlcKey::generate_random();
900
901        let initial = dlc_key
902            .create_signed_license(&["expansion_a", "expansion_b"], product.clone())
903            .expect("create initial license");
904
905        let extended = dlc_key
906            .extend_signed_license(&initial, &["expansion_c"], product.clone())
907            .expect("extend license");
908
909        assert!(dlc_key.verify_signed_license(&extended));
910        let verified_dlcs = extract_dlc_ids_from_license(&extended);
911        assert_eq!(verified_dlcs.len(), 3);
912        assert!(verified_dlcs.contains(&"expansion_a".to_string()));
913        assert!(verified_dlcs.contains(&"expansion_b".to_string()));
914        assert!(verified_dlcs.contains(&"expansion_c".to_string()));
915    }
916
917    #[test]
918    fn extend_signed_license_deduplicates() {
919        let product = Product::from("test_product");
920        let dlc_key = DlcKey::generate_random();
921
922        let initial = dlc_key
923            .create_signed_license(&["expansion_a"], product.clone())
924            .expect("create initial license");
925
926        let extended = dlc_key
927            .extend_signed_license(&initial, &["expansion_a"], product.clone())
928            .expect("extend license");
929
930        assert!(dlc_key.verify_signed_license(&extended));
931        let verified_dlcs = extract_dlc_ids_from_license(&extended);
932        let count = verified_dlcs.iter().filter(|d| d == &"expansion_a").count();
933        assert_eq!(count, 1, "Should not duplicate dlc_ids");
934    }
935
936    #[test]
937    #[serial_test::serial]
938    fn register_dlc_type_adds_pack_registrar_factory() {
939        let mut app = App::new();
940        app.add_plugins(AssetPlugin::default());
941        #[derive(Asset, TypePath)]
942        struct TestAsset;
943        app.init_asset::<TestAsset>();
944        app.register_dlc_type::<TestAsset>();
945
946        let factories = app
947            .world()
948            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
949            .expect("should have factories resource");
950        assert!(
951            factories
952                .0
953                .read()
954                .unwrap()
955                .iter()
956                .any(|f| asset_loader::fuzzy_type_path_match(
957                    f.type_name(),
958                    TestAsset::type_path()
959                ))
960        );
961    }
962
963    #[test]
964    #[serial_test::serial]
965    fn register_dlc_type_is_idempotent_for_pack_factories() {
966        let mut app = App::new();
967        app.add_plugins(AssetPlugin::default());
968        #[derive(Asset, TypePath)]
969        struct TestAsset2;
970        app.init_asset::<TestAsset2>();
971        app.register_dlc_type::<TestAsset2>();
972        app.register_dlc_type::<TestAsset2>();
973
974        let factories = app
975            .world()
976            .get_resource::<asset_loader::DlcPackRegistrarFactories>()
977            .expect("should have factories resource");
978        let count = factories
979            .0
980            .read()
981            .unwrap()
982            .iter()
983            .filter(|f| asset_loader::fuzzy_type_path_match(f.type_name(), TestAsset2::type_path()))
984            .count();
985        assert_eq!(count, 1);
986    }
987}
988
989/// Test helpers for integration tests. These provide controlled access to the
990/// internal registry to support test scenarios. Do not use in production code.
991///
992/// The registry should only be updated by `DlcPackLoader` when assets are loaded
993/// or by systems processing `SignedLicense` tokens in production. This module
994/// allows integration tests to bypass normal flow by directly registering keys.
995#[cfg(test)]
996#[allow(dead_code)]
997pub mod test_helpers {
998    use crate::{EncryptionKey, encrypt_key_registry};
999
1000    /// Insert a test encryption key into the registry for a given DLC id.
1001    ///
1002    /// **Test-only**: This bypasses normal production flows and should only be used
1003    /// in integration tests that need to load assets without processing signed licenses.
1004    pub fn register_test_encryption_key(dlc_id: &str, key: EncryptionKey) {
1005        encrypt_key_registry::insert(dlc_id, key);
1006    }
1007
1008    /// Register an asset path in the test registry.
1009    ///
1010    /// **Test-only**: Used to simulate what `DlcPackLoader` does during asset loading.
1011    pub fn register_test_asset_path(dlc_id: &str, path: &str) {
1012        encrypt_key_registry::register_asset_path(dlc_id, path);
1013    }
1014
1015    /// Clear the entire test registry.
1016    ///
1017    /// **Test-only**: Call this in test cleanup to reset state.
1018    pub fn clear_test_registry() {
1019        encrypt_key_registry::clear_all();
1020    }
1021
1022    pub fn is_malicious_data(data: &[u8]) -> bool {
1023        crate::pack_format::is_data_executable(data)
1024    }
1025}