electron_hardener/
fuses.rs

1//! Functionality for viewing and modifying [fuses] in an Electron application.
2//!
3//! [fuses]: https://www.electronjs.org/docs/tutorial/fuses
4
5use crate::{BinaryError, ElectronApp, PatcherError};
6use std::ops::Range;
7
8#[cfg(test)]
9use enum_iterator::IntoEnumIterator;
10
11/// A representation of a [fuse] that Electron has
12/// built in. They are used to disable specific functionality in the application in a way that can be enforced
13/// via signature checks and codesigning at the OS level.
14///
15/// In the binary, fuses look like this:
16/// ```text
17/// | ...binary | sentinel_bytes | fuse_version | fuse_wire_length | fuse_wire | ...binary |
18/// ```
19///
20/// Refer to the Electron project's [fuse documentation] for more details.
21///
22/// [fuse]: https://www.electronjs.org/docs/tutorial/fuses#the-hard-way
23/// [fuse documentation]: https://www.electronjs.org/docs/tutorial/fuses#what-are-fuses
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[cfg_attr(test, derive(IntoEnumIterator))]
26#[non_exhaustive]
27pub enum Fuse {
28    /// Disables `ELECTRON_RUN_AS_NODE` functionality in the application.
29    RunAsNode,
30    /// Enables [experimental cookie encryption](https://github.com/electron/electron/pull/27524) support
31    /// in the application.
32    EncryptedCookies,
33    /// Disbles the ability to use the [NODE_OPTIONS] environment variable on the application.
34    ///
35    /// [NODE_OPTIONS]: (https://nodejs.org/api/cli.html#cli_node_options_options)
36    NodeOptions,
37    /// Disables the ability to use the [debugging command-line flags] on the application.
38    ///
39    /// [debugging command-line flags](https://nodejs.org/en/docs/guides/debugging-getting-started/#command-line-options)
40    NodeCliInspect,
41    /// Enables the integrity validation of the `app.asar` file when it and resources inside are loaded by Electron.
42    ///
43    /// This is designed to prevent tampering with application code on supported platforms.
44    ///
45    /// To use this, an Electron packaging tool must create the correct checksum and embed it into the application.
46    /// Otherwise, this will have no effect on custom Electron apps.
47    ///
48    /// **Note**: This fuse currently only affects macOS. It is a no-op on other operating systems.
49    EmbeddedAsarIntegrityValidation,
50    /// Forces Electron to only load the application from `app.asar`. Other files and folders will be ignored
51    /// if they exist in the search path.
52    OnlyLoadAppFromAsar,
53}
54
55#[derive(Debug, PartialEq)]
56#[non_exhaustive]
57/// The result of an [operation](ElectronApp::set_fuse_status) on a fuse.
58pub enum FuseStatus {
59    /// The fuse existed in the binary.
60    Present(bool),
61    /// The fuse existed in the binary and was updated with the supplied value.
62    Modified,
63    /// The fuse existed in the binary, but was marked as removed.
64    ///
65    /// The binary contents will not be modified.
66    Removed,
67}
68
69impl Fuse {
70    /// Marker bytes that signal where the fuse wires start inside an Electron app's bytes.
71    const SENTINEL: &'static [u8] = b"dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX";
72
73    /// Marked as disabled and the feature it controls can't be used.
74    const DISABLED: u8 = b'0';
75    /// Marked as enabled and the feature can be used.
76    const ENABLED: u8 = b'1';
77    /// The fuse was removed from the [Electron schema] and marked as such.
78    ///
79    /// Disabling or enabling a fuse that has been removed will have no effect.
80    ///
81    /// [Electron schema]: https://github.com/electron/electron/blob/master/build/fuses/fuses.json
82    const REMOVED: u8 = b'r';
83
84    /// The version of the fuse schema this tool can work with.
85    const EXPECTED_VERSION: u8 = 1;
86
87    /// Returns where in the fuse wire this fuse is located.
88    fn schema_pos(&self) -> usize {
89        let wire_pos = match self {
90            Self::RunAsNode => 1,
91            Self::EncryptedCookies => 2,
92            Self::NodeOptions => 3,
93            Self::NodeCliInspect => 4,
94            Self::EmbeddedAsarIntegrityValidation => 5,
95            Self::OnlyLoadAppFromAsar => 6,
96        };
97
98        wire_pos - 1
99    }
100
101    /// Locates the start of the fuses binary section.
102    ///
103    /// Returns the position of the fuse wire.
104    pub(crate) fn find_wire(binary: &[u8]) -> Result<Range<usize>, PatcherError> {
105        let sentinel_len = Self::SENTINEL.len();
106
107        let pos = binary
108            .windows(sentinel_len)
109            .position(|slice| slice == Self::SENTINEL)
110            .ok_or(BinaryError::NoSentinel)?;
111
112        let start = pos + sentinel_len;
113
114        let version = binary.get(start).ok_or(BinaryError::NoFuseVersion)?;
115
116        if *version != Self::EXPECTED_VERSION {
117            return Err(PatcherError::FuseVersion {
118                expected: Self::EXPECTED_VERSION,
119                found: *version,
120            });
121        }
122
123        let len_pos = start + 1;
124        let wire_len = binary.get(len_pos).ok_or(BinaryError::NoFuseLength)?;
125
126        let wire_start = len_pos + 1;
127        let fuse_bytes = (wire_start)..(wire_start + usize::from(*wire_len));
128
129        Ok(fuse_bytes)
130    }
131
132    fn fuse_status(&self, wire: &[u8]) -> Result<FuseStatus, PatcherError> {
133        let status = wire
134            .get(self.schema_pos())
135            .ok_or(BinaryError::FuseDoesNotExist(*self))?;
136
137        let status = match *status {
138            Self::ENABLED => FuseStatus::Present(true),
139            Self::DISABLED => FuseStatus::Present(false),
140            Self::REMOVED => FuseStatus::Removed,
141            s => {
142                return Err(BinaryError::UnknownFuse {
143                    fuse: *self,
144                    value: s,
145                }
146                .into())
147            }
148        };
149
150        Ok(status)
151    }
152
153    fn disable(&self, wire: &mut [u8]) -> Result<FuseStatus, PatcherError> {
154        let mut enabled = self.fuse_status(wire)?;
155
156        match enabled {
157            FuseStatus::Present(e) if e => {
158                wire[self.schema_pos()] = Self::DISABLED;
159                enabled = FuseStatus::Modified
160            }
161            FuseStatus::Removed => return Err(PatcherError::RemovedFuse(*self)),
162            _ => {}
163        }
164
165        Ok(enabled)
166    }
167
168    fn enable(&self, wire: &mut [u8]) -> Result<FuseStatus, PatcherError> {
169        let mut enabled = self.fuse_status(wire)?;
170
171        match enabled {
172            FuseStatus::Present(e) if !e => {
173                wire[self.schema_pos()] = Self::ENABLED;
174                enabled = FuseStatus::Modified
175            }
176            FuseStatus::Removed => return Err(PatcherError::RemovedFuse(*self)),
177            _ => {}
178        }
179
180        Ok(enabled)
181    }
182}
183
184impl<'a> ElectronApp<'a> {
185    /// Constructs a new [electron app](Self) and verifies that the bytes came from
186    /// a packaged Electron app binary file.
187    ///
188    /// # Errors
189    ///
190    /// This function returns an error if the bytes couldn't be validated to contain an Electron application.
191    pub fn from_bytes(application_bytes: &'a mut [u8]) -> Result<ElectronApp<'a>, PatcherError> {
192        let wire_pos = Fuse::find_wire(application_bytes)?;
193
194        Ok(Self {
195            contents: application_bytes,
196            wire_start: wire_pos.start,
197            wire_end: wire_pos.end,
198        })
199    }
200
201    /// Parses and returns this fuse type's status in the provided binary.
202    ///
203    /// # Return
204    ///
205    /// Returns the current fuse status. This will not return a [modification result](FuseResult::Modified).
206    ///
207    /// # Errors
208    ///
209    /// This function will return an error if an invalid binary is provided or one that is not an Electron application.
210    pub fn get_fuse_status(&self, fuse: Fuse) -> Result<FuseStatus, PatcherError> {
211        let wire = &self.contents[self.wire_start..self.wire_end];
212        fuse.fuse_status(wire)
213    }
214
215    /// Toggles a fuse in the application binary based off the provided value.
216    ///
217    /// # Return
218    ///
219    /// Returns the [result](FuseResult) of the operation if it succeeded.
220    ///
221    /// # Errors
222    ///
223    /// This function will return an error if a fuse wire couldn't be found in the provided binary or
224    /// if a modification of a removed fuse was attempted.
225    pub fn set_fuse_status(
226        &mut self,
227        fuse: Fuse,
228        enabled: bool,
229    ) -> Result<FuseStatus, PatcherError> {
230        let wire = &mut self.contents[self.wire_start..self.wire_end];
231
232        if enabled {
233            fuse.enable(wire)
234        } else {
235            fuse.disable(wire)
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    const TEST_BYTES: &[u8] = include_bytes!("../examples/fake_electron_fuses.bin");
245    const FUSE: Fuse = Fuse::RunAsNode;
246
247    fn get_wire() -> &'static [u8] {
248        let wire_pos = Fuse::find_wire(TEST_BYTES).unwrap();
249        &TEST_BYTES[wire_pos]
250    }
251
252    #[test]
253    fn sentinal_is_found() {
254        assert!(Fuse::find_wire(TEST_BYTES).is_ok());
255    }
256
257    #[test]
258    fn enabled_fuse_is_correct() {
259        assert_eq!(
260            FUSE.fuse_status(get_wire()).unwrap(),
261            FuseStatus::Present(true)
262        );
263    }
264
265    #[test]
266    fn disabled_fuse_is_correct() {
267        let mut wire = get_wire().to_vec();
268        assert_eq!(FUSE.disable(&mut wire).unwrap(), FuseStatus::Modified);
269        assert_eq!(FUSE.fuse_status(&wire).unwrap(), FuseStatus::Present(false));
270    }
271
272    #[test]
273    fn removed_fuse_is_correct() {
274        let mut wire = get_wire().to_vec();
275        wire[FUSE.schema_pos()] = Fuse::REMOVED;
276
277        assert_eq!(FUSE.fuse_status(&wire).unwrap(), FuseStatus::Removed);
278    }
279
280    #[test]
281    fn unknown_fuse_value_is_correct() {
282        let value = 9;
283        let mut wire = get_wire().to_vec();
284        wire[FUSE.schema_pos()] = value;
285
286        assert_eq!(
287            FUSE.fuse_status(&wire),
288            Err(PatcherError::Binary(BinaryError::UnknownFuse {
289                fuse: FUSE,
290                value,
291            }))
292        );
293    }
294
295    #[test]
296    fn modfying_removed_fuse_errors() {
297        let mut wire = get_wire().to_vec();
298        wire[FUSE.schema_pos()] = Fuse::REMOVED;
299
300        assert_eq!(
301            FUSE.disable(&mut wire),
302            Err(PatcherError::RemovedFuse(FUSE))
303        );
304        assert_eq!(FUSE.enable(&mut wire), Err(PatcherError::RemovedFuse(FUSE)));
305    }
306
307    #[test]
308    fn test_app_fuse_actions() {
309        let mut application_bytes = TEST_BYTES.to_vec();
310        let mut app = ElectronApp::from_bytes(&mut application_bytes).unwrap();
311
312        assert_eq!(
313            app.get_fuse_status(FUSE).unwrap(),
314            FuseStatus::Present(true)
315        );
316
317        // Setting the fuse to what it already is shouldn't modify anything.
318        assert_eq!(
319            app.set_fuse_status(FUSE, true).unwrap(),
320            FuseStatus::Present(true)
321        );
322
323        assert_eq!(
324            app.set_fuse_status(FUSE, false).unwrap(),
325            FuseStatus::Modified
326        );
327        assert_eq!(
328            app.get_fuse_status(FUSE).unwrap(),
329            FuseStatus::Present(false)
330        );
331    }
332
333    #[test]
334    fn can_read_all_fuses() {
335        let wire = get_wire();
336
337        for fuse in Fuse::into_enum_iter() {
338            assert!(matches!(
339                fuse.fuse_status(wire).unwrap(),
340                FuseStatus::Present(_)
341            ));
342        }
343    }
344
345    #[test]
346    fn fuse_modifies_correct_position() {
347        let mut wire = get_wire().to_vec();
348
349        let fuse1 = Fuse::RunAsNode;
350        let fuse2 = Fuse::EncryptedCookies;
351        let fuse3 = Fuse::NodeOptions;
352
353        let fuse_2_original_status = fuse2.fuse_status(&wire).unwrap();
354
355        fuse1.disable(&mut wire).unwrap();
356
357        // Check that modifying one fuse doesn't affect others.
358        assert_eq!(fuse2.fuse_status(&wire).unwrap(), fuse_2_original_status);
359
360        let fuse_1_original_status = fuse1.fuse_status(&wire).unwrap();
361
362        fuse2.disable(&mut wire).unwrap();
363
364        assert_eq!(fuse1.fuse_status(&wire).unwrap(), fuse_1_original_status);
365
366        let left_fuse_original_status = fuse1.fuse_status(&wire).unwrap();
367        let right_fuse_original_status = fuse3.fuse_status(&wire).unwrap();
368
369        fuse2.enable(&mut wire).unwrap();
370
371        assert_eq!(fuse1.fuse_status(&wire).unwrap(), left_fuse_original_status);
372        assert_eq!(
373            fuse3.fuse_status(&wire).unwrap(),
374            right_fuse_original_status
375        );
376    }
377}