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