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