Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions SECURITY_DEFER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[ARGON2ID_PARAMS] - Insufficient Argon2id memory cost
Severity: Critical
Location: neuron-encrypt/src/crypto.rs:114
Description: The current Argon2id memory cost is set to 64MB (m_cost=65536). Security guidelines recommend a minimum of 256MB (m_cost=262144) for hardened file encryption to resist specialized hardware-based brute-force attacks.
Why not fixed: no-touch zone
Suggested fix: Update Argon2id Params in derive_key and derive_key_v3 to use m_cost=262144.
First detected: 2026-05-09
Last attempted: 2026-05-09
Attempt count: 1
---
[UNEXPECTED_PANICS] - Unhandled unwrap() calls in crypto-adjacent paths
Severity: Medium
Location: neuron-encrypt/src/crypto.rs:116
Description: Multiple uses of .unwrap() or .unwrap_or_else() on locks/IO results within src/crypto.rs could lead to panics in production if environment conditions change (e.g., mutex poisoning or unexpected IO failure during file processing).
Why not fixed: no-touch zone
Suggested fix: Replace .unwrap() with proper error handling using CryptoError or .expect() with descriptive panic messages.
First detected: 2026-05-09
Last attempted: 2026-05-09
Attempt count: 1
---
[RUSTSEC-2024-0436] - Unmaintained dependency: paste
Severity: Medium
Location: Cargo.lock
Description: The `paste` crate is no longer maintained. While it's a macro-utility crate, unmaintained dependencies increase the long-term risk of unpatched vulnerabilities or incompatibility.
Why not fixed: no-touch zone (Cargo.lock modification restricted)
Suggested fix: Replace `paste` with `pastey` or `with_builtin_macros`.
First detected: 2026-05-09
Last attempted: 2026-05-09
Attempt count: 1
---
1 change: 1 addition & 0 deletions audit_output.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"database":{"advisory-count":1068,"last-commit":"881a159d8d70075fe79eb23605e7127a9c2a738a","last-updated":"2026-05-07T10:56:41+02:00"},"lockfile":{"dependency-count":537},"settings":{"target_arch":[],"target_os":[],"severity":null,"ignore":[],"informational_warnings":["unmaintained","unsound","notice"]},"vulnerabilities":{"found":false,"count":0,"list":[]},"warnings":{"unmaintained":[{"kind":"unmaintained","package":{"name":"paste","version":"1.0.15","source":"registry+https://github.com/rust-lang/crates.io-index","checksum":"57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a","replace":null},"advisory":{"id":"RUSTSEC-2024-0436","package":"paste","title":"paste - no longer maintained","description":"The creator of the crate `paste` has stated in the [`README.md`](https://github.com/dtolnay/paste/blob/master/README.md) \nthat this project is not longer maintained as well as archived the repository\n\n## Possible Alternative(s)\n\n- [`pastey`]: a fork of paste and is aimed to be a drop-in replacement with additional features for paste crate\n- [`with_builtin_macros`]: crate providing a [superset of `paste`'s functionality including general `macro_rules!` eager expansions](https://docs.rs/with_builtin_macros/0.1.0/with_builtin_macros/macro.with_eager_expansions.html) and `concat!`/`concat_idents!` macros\n\n[`pastey`]: https://crates.io/crates/pastey\n[`with_builtin_macros`]: https://crates.io/crates/with_builtin_macros","date":"2024-10-07","aliases":[],"related":[],"collection":"crates","categories":[],"keywords":[],"cvss":null,"informational":"unmaintained","references":[],"source":null,"url":"https://github.com/dtolnay/paste","withdrawn":null,"license":"CC0-1.0","expect-deleted":false},"affected":null,"versions":{"patched":[],"unaffected":[]}}]}}
40 changes: 28 additions & 12 deletions neuron-encrypt/src/bin/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,27 @@ fn read_password(password_file: &Option<PathBuf>) -> Zeroizing<String> {
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (&x, &y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
let len_a = a.len();
let len_b = b.len();
let max_len = len_a.max(len_b);

let mut byte_comparison_result = 0;

for i in 0..max_len {
let byte_a = a.get(i).unwrap_or(&0);
let byte_b = b.get(i).unwrap_or(&0);
byte_comparison_result |= byte_a ^ byte_b;
}
diff == 0

let len_diff = len_a ^ len_b;
// len_mismatch_flag will be 0 if lengths are equal, 1 if lengths are different
let len_mismatch_flag = (((len_diff | len_diff.wrapping_neg()) >> (usize::BITS - 1)) & 1) as u8;

// Combine byte comparison result with length mismatch flag
// If either bytes don't match OR lengths don't match, final_result will be non-zero
let final_result = byte_comparison_result | len_mismatch_flag;

final_result == 0
}
Comment on lines 144 to 166
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'Cargo.toml' --exec rg -nH '^\s*(subtle|aes-gcm|aes-gcm-siv|argon2|chacha20poly1305|curve25519-dalek)\b' {}
fd -t f 'Cargo.lock' --exec rg -n '^name = "subtle"' {}

Repository: darkmaster0345/Neuron-Encrypt

Length of output: 273


🌐 Web query:

Does the subtle crate's ConstantTimeEq::ct_eq handle slices of differing lengths in constant time, and what is its current stable version?

💡 Result:

No. The subtle crate’s ConstantTimeEq::ct_eq for slices explicitly short-circuits (and therefore does not run in time independent of slice lengths) when slice lengths differ: it checks the lengths and returns Choice::from(0) immediately if they are not equal.[1][2] Current stable version (crates.io): subtle 2.6.1.[3]

Citations:


🏁 Script executed:

# Verify subtle version and behavior more precisely
rg -n 'name = "subtle"' neuron-encrypt/Cargo.lock | head -20

Repository: darkmaster0345/Neuron-Encrypt

Length of output: 94


Hand-rolled constant-time compare still leaks the longer length, and bit tricks are fragile under LLVM.

The original early-exit timing leak is gone — good. But two critical concerns remain:

  1. Residual timing leak on input length. The loop runs for max_len = max(len_a, len_b) iterations, so runtime is proportional to the longer input's length. An observer learns max(|a|, |b|), which in read_password_confirmed (line 182) is the length of the longer of the two typed passphrases. A genuinely constant-time compare requires fixed iteration count or accepts that input length is public.

  2. Compiler may defeat the masking. final_result == 0 and the |= loop are not guaranteed to compile to branchless code. LLVM is free to introduce branches or short-circuit if it can prove the result. Constant-time properties at source level are not preserved by the optimizer without explicit barriers like core::hint::black_box.

⚠️ The proposed subtle::ConstantTimeEq fix is inadequate: The subtle crate's ct_eq for slices also short-circuits when input lengths differ—it checks lengths and returns early if unequal. This preserves the exact timing vulnerability identified in concern #1. Using subtle requires padding both inputs to equal length before comparison.

Correct approaches:

  • Pad both inputs to a fixed length, then use subtle::ct_eq(padded_a, padded_b), or
  • Keep a hand-rolled constant-time loop but add core::hint::black_box() or volatile reads around the comparison result to prevent LLVM from introducing branches.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@neuron-encrypt/src/bin/cli.rs` around lines 144 - 166, The constant_time_eq
function leaks the longer input length and may be optimized into branches; to
fix, ensure comparisons operate over a fixed, public length or prevent optimizer
short-circuiting: either (A) in read_password_confirmed pad/normalize both
passphrase slices to a predetermined fixed length before calling
subtle::ConstantTimeEq::ct_eq (or subtle::ct_eq) so lengths are equal, or (B)
keep the hand-rolled constant_time_eq but wrap the comparison accumulator and
final_result uses with core::hint::black_box (or use volatile reads/writes) to
stop LLVM from introducing branches; update calls to
constant_time_eq/read_password_confirmed accordingly so inputs are padded or
black_box-protected.


fn read_password_confirmed(password_file: &Option<PathBuf>) -> Result<Zeroizing<String>, String> {
Expand Down Expand Up @@ -185,18 +198,18 @@ impl IndProgress {
ProgressStyle::with_template(
"[{bar:10.cyan/blue}] {percent}% | {msg} | {bytes:>7}/{total_bytes:7} | {bytes_per_sec} | ETA {eta}",
)
.unwrap()
.expect("Invalid progress bar template")
.with_key("bytes_per_sec", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
write!(w, "{}/s", HumanBytes(state.pos())).unwrap();
write!(w, "{}/s", HumanBytes(state.pos())).expect("Failed to format bytes per second");
})
.progress_chars("##-")
} else {
ProgressStyle::with_template("{spinner} | {msg} | {bytes} | {bytes_per_sec}")
.unwrap()
.expect("Invalid progress bar spinner template")
.with_key(
"bytes_per_sec",
|state: &ProgressState, w: &mut dyn std::fmt::Write| {
write!(w, "{}/s", HumanBytes(state.pos())).unwrap();
write!(w, "{}/s", HumanBytes(state.pos())).expect("Failed to format bytes per second");
},
)
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
Expand Down Expand Up @@ -331,7 +344,10 @@ fn is_vx2_file(path: &Path) -> bool {
}

fn emit_json(result: &JsonResult) {
println!("{}", serde_json::to_string(result).unwrap());
println!(
"{}",
serde_json::to_string(result).expect("Failed to serialize JSON result")
);
}

fn compute_sha256(path: &Path) -> Result<String, String> {
Expand Down
54 changes: 28 additions & 26 deletions neuron-encrypt/src/gui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,10 @@ fn eval_strength(password: &str) -> (Strength, f32, &'static str) {
let len = password.chars().count();

for c in password.chars() {
if c.is_ascii_uppercase() {
if c.is_uppercase() {
has_upper = true;
}
if c.is_ascii_digit() {
if c.is_numeric() {
has_digit = true;
}
if !c.is_alphanumeric() {
Expand Down Expand Up @@ -289,14 +289,9 @@ fn preview_output_name(mode: Mode, path: &Path) -> String {
match mode {
Mode::Encrypt => format!("{}{}", name, crypto::EXTENSION),
Mode::Decrypt => {
let lower = name.to_lowercase();
if lower.ends_with(crypto::EXTENSION) {
// Use char-boundary-safe slicing
let strip_len = name.len() - crypto::EXTENSION.len();
name[..strip_len].to_owned()
} else {
name
}
name.strip_suffix(crypto::EXTENSION)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve case-insensitive .vx2 stripping in decrypt preview

preview_output_name now uses a case-sensitive strip_suffix(crypto::EXTENSION), so decrypting files like report.VX2 leaves the name unchanged. In the GUI decrypt flow this makes destination path equal to source path, and the crypto path validation rejects it as SourceAndDestinationSame, so decryption fails for uppercase/mixed-case extensions. This is a regression from the previous case-insensitive behavior and should keep extension stripping case-insensitive.

Useful? React with 👍 / 👎.

.map(|s| s.to_owned())
Comment on lines 291 to +293
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Decrypt output name no longer strips extension case-insensitively.

The previous logic handled the extension case-insensitively (e.g. .vx2, .VX2, .Vx2) by checking to_lowercase().ends_with(crypto::EXTENSION) and slicing the original string. strip_suffix is case-sensitive, so those variants will no longer be treated as encrypted and the extension will appear in the preview. If that behavior is still required, you could still compute the strip index from a lowercased copy while slicing the original string to stay boundary-safe.

.unwrap_or(name)
}
}
}
Expand Down Expand Up @@ -760,7 +755,12 @@ impl NeuronEncryptApp {
};

painter.rect_filled(button_rect, 7.0, fill);
painter.rect_stroke(button_rect, 7.0, Stroke::new(1.0, stroke_color));
painter.rect_stroke(
button_rect,
7.0,
Stroke::new(1.0, stroke_color),
egui::StrokeKind::Middle,
);
painter.text(
button_rect.center(),
Align2::CENTER_CENTER,
Expand All @@ -784,10 +784,10 @@ impl NeuronEncryptApp {
fn draw_screen_header(&self, ui: &mut egui::Ui) {
let rounding = 11.0;
let (badge, fill, text_color) = self.screen_badge();
egui::Frame::none()
egui::Frame::new()
.fill(fill)
.stroke(Stroke::new(1.0, Palette::BORDER_MED))
.rounding(rounding)
.corner_radius(rounding)
.inner_margin(6.0)
.show(ui, |ui| {
ui.label(
Expand Down Expand Up @@ -845,7 +845,7 @@ impl NeuronEncryptApp {
};

painter.rect_filled(rect, 8.0, fill);
painter.rect_stroke(rect, 8.0, Stroke::new(1.0, stroke_color));
painter.rect_stroke(rect, 8.0, Stroke::new(1.0, stroke_color), egui::StrokeKind::Middle);
painter.text(
rect.center(),
Align2::CENTER_CENTER,
Expand Down Expand Up @@ -889,6 +889,7 @@ impl NeuronEncryptApp {
Palette::BORDER_MED
},
),
egui::StrokeKind::Middle,
);
painter.text(
rect.center_top() + vec2(0.0, 42.0),
Expand Down Expand Up @@ -969,10 +970,10 @@ impl NeuronEncryptApp {
.unwrap_or_else(|_| String::from("Unknown size"));
let output = preview_output_name(self.mode, &path);

egui::Frame::none()
egui::Frame::new()
.fill(Palette::SURFACE_1)
.stroke(Stroke::new(1.0, Palette::BORDER))
.rounding(10.0)
.corner_radius(10.0)
.inner_margin(14.0)
.show(ui, |ui| {
ui.horizontal(|ui| {
Expand Down Expand Up @@ -1083,11 +1084,12 @@ impl NeuronEncryptApp {
Palette::BORDER
},
),
egui::StrokeKind::Middle,
);

let input_rect =
Rect::from_min_max(rect.min + vec2(12.0, 11.0), rect.max - vec2(12.0, 11.0));
ui.allocate_ui_at_rect(input_rect, |ui| {
ui.scope_builder(egui::UiBuilder::new().max_rect(input_rect), |ui| {
let edit = if primary {
egui::TextEdit::singleline(&mut *self.password).id(id)
} else {
Expand Down Expand Up @@ -1154,10 +1156,10 @@ impl NeuronEncryptApp {
border: Color32,
text: Color32,
) {
egui::Frame::none()
egui::Frame::new()
.fill(fill)
.stroke(Stroke::new(1.0, border))
.rounding(8.0)
.corner_radius(8.0)
.inner_margin(12.0)
.show(ui, |ui| {
ui.label(
Expand Down Expand Up @@ -1221,10 +1223,10 @@ impl NeuronEncryptApp {
.is_some_and(|path| is_vx2_file(path))
{
ui.add_space(14.0);
egui::Frame::none()
egui::Frame::new()
.fill(Palette::warning_muted())
.stroke(Stroke::new(1.0, Palette::WARNING))
.rounding(8.0)
.corner_radius(8.0)
.inner_margin(12.0)
.show(ui, |ui| {
ui.label(
Expand Down Expand Up @@ -1376,10 +1378,10 @@ impl NeuronEncryptApp {
);
ui.add_space(12.0);

egui::Frame::none()
egui::Frame::new()
.fill(Palette::SURFACE_1)
.stroke(Stroke::new(1.0, Palette::BORDER))
.rounding(8.0)
.corner_radius(8.0)
.inner_margin(12.0)
.show(ui, |ui| {
ui.label(
Expand Down Expand Up @@ -1899,17 +1901,17 @@ impl eframe::App for NeuronEncryptApp {
}

egui::CentralPanel::default()
.frame(egui::Frame::none().fill(Palette::BG))
.frame(egui::Frame::new().fill(Palette::BG))
.show(ctx, |ui| {
self.draw_title_bar(ui);
ui.add_space(22.0);

egui::ScrollArea::vertical().show(ui, |ui| {
ui.vertical_centered(|ui| {
egui::Frame::none()
egui::Frame::new()
.fill(Palette::SURFACE)
.stroke(Stroke::new(1.0, Palette::BORDER))
.rounding(12.0)
.corner_radius(12.0)
.inner_margin(24.0)
.show(ui, |ui| {
ui.set_width(520.0);
Expand Down
2 changes: 1 addition & 1 deletion neuron-encrypt/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ fn main() -> eframe::Result<()> {
let mut fonts = egui::FontDefinitions::default();
fonts
.font_data
.insert("JetBrainsMono".to_owned(), font_data);
.insert("JetBrainsMono".to_owned(), font_data.into());
fonts
.families
.entry(egui::FontFamily::Monospace)
Expand Down