From 5ec1dcac8ff87feba4812f37c135d705957cbeef Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 16:47:03 +0200 Subject: [PATCH 1/3] style: cargo fmt baseline (repo was not fmt-clean) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time normalization so the new `cargo fmt --all --check` CI gate passes. Formatting only — no logic changes (rustfmt never alters semantics). --- src/main.rs | 16 +++++-- src/onboarding.rs | 57 ++++++++++++++++--------- src/palette.rs | 28 +++++++------ src/receive.rs | 12 ++---- src/settings_view.rs | 4 +- src/shell.rs | 99 ++++++++++++++++++++++++-------------------- src/welcome.rs | 31 +++++++++++--- 7 files changed, 154 insertions(+), 93 deletions(-) diff --git a/src/main.rs b/src/main.rs index 581d06c..e91fd29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,15 +7,15 @@ //! Fork checklist: rename the crate in `Cargo.toml`, change `APP_NAME` and the //! bundle identifier, swap `assets/icon.png`, then start editing the views. +mod onboarding; +mod palette; +mod receive; mod settings; mod settings_view; mod shell; mod theme; #[cfg(feature = "tray")] mod tray; -mod onboarding; -mod palette; -mod receive; mod wallet; mod welcome; @@ -35,7 +35,15 @@ pub const APP_NAME: &str = "Deckard"; // to, hang a menu item off of, and handle in a view or globally. Add your own here. gpui::actions!( deckard, - [Quit, About, OpenSettings, ToggleTheme, NewItem, GoBack, TogglePalette] + [ + Quit, + About, + OpenSettings, + ToggleTheme, + NewItem, + GoBack, + TogglePalette + ] ); fn main() { diff --git a/src/onboarding.rs b/src/onboarding.rs index 309c670..5ab13d1 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -106,7 +106,11 @@ impl Shell { } fn render_create_setup(&self, cx: &mut Context) -> impl IntoElement { - let busy_label = if self.auth_busy { "Encrypting…" } else { "Continue" }; + let busy_label = if self.auth_busy { + "Encrypting…" + } else { + "Continue" + }; v_flex() .gap_5() .child(self.auth_heading( @@ -146,7 +150,11 @@ impl Shell { let t = cx.theme(); (t.border, t.secondary, t.foreground, t.muted_foreground) }; - let busy_label = if self.auth_busy { "Saving…" } else { "Confirm & finish" }; + let busy_label = if self.auth_busy { + "Saving…" + } else { + "Confirm & finish" + }; // The 12-word grid: each cell shows the word only while held-to-reveal. let words: Vec = self @@ -175,12 +183,7 @@ impl Shell { .border_1() .border_color(border) .bg(surface) - .child( - div() - .text_xs() - .text_color(muted) - .child(format!("{n}")), - ) + .child(div().text_xs().text_color(muted).child(format!("{n}"))) .child( div() .text_sm() @@ -256,7 +259,11 @@ impl Shell { } fn render_import(&self, cx: &mut Context) -> impl IntoElement { - let busy_label = if self.auth_busy { "Importing…" } else { "Import" }; + let busy_label = if self.auth_busy { + "Importing…" + } else { + "Import" + }; v_flex() .gap_5() .child(self.auth_heading( @@ -292,7 +299,11 @@ impl Shell { fn render_migrate(&self, cx: &mut Context) -> impl IntoElement { let danger = cx.theme().danger; - let busy_label = if self.auth_busy { "Encrypting…" } else { "Encrypt & continue" }; + let busy_label = if self.auth_busy { + "Encrypting…" + } else { + "Encrypt & continue" + }; v_flex() .gap_5() .child(self.auth_heading( @@ -324,7 +335,11 @@ impl Shell { } fn render_unlock(&self, cx: &mut Context) -> impl IntoElement { - let busy_label = if self.auth_busy { "Unlocking…" } else { "Unlock" }; + let busy_label = if self.auth_busy { + "Unlocking…" + } else { + "Unlock" + }; v_flex() .gap_5() .child(self.auth_heading( @@ -346,7 +361,12 @@ impl Shell { // --- small shared pieces --- - fn auth_heading(&self, title: &str, subtitle: &str, cx: &mut Context) -> impl IntoElement { + fn auth_heading( + &self, + title: &str, + subtitle: &str, + cx: &mut Context, + ) -> impl IntoElement { let theme = cx.theme(); v_flex() .gap_2() @@ -389,12 +409,11 @@ impl Shell { /// A one-line error, or nothing. fn error_line(&self, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); - div() - .children(self.auth_error.as_ref().map(|e| { - div() - .text_sm() - .text_color(theme.danger) - .child(format!("⚠ {e}")) - })) + div().children(self.auth_error.as_ref().map(|e| { + div() + .text_sm() + .text_color(theme.danger) + .child(format!("⚠ {e}")) + })) } } diff --git a/src/palette.rs b/src/palette.rs index 207d1a8..bd26d57 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -60,12 +60,14 @@ impl Shell { .text_color(muted) .child("Commands"), ) - .child(row("cmd-portfolio", "Go to Portfolio", "").on_click(cx.listener( - |this, _, _, cx| { - this.palette_open = false; - this.navigate(Route::Welcome, cx); - }, - ))) + .child( + row("cmd-portfolio", "Go to Portfolio", "").on_click(cx.listener( + |this, _, _, cx| { + this.palette_open = false; + this.navigate(Route::Welcome, cx); + }, + )), + ) .child(row("cmd-receive", "Receive", "").on_click(cx.listener( |this, _, _, cx| { this.palette_open = false; @@ -87,12 +89,14 @@ impl Shell { cx.notify(); }, ))) - .child(row("cmd-theme", "Toggle theme", "⌘⇧D").on_click(cx.listener( - |this, _, _, cx| { - this.palette_open = false; - this.toggle_mode(cx); - }, - ))) + .child( + row("cmd-theme", "Toggle theme", "⌘⇧D").on_click(cx.listener( + |this, _, _, cx| { + this.palette_open = false; + this.toggle_mode(cx); + }, + )), + ) .child(row("cmd-lock", "Lock wallet", "").on_click(cx.listener( |this, _, _, cx| { this.palette_open = false; diff --git a/src/receive.rs b/src/receive.rs index 456ef54..3f936e3 100644 --- a/src/receive.rs +++ b/src/receive.rs @@ -2,9 +2,7 @@ //! string, and a working copy-to-clipboard. The address is a real keypair //! (see wallet.rs); anyone can send to it. -use gpui::{ - div, px, rgb, ClipboardItem, Context, FontWeight, IntoElement, ParentElement, Styled, -}; +use gpui::{div, px, rgb, ClipboardItem, Context, FontWeight, IntoElement, ParentElement, Styled}; use gpui_component::{ button::{Button, ButtonVariants}, h_flex, v_flex, ActiveTheme, @@ -96,11 +94,9 @@ impl Shell { )); })), ) - .child( - Button::new("receive-back").ghost().label("Back").on_click( - cx.listener(|this, _, _, cx| this.navigate(Route::Welcome, cx)), - ), - ), + .child(Button::new("receive-back").ghost().label("Back").on_click( + cx.listener(|this, _, _, cx| this.navigate(Route::Welcome, cx)), + )), ), ) } diff --git a/src/settings_view.rs b/src/settings_view.rs index b83dff2..793ac7b 100644 --- a/src/settings_view.rs +++ b/src/settings_view.rs @@ -102,7 +102,9 @@ impl Shell { let name_control = Input::new(&self.name_input).w(px(220.0)).into_any_element(); let rpc_control = Input::new(&self.rpc_input).w(px(260.0)).into_any_element(); - let watch_control = Input::new(&self.watch_input).w(px(260.0)).into_any_element(); + let watch_control = Input::new(&self.watch_input) + .w(px(260.0)) + .into_any_element(); let launch_control = Switch::new("launch-min") .checked(self.settings.launch_minimized) diff --git a/src/shell.rs b/src/shell.rs index f8e2e50..5edac98 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -21,7 +21,7 @@ use zeroize::Zeroizing; use crate::settings::{Settings, ThemeModePref}; use crate::theme::{self, Accent}; use crate::wallet; -use crate::{GoBack, NewItem, OpenSettings, ToggleTheme, TogglePalette, APP_NAME}; +use crate::{GoBack, NewItem, OpenSettings, TogglePalette, ToggleTheme, APP_NAME}; /// Trim a noisy provider error down to one short line for the UI. fn short_err(e: impl std::fmt::Display) -> String { @@ -142,18 +142,21 @@ impl Shell { .placeholder("https://… (default: bundled public RPC)") .default_value(settings.rpc_url.clone()) }); - cx.subscribe(&rpc_input, |this, state, event: &InputEvent, cx| match event { - InputEvent::Change => { - this.settings.rpc_url = state.read(cx).value().to_string(); - this.settings.save(); - } - InputEvent::Blur => { - this.settings.rpc_url = state.read(cx).value().to_string(); - this.settings.save(); - this.respawn_provider(cx); - } - _ => {} - }) + cx.subscribe( + &rpc_input, + |this, state, event: &InputEvent, cx| match event { + InputEvent::Change => { + this.settings.rpc_url = state.read(cx).value().to_string(); + this.settings.save(); + } + InputEvent::Blur => { + this.settings.rpc_url = state.read(cx).value().to_string(); + this.settings.save(); + this.respawn_provider(cx); + } + _ => {} + }, + ) .detach(); // Watch address / ENS: persist as typed; re-target the portfolio on blur. @@ -162,18 +165,21 @@ impl Shell { .placeholder("0x… or name.eth (blank = your wallet)") .default_value(settings.watch_address.clone()) }); - cx.subscribe(&watch_input, |this, state, event: &InputEvent, cx| match event { - InputEvent::Change => { - this.settings.watch_address = state.read(cx).value().to_string(); - this.settings.save(); - } - InputEvent::Blur => { - this.settings.watch_address = state.read(cx).value().to_string(); - this.settings.save(); - this.retarget(cx); - } - _ => {} - }) + cx.subscribe( + &watch_input, + |this, state, event: &InputEvent, cx| match event { + InputEvent::Change => { + this.settings.watch_address = state.read(cx).value().to_string(); + this.settings.save(); + } + InputEvent::Blur => { + this.settings.watch_address = state.read(cx).value().to_string(); + this.settings.save(); + this.retarget(cx); + } + _ => {} + }, + ) .detach(); // Auth inputs — passphrases are masked and NEVER persisted to disk. @@ -185,10 +191,12 @@ impl Shell { let create_pass2 = masked(window, cx, "Confirm passphrase"); let import_pass = masked(window, cx, "Choose a passphrase (min 8 characters)"); let pass_input = masked(window, cx, "Passphrase"); - let confirm_words = - cx.new(|cx| InputState::new(window, cx).placeholder("the requested words, space-separated")); - let import_secret = cx - .new(|cx| InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key")); + let confirm_words = cx.new(|cx| { + InputState::new(window, cx).placeholder("the requested words, space-separated") + }); + let import_secret = cx.new(|cx| { + InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key") + }); // Submit-on-Enter for each auth field (keyboard-first). cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| { @@ -607,7 +615,9 @@ impl Shell { /// The unlocked wallet's own address as an EIP-55 string (empty until unlocked). pub fn wallet_address_string(&self) -> String { - self.wallet_address.map(|a| a.to_string()).unwrap_or_default() + self.wallet_address + .map(|a| a.to_string()) + .unwrap_or_default() } /// Auto-focus the primary input for the current auth step (so the user — and the @@ -715,20 +725,21 @@ impl Shell { return; } match res { - Ok(Ok(addr)) => { - this.display_address = addr; - this.refresh_portfolio(cx); - } - Ok(Err(e)) => { - this.portfolio_loading = false; - this.portfolio_error = Some(format!("couldn't resolve name — {}", short_err(e))); - cx.notify(); - } - Err(_) => { - this.portfolio_loading = false; - this.portfolio_error = Some("network worker stopped".into()); - cx.notify(); - } + Ok(Ok(addr)) => { + this.display_address = addr; + this.refresh_portfolio(cx); + } + Ok(Err(e)) => { + this.portfolio_loading = false; + this.portfolio_error = + Some(format!("couldn't resolve name — {}", short_err(e))); + cx.notify(); + } + Err(_) => { + this.portfolio_loading = false; + this.portfolio_error = Some("network worker stopped".into()); + cx.notify(); + } } }) .ok(); diff --git a/src/welcome.rs b/src/welcome.rs index c5f20b6..2169c1b 100644 --- a/src/welcome.rs +++ b/src/welcome.rs @@ -88,7 +88,11 @@ impl Shell { )); } } - let has_tokens = self.portfolio.as_ref().map(|p| !p.tokens.is_empty()).unwrap_or(false); + let has_tokens = self + .portfolio + .as_ref() + .map(|p| !p.tokens.is_empty()) + .unwrap_or(false); // Status sub-line: synced block, watching tag, or an error. let status_line = if let Some(err) = &self.portfolio_error { @@ -96,12 +100,20 @@ impl Shell { } else if first_sync { "Syncing over Ethereum…".to_string() } else if let Some(block) = self.synced_block { - let net = if self.viewing_watch { "watching · " } else { "" }; + let net = if self.viewing_watch { + "watching · " + } else { + "" + }; format!("{net}synced · block {block}") } else { "Ethereum mainnet".to_string() }; - let status_color = if self.portfolio_error.is_some() { theme.danger } else { muted }; + let status_color = if self.portfolio_error.is_some() { + theme.danger + } else { + muted + }; div() .flex_1() @@ -130,7 +142,11 @@ impl Shell { .py_1() .rounded_full() .border_1() - .border_color(if self.viewing_watch { accent } else { border }) + .border_color(if self.viewing_watch { + accent + } else { + border + }) .bg(surface) .text_xs() .text_color(fg) @@ -233,7 +249,12 @@ impl Shell { .flex_col() .items_center() .gap_1() - .child(div().text_sm().text_color(theme.foreground).child("No balances yet")) + .child( + div() + .text_sm() + .text_color(theme.foreground) + .child("No balances yet"), + ) .child( div() .text_xs() From 0280ed5f1c8280f5fecef81bf40a5147a4124473 Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 16:47:13 +0200 Subject: [PATCH 2/3] build: agentic lint/CI/supply-chain policy + harden deckard-core Move the lint policy into the manifest and enforce it in CI so agents see the same rules at `cargo check` time that CI gates: - [workspace.lints] + [lints] in both crates; clippy.toml (msrv 1.95, disallowed-methods: mem::forget/thread_rng); checked-in rustfmt.toml. - deckard-core: #![forbid(unsafe_code)] + #![deny(unwrap_used, expect_used, get_unwrap, indexing_slicing, panic, mem_forget)]. The untrusted-byte parser is refactored to bounds-checked .get()/.first()/try_into(); the two startup-fatal expects carry a documented #[allow]; #[must_use] on the secret types (Vault/UnlockedVault). - deny.toml + non-blocking cargo-deny CI job (advisories/licenses/bans/sources). - CI: add fmt --check, default-feature clippy, cargo test, and --locked. - docs/AGENTIC-ENGINEERING.md (shareable upstream with Deck) + CLAUDE.md/AGENTS.md. Verified green: clippy (default + tray), cargo test 13/13, cargo fmt --check. Cross-model reviewed by codex GPT-5.5. Tier 2/3 backlog: #7. --- .github/workflows/ci.yml | 35 ++- AGENTS.md | 47 ++++ CLAUDE.md | 29 +++ Cargo.toml | 17 ++ clippy.toml | 35 +++ crates/deckard-core/Cargo.toml | 5 + crates/deckard-core/src/balances.rs | 21 +- crates/deckard-core/src/eth.rs | 30 ++- crates/deckard-core/src/keystore.rs | 158 ++++++++++--- crates/deckard-core/src/lib.rs | 21 +- deny.toml | 63 ++++++ docs/AGENTIC-ENGINEERING.md | 330 ++++++++++++++++++++++++++++ justfile | 4 +- rustfmt.toml | 8 + 14 files changed, 751 insertions(+), 52 deletions(-) create mode 100644 AGENTS.md create mode 100644 clippy.toml create mode 100644 deny.toml create mode 100644 docs/AGENTIC-ENGINEERING.md create mode 100644 rustfmt.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6259b5f..2ae07fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,15 @@ jobs: # source of truth. rustup (preinstalled on GitHub runners) auto-installs the # pinned version and its clippy/rustfmt components on the first cargo call. - uses: Swatinem/rust-cache@v2 - - run: cargo build - - run: cargo build --features tray - - run: cargo clippy --all-targets --features tray -- -D warnings + # --locked everywhere: reproducibility lives in the committed Cargo.lock (it pins the exact + # git gpui commits). --locked fails CI if the lock is stale, forcing it to stay committed. + - run: cargo build --locked + - run: cargo build --locked --features tray + - run: cargo clippy --locked --all-targets --features tray -- -D warnings + # Lint the DEFAULT (shipping) feature config too — CI only ever linted the tray config before. + - run: cargo clippy --locked --all-targets -- -D warnings + # deckard-core's keystore/eth tests previously never ran in CI. + - run: cargo test --locked --workspace linux: runs-on: ubuntu-latest @@ -56,6 +62,23 @@ jobs: libfontconfig1-dev libfreetype6-dev \ libssl-dev \ libgtk-3-dev libayatana-appindicator3-dev libxdo-dev - - run: cargo build - - run: cargo build --features tray - - run: cargo clippy --all-targets --features tray -- -D warnings + - run: cargo build --locked + - run: cargo build --locked --features tray + - run: cargo clippy --locked --all-targets --features tray -- -D warnings + - run: cargo clippy --locked --all-targets -- -D warnings + # Formatting is OS-independent, so gate it once on the cheaper Linux runner. + - run: cargo fmt --all --check + - run: cargo test --locked --workspace + + # Supply-chain gate (advisories / licenses / bans / sources). Config: deny.toml. + # Lands NON-BLOCKING (continue-on-error): promote to a required check after one green run AND + # after seeding deny.toml [licenses].allow from a local `cargo deny check licenses`. + # Rationale: docs/AGENTIC-ENGINEERING.md §4. + cargo-deny: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check advisories bans sources licenses diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c96cf0a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# AGENTS.md — Deckard + +Guidance for any coding agent (Codex, Claude Code, etc.) working in this repo. Claude Code also +reads `CLAUDE.md`; the two are kept in sync. Full rationale: `docs/AGENTIC-ENGINEERING.md`. + +## What this is +Deckard is a native, self-custodial **Ethereum wallet** (GPUI + Rust, macOS + Linux). It is +**security-sensitive**: it holds private keys, BIP-39 seed phrases, and an encrypted keystore. +Treat key material with care. + +## Layout +- `src/*.rs` — the `deckard` GPUI app (the view layer / shell). +- `crates/deckard-core` — the headless engine (Ethereum provider, balances, HD keys, encrypted + keystore). **No GPUI dependency; fully unit-testable.** Most logic belongs here, not in the app. + +## Commands +- Iterate fast: `cargo check -p deckard-core` (GPUI-free — seconds, not minutes). +- Lint: `just check` — clippy `-D warnings` on BOTH the default and `--features tray` configs. +- Format: `just fmt` (`cargo fmt`); CI gates `cargo fmt --all --check`. +- Test: `cargo test --workspace`. +- Bump the git GPUI stack: `just bump-gpui` (the ONLY way to change those pins). + +## Definition of done (all must hold; show command output as evidence) +1. `cargo fmt --all --check` clean +2. `just check` green (both feature configs) +3. `cargo test --workspace` green +4. No new/changed dependencies in `Cargo.toml` or `Cargo.lock` unless explicitly approved + +Never report a task complete while any of these is red. + +## Code constraints (see docs/AGENTIC-ENGINEERING.md for the full rationale) +**Enforced workspace-wide** (CI fails the build): `todo!` / `dbg!` denied; `unused_must_use` denied; +`deckard-core` is `#![forbid(unsafe_code)]` and the app crate is `unsafe_code = "deny"` (a new `unsafe` +block needs a reviewed `// SAFETY:` comment + explicit `#[allow]`); `std::mem::forget` / +`core::mem::forget` / `rand::thread_rng` denied (use `drop()` / `OsRng`). + +**Enforced in `deckard-core`** via crate-level `#![deny(...)]`: no `.unwrap()` / `.expect()` / `panic!` / +raw slice indexing in non-test code — propagate with `Result` / `?` and parse untrusted bytes through the +bounded `Reader` in `keystore.rs`. The app crate may `unwrap` infallible GPUI handles; the engine must +not. Genuinely-unrecoverable boundaries use a scoped `#[allow]` + `// reason` (see `eth.rs`), never a +bare `unwrap`. + +**Always:** never log or `Debug`-print a seed, key, or passphrase. Secrets live in `Zeroizing`. + +## Design +Before any visual/UI change, read `DESIGN.md` (and the constraints in `CLAUDE.md`). Ground design in +the real reference screenshots, not remembered descriptions. diff --git a/CLAUDE.md b/CLAUDE.md index 33b3e1a..577851d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,3 +11,32 @@ Ground all design work in **real reference screenshots** (Linear, Conductor, Spl Stripe), never in remembered descriptions — that is how the first drafts went wrong. The interactive, dogfooded reference lives at `~/.gstack/projects/hellno-deckard/designs/deckard-foundation-preview.html`. + +## Engineering & verification +The full rationale (what we enforce and *why*, plus deliberately-rejected rules) is in +`docs/AGENTIC-ENGINEERING.md`. The quick reference: + +**Iterate fast:** `cargo check -p deckard-core` — the engine is GPUI-free and checks in seconds. + +**Definition of done** (ALL must hold; paste the command output as evidence — do not claim done while red): +1. `cargo fmt --all --check` is clean +2. `just check` is green — runs clippy `-D warnings` on BOTH the default and `--features tray` configs +3. `cargo test --workspace` is green +4. No new or changed dependencies (`Cargo.toml` / `Cargo.lock`) unless explicitly approved + (the git GPUI stack is bumped only via `just bump-gpui` — never hand-edit those pins) + +## Code constraints + +**Enforced workspace-wide** by `[workspace.lints]` + `clippy.toml` (CI fails the build): +- `todo!` and `dbg!` are denied; ignored `Result`s (`unused_must_use`) are denied. +- `deckard-core` is `#![forbid(unsafe_code)]`; the app crate is `unsafe_code = "deny"`. +- `std::mem::forget` / `core::mem::forget` and `rand::thread_rng` are denied — use `drop()` / `OsRng`. + +**Enforced in `deckard-core`** (the trust core) via crate-level `#![deny(...)]` — clippy fails the build: +- No `.unwrap()` / `.expect()` / `panic!` / raw slice indexing in non-test code — propagate with + `Result` / `?` and parse untrusted bytes through the bounded `Reader` in `keystore.rs`. The app + crate may `unwrap` infallible GPUI handles; the engine must not. The two startup-fatal `expect`s in + `eth.rs` carry a documented local `#[allow]` — match that pattern (a `// reason` + scoped `#[allow]`) + for any genuinely-unrecoverable boundary, don't reach for a bare `unwrap`. + +**Always, every crate:** never log or `Debug`-print a seed, key, or passphrase — secrets stay in `Zeroizing`. diff --git a/Cargo.toml b/Cargo.toml index 3456779..1dd39c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,23 @@ default-run = "deckard" [workspace] members = ["crates/deckard-core"] +# Lint policy lives in the manifest (not just CI flags) so rust-analyzer and `cargo check` surface +# the exact same rules CI enforces. Inherited by every crate via `[lints] workspace = true`. +# Rationale + the deliberately-rejected lints: docs/AGENTIC-ENGINEERING.md §1. +[workspace.lints.rust] +unused_must_use = "deny" # an ignored Result from a fallible crypto/IO call is a silent bug +unsafe_code = "deny" # deny (not forbid): leaves a reviewed escape hatch if objc2 ever needs raw unsafe + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } # priority = -1 is required on a lint *group* +todo = "deny" # a stray todo!() left on a code path panics in production +dbg_macro = "deny" # debug noise — and dbg!(seed) is a key leak + +# The root crate is both a package and the workspace root, so it does NOT auto-inherit the table +# above — it must opt in explicitly, same as the member crate. +[lints] +workspace = true + [[bin]] name = "deckard" path = "src/main.rs" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..3b39c62 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,35 @@ +# Project-wide clippy configuration. See docs/AGENTIC-ENGINEERING.md §2. + +# Keep clippy's API suggestions within what the pinned toolchain supports. +msrv = "1.95.0" + +# These pair with deckard-core's crate-level restriction lints (unwrap_used/expect_used/panic, denied +# in its lib.rs): production code must not unwrap/expect/panic, but test code may freely. Without these +# keys the deny would also fire inside #[cfg(test)]. +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true # tamper tests index byte offsets on purpose + +# Crypto / Ethereum jargon that would otherwise trip clippy::doc_markdown. ".." keeps clippy's +# built-in default identifier list (GitHub, OAuth, ...) and extends it. +doc-valid-idents = [ + "..", + "BIP-39", + "BIP-32", + "EIP-1559", + "ERC-20", + "XChaCha20", + "Argon2id", + "ChaCha20-Poly1305", + "secp256k1", + "keccak", + "Multicall3", +] + +# Wallet-specific footguns, banned by construction (both are unused today, so zero migration cost). +disallowed-methods = [ + { path = "std::mem::forget", reason = "skips Drop, so skips zeroize scrubbing of key material; use drop()" }, + { path = "core::mem::forget", reason = "same as std::mem::forget (re-export) — skips zeroize scrubbing; use drop()" }, + { path = "rand::thread_rng", reason = "use OsRng for anything key-derived (keystore.rs already does)" }, +] diff --git a/crates/deckard-core/Cargo.toml b/crates/deckard-core/Cargo.toml index 44721be..d18234a 100644 --- a/crates/deckard-core/Cargo.toml +++ b/crates/deckard-core/Cargo.toml @@ -33,3 +33,8 @@ rand = "0.8" [dev-dependencies] tokio = { version = "1", features = ["rt", "macros", "sync", "rt-multi-thread"] } + +# Inherit the workspace lint policy (see root Cargo.toml [workspace.lints]). deckard-core also sets +# #![forbid(unsafe_code)] in lib.rs — the security engine is a pure-safe, compile-time guarantee. +[lints] +workspace = true diff --git a/crates/deckard-core/src/balances.rs b/crates/deckard-core/src/balances.rs index 3d18c5e..86ceff9 100644 --- a/crates/deckard-core/src/balances.rs +++ b/crates/deckard-core/src/balances.rs @@ -46,7 +46,10 @@ pub struct Portfolio { } /// Read the full portfolio for `address` in one Multicall3 round-trip. -pub async fn fetch_portfolio(provider: &DynProvider, address: Address) -> anyhow::Result { +pub async fn fetch_portfolio( + provider: &DynProvider, + address: Address, +) -> anyhow::Result { let mc = IMulticall3::new(MULTICALL3, provider); let mut calls = Vec::with_capacity(DEFAULT_TOKENS.len() + 1); @@ -73,11 +76,19 @@ pub async fn fetch_portfolio(provider: &DynProvider, address: Address) -> anyhow Ok(r) if !r.is_empty() => r, _ => { let native_wei = provider.get_balance(address).await?; - return Ok(Portfolio { address, native_wei, tokens: Vec::new() }); + return Ok(Portfolio { + address, + native_wei, + tokens: Vec::new(), + }); } }; - let native_wei = IMulticall3::getEthBalanceCall::abi_decode_returns(&results[0].returnData)?; + // `results` is non-empty here (guarded by the match above); `.first()` avoids raw indexing. + let native = results + .first() + .ok_or_else(|| anyhow::anyhow!("multicall returned no results"))?; + let native_wei = IMulticall3::getEthBalanceCall::abi_decode_returns(&native.returnData)?; let mut tokens = Vec::new(); for (t, r) in DEFAULT_TOKENS.iter().zip(results.iter().skip(1)) { @@ -107,8 +118,8 @@ pub async fn fetch_portfolio(provider: &DynProvider, address: Address) -> anyhow /// `1_934_500_000_000_000_000` @ 18 decimals → `"1.9345"`. Truncates (never rounds /// up) to `max_frac` fractional digits and strips trailing zeros. pub fn format_amount(raw: U256, decimals: u8, max_frac: usize) -> String { - let full = alloy::primitives::utils::format_units(raw, decimals) - .unwrap_or_else(|_| "0".to_string()); + let full = + alloy::primitives::utils::format_units(raw, decimals).unwrap_or_else(|_| "0".to_string()); let (int_part, frac_part) = full.split_once('.').unwrap_or((full.as_str(), "")); let frac: String = frac_part.chars().take(max_frac).collect(); diff --git a/crates/deckard-core/src/eth.rs b/crates/deckard-core/src/eth.rs index 4f1f7d7..5a9fcc0 100644 --- a/crates/deckard-core/src/eth.rs +++ b/crates/deckard-core/src/eth.rs @@ -23,10 +23,21 @@ type Reply = flume::Sender>; /// Typed requests the GUI sends to the network worker. Each carries its own reply /// channel so call sites stay ergonomic and unrelated requests never head-of-line block. enum EthReq { - Balance { addr: Address, reply: Reply }, - BlockNumber { reply: Reply }, - Portfolio { addr: Address, reply: Reply }, - ResolveName { name: String, reply: Reply
}, + Balance { + addr: Address, + reply: Reply, + }, + BlockNumber { + reply: Reply, + }, + Portfolio { + addr: Address, + reply: Reply, + }, + ResolveName { + name: String, + reply: Reply
, + }, } /// A cloneable handle to the network worker thread. Clone it freely into UI views; @@ -42,6 +53,9 @@ impl EthProvider { pub fn spawn(rpc_url: impl Into) -> Self { let rpc_url = rpc_url.into(); let (tx, rx) = flume::unbounded::(); + // Fatal-at-startup boundary: if the OS refuses to spawn the network thread the app cannot + // function, so a clear panic is correct here — this is not fallible user input. + #[allow(clippy::expect_used)] std::thread::Builder::new() .name("deckard-eth".into()) .spawn(move || run_worker(rpc_url, rx)) @@ -68,7 +82,10 @@ impl EthProvider { } /// Forward-resolve an ENS name (e.g. `vitalik.eth`) to an address. - pub fn resolve_name(&self, name: impl Into) -> flume::Receiver> { + pub fn resolve_name( + &self, + name: impl Into, + ) -> flume::Receiver> { let name = name.into(); self.request(|reply| EthReq::ResolveName { name, reply }) } @@ -91,6 +108,9 @@ impl EthProvider { /// The worker entry point: build the runtime + provider, then service requests until /// every `EthProvider` handle has dropped (which closes `rx`). fn run_worker(rpc_url: String, rx: flume::Receiver) { + // Fatal-at-startup boundary: a current-thread runtime we cannot build leaves the worker unable + // to do anything; panicking with a clear message beats silently servicing nothing. + #[allow(clippy::expect_used)] let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() diff --git a/crates/deckard-core/src/keystore.rs b/crates/deckard-core/src/keystore.rs index 023d867..2f48c40 100644 --- a/crates/deckard-core/src/keystore.rs +++ b/crates/deckard-core/src/keystore.rs @@ -60,10 +60,18 @@ impl KdfParams { /// (~0.5–1s on Apple Silicon), and strictly harder than every surveyed wallet (which /// use PBKDF2 or scrypt). The header carries the params, so `/cso` can raise them and /// old vaults still open (upgrade-on-unlock re-seals). - pub const PRODUCTION: KdfParams = KdfParams { m_kib: 256 * 1024, t: 3, p: 1 }; + pub const PRODUCTION: KdfParams = KdfParams { + m_kib: 256 * 1024, + t: 3, + p: 1, + }; /// Fast params for tests only — NOT for real vaults. #[cfg(test)] - pub const FAST_TEST: KdfParams = KdfParams { m_kib: 8 * 1024, t: 1, p: 1 }; + pub const FAST_TEST: KdfParams = KdfParams { + m_kib: 8 * 1024, + t: 1, + p: 1, + }; fn validate(&self) -> anyhow::Result<()> { anyhow::ensure!(self.p == 1, "unsupported Argon2 parallelism"); @@ -71,7 +79,10 @@ impl KdfParams { (MIN_M_KIB..=MAX_M_KIB).contains(&self.m_kib), "Argon2 memory cost out of bounds" ); - anyhow::ensure!((1..=MAX_T).contains(&self.t), "Argon2 time cost out of bounds"); + anyhow::ensure!( + (1..=MAX_T).contains(&self.t), + "Argon2 time cost out of bounds" + ); Ok(()) } } @@ -153,6 +164,7 @@ impl Header { /// The on-disk vault: header + the two ciphertexts. Carries no plaintext secret. #[derive(Clone)] +#[must_use] pub struct Vault { header: Header, wrapped_dek: [u8; WRAPPED_DEK_LEN], @@ -175,13 +187,21 @@ impl Vault { OsRng.fill_bytes(&mut entropy); let phrase = entropy_to_phrase(&entropy)?; - let kind = if n == 16 { SecretKind::Entropy16 } else { SecretKind::Entropy32 }; + let kind = if n == 16 { + SecretKind::Entropy16 + } else { + SecretKind::Entropy32 + }; let vault = Self::seal(kind, &entropy, passphrase, kdf)?; Ok((vault, phrase)) } /// Import an existing BIP-39 phrase (checksum-validated). - pub fn import_mnemonic(phrase: &str, passphrase: &str, kdf: KdfParams) -> anyhow::Result { + pub fn import_mnemonic( + phrase: &str, + passphrase: &str, + kdf: KdfParams, + ) -> anyhow::Result { let mnemonic = bip39::Mnemonic::parse(phrase.trim()) .map_err(|_| anyhow::anyhow!("invalid recovery phrase"))?; let entropy = Zeroizing::new(mnemonic.to_entropy()); @@ -200,7 +220,8 @@ impl Vault { /// bytes are a valid secp256k1 scalar — so we never persist an unusable vault. pub fn import_raw_key(hex: &str, passphrase: &str, kdf: KdfParams) -> anyhow::Result { let key = parse_exact_32(hex)?; - PrivateKeySigner::from_slice(&key).map_err(|_| anyhow::anyhow!("not a valid private key"))?; + PrivateKeySigner::from_slice(&key) + .map_err(|_| anyhow::anyhow!("not a valid private key"))?; Self::seal(SecretKind::RawKey, &key, passphrase, kdf) } @@ -248,7 +269,11 @@ impl Vault { let payload_aad = [AAD_PAYLOAD, &core, &wrapped_dek].concat(); let ct_entropy = aead_encrypt(dek.as_slice(), &entropy_nonce, secret, &payload_aad)?; - Ok(Vault { header, wrapped_dek, ct_entropy }) + Ok(Vault { + header, + wrapped_dek, + ct_entropy, + }) } /// Decrypt the vault with `passphrase`, yielding an in-memory unlocked wallet. @@ -261,23 +286,39 @@ impl Vault { let kek = derive_kek(passphrase.as_bytes(), &self.header.salt, &self.header.kdf)?; let wrap_aad = [AAD_WRAP, &core].concat(); let dek = Zeroizing::new( - aead_decrypt(kek.as_slice(), &self.header.wrap_nonce, &self.wrapped_dek, &wrap_aad) - .map_err(|_| unlock_failed())?, + aead_decrypt( + kek.as_slice(), + &self.header.wrap_nonce, + &self.wrapped_dek, + &wrap_aad, + ) + .map_err(|_| unlock_failed())?, ); let payload_aad = [AAD_PAYLOAD, &core, &self.wrapped_dek].concat(); let secret = Zeroizing::new( - aead_decrypt(dek.as_slice(), &self.header.entropy_nonce, &self.ct_entropy, &payload_aad) - .map_err(|_| unlock_failed())?, + aead_decrypt( + dek.as_slice(), + &self.header.entropy_nonce, + &self.ct_entropy, + &payload_aad, + ) + .map_err(|_| unlock_failed())?, ); - Ok(UnlockedVault { kind: self.header.secret_kind, secret }) + Ok(UnlockedVault { + kind: self.header.secret_kind, + secret, + }) } /// Serialize to the on-disk byte format. pub fn to_bytes(&self) -> Vec { let mut b = self.header.core_bytes(); b.extend_from_slice(&self.wrapped_dek); + // ct_entropy is bounded by MAX_CT_ENTROPY (1024) at both seal and parse time (and `read` + // caps the whole file at MAX_VAULT_BYTES), so this cast can never truncate — to_bytes stays + // infallible by construction. b.extend_from_slice(&(self.ct_entropy.len() as u32).to_le_bytes()); b.extend_from_slice(&self.ct_entropy); b @@ -292,7 +333,11 @@ impl Vault { let mut vault_id = [0u8; VAULT_ID_LEN]; vault_id.copy_from_slice(r.take(VAULT_ID_LEN)?); anyhow::ensure!(r.u8()? == KDF_ARGON2ID, "unsupported KDF"); - let kdf = KdfParams { m_kib: r.u32()?, t: r.u32()?, p: r.u32()? }; + let kdf = KdfParams { + m_kib: r.u32()?, + t: r.u32()?, + p: r.u32()?, + }; kdf.validate()?; // cap-check BEFORE deriving anything let mut salt = [0u8; SALT_LEN]; salt.copy_from_slice(r.take(SALT_LEN)?); @@ -363,7 +408,10 @@ impl Vault { /// reading it into memory (a hostile multi-GB `vault.bin` can't OOM us). pub fn read(path: &Path) -> anyhow::Result { let meta = std::fs::metadata(path)?; - anyhow::ensure!(meta.len() <= MAX_VAULT_BYTES, "vault file is implausibly large"); + anyhow::ensure!( + meta.len() <= MAX_VAULT_BYTES, + "vault file is implausibly large" + ); let bytes = std::fs::read(path)?; Self::from_bytes(&bytes) } @@ -375,6 +423,7 @@ impl Vault { /// An unlocked wallet held in memory only while the app is unlocked. Drops zeroize the /// secret. The alloy signer is reconstructed transiently per call and never stored. +#[must_use] pub struct UnlockedVault { kind: SecretKind, secret: Zeroizing>, // entropy (16/32) or a raw 32-byte key @@ -411,14 +460,21 @@ impl UnlockedVault { /// The recovery phrase, for the gated reveal flow. Errors for raw-key imports. pub fn reveal_phrase(&self) -> anyhow::Result> { - anyhow::ensure!(self.kind.has_phrase(), "imported raw key has no recovery phrase"); + anyhow::ensure!( + self.kind.has_phrase(), + "imported raw key has no recovery phrase" + ); entropy_to_phrase(&self.secret) } } // --- crypto helpers --- -fn derive_kek(passphrase: &[u8], salt: &[u8], kdf: &KdfParams) -> anyhow::Result> { +fn derive_kek( + passphrase: &[u8], + salt: &[u8], + kdf: &KdfParams, +) -> anyhow::Result> { let params = Params::new(kdf.m_kib, kdf.t, kdf.p, Some(32)) .map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?; let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); @@ -429,7 +485,12 @@ fn derive_kek(passphrase: &[u8], salt: &[u8], kdf: &KdfParams) -> anyhow::Result Ok(kek) } -fn aead_encrypt(key: &[u8], nonce: &[u8; NONCE_LEN], msg: &[u8], aad: &[u8]) -> anyhow::Result> { +fn aead_encrypt( + key: &[u8], + nonce: &[u8; NONCE_LEN], + msg: &[u8], + aad: &[u8], +) -> anyhow::Result> { anyhow::ensure!(key.len() == 32, "bad AEAD key length"); let cipher = XChaCha20Poly1305::new(Key::from_slice(key)); cipher @@ -437,7 +498,12 @@ fn aead_encrypt(key: &[u8], nonce: &[u8; NONCE_LEN], msg: &[u8], aad: &[u8]) -> .map_err(|_| anyhow::anyhow!("encryption failed")) } -fn aead_decrypt(key: &[u8], nonce: &[u8; NONCE_LEN], ct: &[u8], aad: &[u8]) -> anyhow::Result> { +fn aead_decrypt( + key: &[u8], + nonce: &[u8; NONCE_LEN], + ct: &[u8], + aad: &[u8], +) -> anyhow::Result> { anyhow::ensure!(key.len() == 32, "bad AEAD key length"); let cipher = XChaCha20Poly1305::new(Key::from_slice(key)); cipher @@ -471,11 +537,16 @@ fn entropy_to_phrase(entropy: &[u8]) -> anyhow::Result> { /// Parse a hex private key that MUST be exactly 32 bytes (64 hex chars, optional `0x`). fn parse_exact_32(hex: &str) -> anyhow::Result>> { let h = hex.trim().strip_prefix("0x").unwrap_or(hex.trim()); - anyhow::ensure!(h.len() == 64, "private key must be exactly 32 bytes (64 hex chars)"); + anyhow::ensure!( + h.len() == 64, + "private key must be exactly 32 bytes (64 hex chars)" + ); let mut out = Zeroizing::new(vec![0u8; 32]); - for (i, chunk) in h.as_bytes().chunks(2).enumerate() { + // h.len() == 64 (checked above) → exactly 32 two-char chunks, matching out's 32 slots. + // iter_mut().zip() avoids raw indexing (clippy::indexing_slicing). + for (slot, chunk) in out.iter_mut().zip(h.as_bytes().chunks(2)) { let s = std::str::from_utf8(chunk).map_err(|_| anyhow::anyhow!("invalid hex"))?; - out[i] = u8::from_str_radix(s, 16).map_err(|_| anyhow::anyhow!("invalid hex"))?; + *slot = u8::from_str_radix(s, 16).map_err(|_| anyhow::anyhow!("invalid hex"))?; } Ok(out) } @@ -490,18 +561,31 @@ impl<'a> Reader<'a> { Self { buf, pos: 0 } } fn take(&mut self, n: usize) -> anyhow::Result<&'a [u8]> { - let end = self.pos.checked_add(n).filter(|e| *e <= self.buf.len()); - let end = end.ok_or_else(|| anyhow::anyhow!("vault truncated"))?; - let s = &self.buf[self.pos..end]; + let end = self + .pos + .checked_add(n) + .filter(|e| *e <= self.buf.len()) + .ok_or_else(|| anyhow::anyhow!("vault truncated"))?; + // `.get(range)` instead of `self.buf[pos..end]`: bounds-checked, no raw slice indexing. + let s = self + .buf + .get(self.pos..end) + .ok_or_else(|| anyhow::anyhow!("vault truncated"))?; self.pos = end; Ok(s) } fn u8(&mut self) -> anyhow::Result { - Ok(self.take(1)?[0]) + self.take(1)? + .first() + .copied() + .ok_or_else(|| anyhow::anyhow!("vault truncated")) } fn u32(&mut self) -> anyhow::Result { - let b = self.take(4)?; - Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + let b: [u8; 4] = self + .take(4)? + .try_into() + .map_err(|_| anyhow::anyhow!("vault truncated"))?; + Ok(u32::from_le_bytes(b)) } /// Assert the whole buffer was consumed (no trailing bytes). fn finish(&self) -> anyhow::Result<()> { @@ -583,7 +667,10 @@ mod tests { let (vault, _) = Vault::create(PW, WordCount::Twelve, KdfParams::FAST_TEST).unwrap(); let mut bytes = vault.to_bytes(); bytes.push(0x00); // one extra byte - assert!(Vault::from_bytes(&bytes).is_err(), "trailing garbage must be rejected"); + assert!( + Vault::from_bytes(&bytes).is_err(), + "trailing garbage must be rejected" + ); } #[test] @@ -594,7 +681,10 @@ mod tests { // +m(4)+t(4)+p(4)+salt(16) = offset 51. let off = 4 + 1 + 1 + VAULT_ID_LEN + 1 + 4 + 4 + 4 + SALT_LEN; bytes[off] = 1; - assert!(Vault::from_bytes(&bytes).is_err(), "non-zero BIP-39 flag must be rejected"); + assert!( + Vault::from_bytes(&bytes).is_err(), + "non-zero BIP-39 flag must be rejected" + ); } #[test] @@ -613,7 +703,10 @@ mod tests { let ct_actual = bytes.len(); // we'll just corrupt the declared length to huge let len_pos = ct_actual - vault.ct_entropy.len() - 4; bytes[len_pos..len_pos + 4].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(Vault::from_bytes(&bytes).is_err(), "absurd ct length must be rejected"); + assert!( + Vault::from_bytes(&bytes).is_err(), + "absurd ct length must be rejected" + ); } #[test] @@ -623,6 +716,9 @@ mod tests { // m_kib lives right after magic(4)+ver(1)+kind(1)+vault_id(16)+kdf_id(1) = offset 23. let off = 4 + 1 + 1 + VAULT_ID_LEN + 1; bytes[off..off + 4].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(Vault::from_bytes(&bytes).is_err(), "absurd m_kib must be rejected"); + assert!( + Vault::from_bytes(&bytes).is_err(), + "absurd m_kib must be rejected" + ); } } diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index d317ebd..725a773 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -9,6 +9,23 @@ //! over `flume` channels, whose `recv_async()` future is runtime-agnostic — so the //! GUI thread never blocks and never touches tokio. +// The security engine is pure-safe by construction. `forbid` (stronger than the workspace-wide +// `deny`) makes that a hard compile-time guarantee that cannot be locally overridden — no agent or +// future edit can introduce an `unsafe` block in the crate that touches keys and untrusted bytes. +#![forbid(unsafe_code)] +// Panic-class restriction lints for the trust core (issue #7, hardened path): production code in +// deckard-core propagates errors, it does not panic on bad input. Tests are exempt via clippy.toml +// (allow-{unwrap,expect,panic,indexing-slicing}-in-tests). The two legitimate startup-fatal +// `expect`s in eth.rs carry a local `#[allow(clippy::expect_used)]` with a documented reason. +#![deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::get_unwrap, + clippy::indexing_slicing, + clippy::panic, + clippy::mem_forget +)] + pub mod balances; pub mod eth; pub mod keystore; @@ -16,9 +33,7 @@ pub mod tokens; pub use balances::{fetch_portfolio, format_amount, Portfolio, TokenBalance}; pub use eth::{EthProvider, DEFAULT_RPC}; -pub use keystore::{ - random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount, -}; +pub use keystore::{random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount}; pub use tokens::{TokenInfo, DEFAULT_TOKENS}; // Re-export the alloy primitive types the UI renders, so the app layer doesn't diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..2b2e77f --- /dev/null +++ b/deny.toml @@ -0,0 +1,63 @@ +# cargo-deny configuration — supply-chain gate. See docs/AGENTIC-ENGINEERING.md §4. +# Run locally with: cargo install cargo-deny && cargo deny check +# The CI job lands NON-BLOCKING (continue-on-error) first; promote it to a required check after one +# green run AND after seeding [licenses].allow below from a real `cargo deny check licenses` run. + +[advisories] +# RustSec advisory DB. Vulnerabilities and unmaintained crates are denied by default in cargo-deny +# v2; we additionally refuse yanked crates. Add to `ignore` ONLY with a written justification. +yanked = "deny" +ignore = [] + +[licenses] +version = 2 +confidence-threshold = 0.9 +# ⚠️ SEED-THEN-TIGHTEN: this is a reasonable starting superset, NOT verified against the full +# alloy + git-gpui tree. Run `cargo deny check licenses` locally and add exactly what it reports +# (plus [licenses.exceptions] for any single oddball) before making the CI job blocking. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", + "MPL-2.0", + "Unlicense", + "CC0-1.0", +] +# This workspace's OWN crates are AGPL-3.0-or-later (see each Cargo.toml). cargo-deny license-checks +# first-party crates too, so allow AGPL for our crates ONLY (not the whole dependency tree). If +# cargo-deny rejects the `crate =` key on the action's version, switch to `name =`; verify on the +# first local `cargo deny check licenses` run. +exceptions = [ + { allow = ["AGPL-3.0-or-later"], crate = "deckard" }, + { allow = ["AGPL-3.0-or-later"], crate = "deckard-core" }, +] + +[bans] +# The git gpui stack legitimately pulls duplicate versions (e.g. objc2 0.5 + 0.6), so a hard deny +# here would be permanently red. Warn so we can see the dupes without blocking. +multiple-versions = "warn" +wildcards = "deny" +deny = [ + { crate = "openssl", reason = "prefer rustls/ring; avoid the OpenSSL CVE surface" }, +] + +[sources] +# Only crates.io and these trusted git origins are allowed. A typo-squatted or malicious forked git +# dependency fails CI here. The zed-industries/* forks of font-kit/reqwest/scap/wgpu are Zed's own +# pinned upstream patches, pulled transitively by the gpui git stack — verify this list against +# `grep 'git+' Cargo.lock` after every `just bump-gpui` (Zed may add/retire forks). +unknown-registry = "deny" +unknown-git = "deny" +allow-git = [ + "https://github.com/zed-industries/zed", + "https://github.com/zed-industries/font-kit", + "https://github.com/zed-industries/reqwest", + "https://github.com/zed-industries/scap", + "https://github.com/zed-industries/wgpu", + "https://github.com/longbridge/gpui-component", +] diff --git a/docs/AGENTIC-ENGINEERING.md b/docs/AGENTIC-ENGINEERING.md new file mode 100644 index 0000000..9bf4c8a --- /dev/null +++ b/docs/AGENTIC-ENGINEERING.md @@ -0,0 +1,330 @@ +# Agentic engineering: lints, constraints & CI for building fast with coding agents + +> **Audience:** maintainers of Deckard *and* the upstream **Deck** GPUI starter it was forked +> from. Most of this is general to any GPUI + Rust project; the few **wallet-specific** rules are +> tagged `🔐` so Deck can drop them. This is the *why* behind the config; the *what to run* lives in +> `CLAUDE.md` / `AGENTS.md`. + +## The thesis + +A coding agent is fast but has no taste and no memory of *this* repo's intent. The cheapest way to +make an agent (or a hurried human) reliably produce good code is to **make the compiler and CI say +no for you.** Every rule below converts a class of mistake from "caught in review, maybe" into +"caught at `cargo check`, always." + +Three principles, in priority order: + +1. **The manifest is the source of truth, not CI flags.** If the lint policy only lives in a CI + `-D warnings` flag, the agent doesn't see it until the build is already red. Put it in + `Cargo.toml` (`[workspace.lints]`) and `clippy.toml` so `rust-analyzer` and `cargo check` show + the *exact same* policy at the moment code is written. **Shorten the feedback loop to zero.** +2. **CI is the only gate that matters.** Pre-commit hooks, editor warnings, and good intentions are + all bypassable (`git commit --no-verify`, "I'll fix it later"). An agent will confidently report + "done" while red. So anything you actually care about must *block merge* in CI. +3. **Prefer compile-time over runtime, and `warn` → fix → `deny` over big-bang.** Land a new lint as + `warn`, clear the backlog, *then* flip to `deny`. A rule that breaks `main` on day one gets + reverted; a rule that lands green stays forever. + +These match what the paradigm Rust projects do — we cross-checked +[reth](https://github.com/paradigmxyz/reth) (an Ethereum node, our closest analog), +[alloy](https://github.com/alloy-rs/alloy) (which we depend on), +[Zed](https://github.com/zed-industries/zed) (our GPUI source), tokio, ripgrep, and the +[Embark Studios shared lint set](https://github.com/EmbarkStudios/rust-ecosystem/blob/main/lints.rs). +The recurring pattern is identical: a `[workspace.lints]` table, a `clippy.toml`, a checked-in +`rustfmt.toml`, `cargo-deny` in CI, and a CI matrix that runs fmt + clippy + test on every push. + +--- + +## Tier 1 — the do-now set + +Each item: **what**, **why it helps an agent**, the **snippet**, and the **tradeoff**. + +### 1. Move the lint policy into the manifest — `[workspace.lints]` + +**What.** A lint table in the root `Cargo.toml`, inherited by every crate via `[lints] workspace = +true`. Today our `-D warnings` policy exists *only* as a CLI flag in `just check` and CI. + +```toml +# root Cargo.toml +[workspace.lints.rust] +unused_must_use = "deny" # an ignored Result from a fallible crypto/IO call is a silent bug +unsafe_code = "deny" # deny (not forbid) at the root — see §6 for why the escape hatch matters + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } # priority = -1 is required on a lint *group* +todo = "deny" # a stray todo!() left on a code path panics in production +dbg_macro = "deny" # dbg!(x) is debug noise — and 🔐 dbg!(seed) is a key leak +``` + +Then in **both** package manifests (the root `deckard` package *and* `crates/deckard-core`): + +```toml +[lints] +workspace = true +``` + +> **Gotcha (verified):** the root `Cargo.toml` is *both* a package and the workspace root, so it +> does **not** auto-inherit — it needs its own `[lints] workspace = true` line, same as the member +> crate. + +**Why for an agent.** The agent now sees `clippy::all` and the deny-list in-editor and at +`cargo check`, identical to what CI enforces — no more "looked fine locally, red in CI." `todo` / +`dbg_macro` at `deny` make two classic agent habits (leaving a `todo!()` stub, leaving a `dbg!`) +into hard compile errors. + +**Tradeoff.** We keep `clippy::all` at `warn` in the manifest (not `deny`) and keep `-D warnings` +in CI. That's deliberate: `warn` lets you iterate locally without every style nit blocking +`cargo build`, while CI still fails on any warning. Setting `clippy::all = "deny"` would make a +*future toolchain bump* (new clippy lints) break the build for unrelated reasons. `warn` + CI gate +is the reth/alloy convention. + +### 2. A project lint config — `clippy.toml` + +**What.** New file at the workspace root. + +```toml +msrv = "1.95.0" # match rust-toolchain.toml; governs which API suggestions clippy makes +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true + +# crypto/eth jargon that would otherwise trip clippy::doc_markdown. ".." keeps clippy's defaults. +doc-valid-idents = ["..", "BIP-39", "BIP-32", "EIP-1559", "ERC-20", "XChaCha20", + "Argon2id", "ChaCha20-Poly1305", "secp256k1", "keccak", "Multicall3"] + +# 🔐 wallet-specific footguns, banned by construction (both unused today → zero migration): +disallowed-methods = [ + { path = "std::mem::forget", reason = "skips Drop, so skips zeroize scrubbing of key material; use drop()" }, + { path = "rand::thread_rng", reason = "use OsRng for anything key-derived (keystore.rs already does)" }, +] +``` + +**Why for an agent.** `disallowed-methods` is the highest-signal-per-line tool clippy offers: it +turns "don't use X here, we have reasons" tribal knowledge into a compile error *with the reason +printed*. An agent reaching for `thread_rng()` to generate entropy gets told, at `cargo check`, to +use `OsRng`. The `allow-*-in-tests` keys let `deckard-core`'s crate-level restriction lints +(`#![deny(clippy::unwrap_used, expect_used, panic, indexing_slicing, …)]`) coexist with normal test +code that unwraps freely. + +**Tradeoff.** None material — both disallowed methods are already unused, so this is pure +prevention. Deck: keep `doc-valid-idents` (trim the crypto words), drop the two `disallowed-methods` +or replace with your own footguns. + +### 3. Deterministic formatting — `rustfmt.toml` + +**What.** New file at the workspace root. All keys are **stable-channel** (our pinned 1.95 stable +`cargo fmt` honors them; nightly-only keys like `imports_granularity` / `group_imports` are +deliberately excluded — stable silently ignores them, which is worse than not setting them). + +```toml +edition = "2021" +max_width = 100 +``` + +Intentionally minimal — `edition` + the width the code is already written to (both rustfmt defaults +today), so landing it is near-zero churn. Style idioms (e.g. redundant field names) are left to +`clippy::all`. An opinionated `use_small_heuristics = "Max"` was tried and dropped: it reflowed ~2× +more code for no correctness gain. + +**Why for an agent.** Without a checked-in config, two agents (or two rustfmt versions) format the +same code differently, producing noisy diffs that bury the real change. A pinned config makes the +diff deterministic, and `cargo fmt --all --check` in CI (§5) makes "did you run fmt?" a yes/no gate +instead of a review comment. + +**Tradeoff.** The repo wasn't `cargo fmt`-clean to begin with, so enabling the `--check` gate needs +**one baseline `cargo fmt --all` commit** (formatting-only — rustfmt never changes semantics). + +### 4. Supply-chain gate — `deny.toml` + a `cargo-deny` CI job + +**What.** `cargo-deny` checks the dependency tree for security advisories, banned/duplicate crates, +disallowed licenses, and untrusted sources. New `deny.toml`: + +```toml +[advisories] +yanked = "deny" +ignore = [] # add { id = "RUSTSEC-…", reason = "…" } only with written justification + +[licenses] +version = 2 +confidence-threshold = 0.9 +# ⚠️ SEED THIS from a real `cargo deny check licenses` run before making the job blocking — +# the alloy + git-gpui tree pulls a wide license surface and a blind list WILL red the build. +allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", + "ISC", "Unicode-3.0", "Zlib", "MPL-2.0", "Unlicense", "CC0-1.0"] + +[bans] +multiple-versions = "warn" # the git gpui stack pulls dup versions (objc2 0.5+0.6) — deny is unworkable +wildcards = "deny" +deny = [{ crate = "openssl", reason = "prefer rustls/ring; avoid the OpenSSL CVE surface" }] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +# All SIX git origins, not two: Zed pins its own forks of font-kit/reqwest/scap/wgpu, pulled +# transitively by the gpui stack. Re-derive with `grep 'git+' Cargo.lock` after every bump-gpui. +allow-git = ["https://github.com/zed-industries/zed", + "https://github.com/zed-industries/font-kit", + "https://github.com/zed-industries/reqwest", + "https://github.com/zed-industries/scap", + "https://github.com/zed-industries/wgpu", + "https://github.com/longbridge/gpui-component"] +``` + +**Why for an agent.** Three agent/maintenance failure modes, gated at once: (a) pulling in a crate +with a known RUSTSEC advisory, (b) adding a dep from a random git fork (the `[sources]` allow-list +permits only the known git origins — currently the six Zed/longbridge ones the gpui stack pulls — so +a typo-squat or malicious fork fails CI), (c) license drift in a copyleft project. For a 🔐 wallet this is table stakes; even for Deck it's +cheap insurance. + +**Tradeoff.** The license `allow` list must be **seeded locally first** (`cargo deny check +licenses`), and `multiple-versions` must stay `warn` because the git gpui tree legitimately +duplicates crates. We therefore land the CI job **non-blocking** and promote it after one green run +(see Rollout). + +### 5. Close the CI gaps + +**What.** Our CI currently runs only `cargo build`, `cargo build --features tray`, and +`cargo clippy --all-targets --features tray -- -D warnings`. That means: **format is never checked, +tests never run, and clippy never lints the default (`--features`-less) build that we actually +ship.** Add (split across the existing macOS/Linux jobs to respect the macOS minute multiplier): + +```yaml +# linux job (formatting is OS-independent, so check it once on the cheap runner): +- run: cargo fmt --all --check +- run: cargo clippy --locked --all-targets -- -D warnings # the DEFAULT feature config CI never linted +- run: cargo test --locked --workspace + +# macOS job: +- run: cargo clippy --locked --all-targets -- -D warnings +- run: cargo test --locked --workspace +``` + +> Add `--locked` to **all** `cargo build`/`clippy`/`test` invocations (not shown above per-line): +> reproducibility lives entirely in the committed `Cargo.lock` (it pins the exact git gpui commits), +> so `--locked` makes a stale lockfile a CI failure. `just bump-gpui` rewrites and commits the lock, +> so this never fights the bump workflow. + +Plus the `cargo-deny` job (non-blocking first): + +```yaml +cargo-deny: + runs-on: ubuntu-latest + # Informational on first land. Promote to a required check after one green run AND after seeding + # deny.toml [licenses].allow from a local `cargo deny check licenses`. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: { command: check advisories bans sources licenses } +``` + +**Why for an agent.** This is principle #2 made real. `deckard-core`'s keystore/eth tests exist but +**never run in CI today** — an agent can break the encrypted-vault round-trip and CI stays green. +Running `cargo test` and the default-feature clippy closes the "green CI but actually broken" hole +that lets an agent honestly believe it's done. + +**Tradeoff.** Slightly longer CI; the macOS runner is 10× billed on *private* repos (we're public, +so free) — hence formatting/deny run only on Linux. + +### 6. Unsafe policy — forbid the core, deny the app + +**What.** + +```rust +// crates/deckard-core/src/lib.rs — first item, after the //! module docs +#![forbid(unsafe_code)] // the engine is pure-safe; make it a compile-time guarantee +``` + +```toml +# root Cargo.toml [workspace.lints.rust] — applies to the app crate +unsafe_code = "deny" # deny, NOT forbid — see below +``` + +**Why for an agent.** `deckard-core` (the part that touches keys, crypto, and untrusted bytes) has +zero `unsafe` — `forbid` locks that in so no agent can ever sneak an `unsafe` block into the +security core. The app crate's only Apple-FFI code (the 🔐/tray dock-hiding via `objc2`) currently +uses *safe* wrappers, so `unsafe_code = "deny"` compiles today for both the default and +`--features tray` builds. + +**Tradeoff — why `deny` not `forbid` at the root:** `forbid` cannot be locally overridden. If a +future `objc2` bump reintroduces a raw `unsafe {}` block in the tray path, `forbid` would brick the +build with no escape hatch. `deny` lets you add a single, reviewed, `// SAFETY:`-commented +`#[allow(unsafe_code)]` at that exact site. (Note: `[workspace.lints]` governs first-party code +only — `unsafe` inside dependencies is unaffected, which is correct.) + +### 7. Tell the agent the rules — `CLAUDE.md` + `AGENTS.md` + +**What.** Our `CLAUDE.md` only covers the design system. Add an "Engineering & verification" +section, and create an `AGENTS.md` (Codex and other agents read that filename) mirroring it. The +essentials: the fast iteration command, an explicit **definition of done**, and the code +constraints (flagging what's lint-enforced today vs what's still convention). + +```markdown +## Engineering & verification +Iterate fast: `cargo check -p deckard-core` (the engine is GPUI-free — checks in seconds). +Definition of done (ALL must hold; paste the command output as evidence): +1. `cargo fmt --all --check` clean +2. clippy `-D warnings` green on BOTH the default and `--features tray` configs +3. `cargo test --workspace` green +4. No new/changed deps (Cargo.toml or Cargo.lock) unless explicitly approved + +## Code constraints +Workspace: `todo!`/`dbg!` denied; `unused_must_use` denied; `deckard-core` is `#![forbid(unsafe_code)]` ++ app crate `unsafe_code = "deny"`; `mem::forget`/`thread_rng` denied. deckard-core additionally +`#![deny(...)]`s unwrap/expect/panic/indexing_slicing in non-test code (untrusted bytes go through the +bounded `Reader`; genuine fatal boundaries use a scoped `#[allow]` + reason). +🔐 Always: never log or `Debug`-print a seed, key, or passphrase; secrets stay in `Zeroizing`. +``` + +**Why for an agent.** An explicit "definition of done with evidence" is the single most effective +guardrail against an agent declaring victory while red. The constraints duplicate what the lints +enforce, in prose, so the agent internalizes them *before* writing rather than learning from a +failed build. + +**Tradeoff.** Two files to keep in sync (`CLAUDE.md` and `AGENTS.md`). Keep them short and point +both at this doc for the rationale. + +--- + +## Deliberately NOT recommended (so we don't over-rotate) + +| Tempting | Why we skip it | +|---|---| +| `#![forbid(unsafe_code)]` at the **workspace root** | No escape hatch if an `objc2` bump needs raw `unsafe` in the tray path. Use root `deny` + `forbid` only on `deckard-core`. | +| `clippy::pedantic` / `clippy::nursery` globally | Too noisy for a small UI app; nursery lints are unstable → a toolchain bump can surface new warnings and break `-D warnings`. Cherry-pick instead. | +| The full `clippy::restriction` group | Clippy's own docs say never enable it wholesale (it contains mutually contradictory lints). Pick individual ones. | +| `indexing_slicing = "deny"` | 🔐 The keystore's binary-format `Reader` does *bounds-checked* raw indexing by design; `deny` forces a rewrite or `#[allow]` litter for no safety gain. | +| `let_underscore_must_use = "deny"` | Breaks 6 intentional `let _ = ()` discards (e.g. best-effort `sync_all()`, `reply.send()` on a closed channel). | +| Edition 2024 bump | We pin 1.95 in lockstep with gpui's git HEAD; an edition bump is orthogonal churn that risks the gpui pairing. | +| Nightly rustfmt keys / nightly build flags (`-Zthreads`, cranelift, `-Zshare-generics`) | The 1.95 stable pin is **mandatory** for the git gpui build — nightly flags in a committed config brick `cargo build` for everyone. | +| `panic = "abort"` | A wallet wants unwinding so a panic mid-keystore-write doesn't `abort()`; also the tests assert rejection/`is_err()` paths. | +| `multiple-versions = "deny"` in deny.toml | The git gpui stack pulls duplicate versions (objc2 0.5+0.6); permanently red. Keep `warn`. | +| mold/lld linker config | On 1.95, `rust-lld` is already the Linux default and Apple `ld-prime` the macOS default (lld on macOS is a measured *regression*). Do nothing. | + +--- + +## Rollout order (stays green at every step) + +1. **Baseline format.** `cargo fmt --all`, commit. Add `rustfmt.toml`. → `--check` now passes. +2. **Lints.** Add `[workspace.lints]` + `[lints] workspace = true` in both manifests + `clippy.toml`. + Run clippy (both feature configs); fix any wave; the deny-level items here are pre-verified clean. +3. **Unsafe policy.** `#![forbid(unsafe_code)]` on `deckard-core`; `unsafe_code = "deny"` at root. +4. **CI gaps.** Add fmt-check, default-feature clippy, and `cargo test`. Land, confirm green, mark required. +5. **deny.toml.** Seed `[licenses].allow` from a local `cargo deny check licenses` run *first*; land + the `cargo-deny` job non-blocking; promote to required after one green run. +6. **Docs.** Update `CLAUDE.md`, add `AGENTS.md`. +7. **Tier 2** (see below) incrementally. + +The invariant: **every new lint enters as `warn`; every new CI job enters non-blocking. Confirm +green, then tighten.** `main` is never red because of a hardening change. + +--- + +## Provenance + +Derived from a multi-agent research sweep of paradigm Rust OSS repos (reth, alloy, Zed, tokio, +ripgrep, Embark) plus a feasibility pass that verified every recommendation against this repo's +actual source — which caught and corrected several plausible-but-wrong suggestions (e.g. the +keystore's `unwrap`s are all test-only; its `Reader` legitimately indexes raw bytes). Tier 2 / Tier +3 (nextest, profile tuning, typed errors, `cargo-machete`, doctests, proptest, MSRV job, release +automation) are tracked separately. diff --git a/justfile b/justfile index 197fd14..9a03dd0 100644 --- a/justfile +++ b/justfile @@ -21,8 +21,8 @@ run-tray: fmt: cargo fmt check: - cargo clippy --all-targets -- -D warnings - cargo clippy --all-targets --features tray -- -D warnings + cargo clippy --locked --all-targets -- -D warnings + cargo clippy --locked --all-targets --features tray -- -D warnings # Bump the git GPUI stack to the latest upstream commits, then rebuild. # Reproducibility lives in Cargo.lock — commit it (and rust-toolchain.toml if you diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..e337f2a --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,8 @@ +# Checked-in formatter config so diffs stay deterministic across rustfmt versions and machines. +# Intentionally minimal: it pins the edition + width the code is already formatted to (rustfmt's +# defaults today), so landing it is near-zero churn. Style idioms (e.g. redundant field names) are +# left to clippy. Every key is STABLE-channel — `just fmt` runs the pinned 1.95.0 stable `cargo fmt`, +# which silently ignores nightly-only keys (imports_granularity, group_imports), so we don't set them. +# See docs/AGENTIC-ENGINEERING.md §3. +edition = "2021" +max_width = 100 From e90737ac59e4e88758710f036d7e071f5de07eec Mon Sep 17 00:00:00 2001 From: hellno Date: Sun, 7 Jun 2026 22:10:18 +0200 Subject: [PATCH 3/3] fix(keystore): close the unlock error-message oracle Vault::open collapses every unlock failure (parse, KDF, wrong-pass, AEAD, IO) to one generic message so the unlock screen can't reveal whether a vault is tampered/corrupt vs the passphrase wrong. Also adds `just core` (fast GPUI-free engine loop) and frozen v1 decode-compat fixtures. --- AGENTS.md | 3 +- CLAUDE.md | 4 +- crates/deckard-core/src/keystore.rs | 168 ++++++++++++++++++++++++++++ justfile | 7 ++ src/shell.rs | 5 +- 5 files changed, 183 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c96cf0a..c210ce6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,8 @@ Treat key material with care. keystore). **No GPUI dependency; fully unit-testable.** Most logic belongs here, not in the app. ## Commands -- Iterate fast: `cargo check -p deckard-core` (GPUI-free — seconds, not minutes). +- Iterate fast: `just core` — clippy + test the GPUI-free engine (`deckard-core`) in seconds, no gpui + build. Use while working on the engine; the full DoD still applies before done. UI work needs `just check`. - Lint: `just check` — clippy `-D warnings` on BOTH the default and `--features tray` configs. - Format: `just fmt` (`cargo fmt`); CI gates `cargo fmt --all --check`. - Test: `cargo test --workspace`. diff --git a/CLAUDE.md b/CLAUDE.md index 577851d..1c3a154 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,9 @@ interactive, dogfooded reference lives at The full rationale (what we enforce and *why*, plus deliberately-rejected rules) is in `docs/AGENTIC-ENGINEERING.md`. The quick reference: -**Iterate fast:** `cargo check -p deckard-core` — the engine is GPUI-free and checks in seconds. +**Iterate fast:** `just core` — clippy + tests the GPUI-free engine (`deckard-core`) in seconds, no +gpui build. Reach for it while working on the engine; the full Definition of Done below still applies +before you're done. (For UI work you must build the app: `just check`.) **Definition of done** (ALL must hold; paste the command output as evidence — do not claim done while red): 1. `cargo fmt --all --check` is clean diff --git a/crates/deckard-core/src/keystore.rs b/crates/deckard-core/src/keystore.rs index 2f48c40..f043d04 100644 --- a/crates/deckard-core/src/keystore.rs +++ b/crates/deckard-core/src/keystore.rs @@ -416,6 +416,33 @@ impl Vault { Self::from_bytes(&bytes) } + /// Parse + unlock from in-memory bytes as a SINGLE authentication step. Every failure — + /// malformed/truncated/tampered bytes, hostile KDF params, wrong passphrase, AEAD rejection — + /// collapses to the same generic [`unlock_failed`] *message*, so the unlock path is not an + /// error-message oracle distinguishing "wrong passphrase" from "tampered/corrupt vault". + /// + /// Scope: this equalizes the rendered error, NOT timing — a parse reject returns before Argon2, + /// while a wrong passphrase runs it, so failure latency still differs. That residual is accepted + /// deliberately: an attacker who holds the vault file can already parse it, and a desktop wallet + /// doesn't expose unlock latency remotely; padding every malformed-file failure with a full + /// Argon2 pass would cost ~1s for no real gain in this threat model. + /// + /// Use this (or [`Vault::open`]) on the unlock path; `from_bytes`/`unlock` stay available where a + /// specific diagnostic is intentionally wanted and is NOT attacker-facing. + pub fn open_bytes(bytes: &[u8], passphrase: &str) -> anyhow::Result { + Self::from_bytes(bytes) + .and_then(|v| v.unlock(passphrase)) + .map_err(|_| unlock_failed()) + } + + /// Read a vault file and unlock it in one step, with the same generic-error (no-oracle) + /// contract as [`Vault::open_bytes`]. This is what the unlock screen calls. + pub fn open(path: &Path, passphrase: &str) -> anyhow::Result { + Self::read(path) + .and_then(|v| v.unlock(passphrase)) + .map_err(|_| unlock_failed()) + } + pub fn secret_kind(&self) -> SecretKind { self.header.secret_kind } @@ -621,6 +648,147 @@ mod tests { assert!(vault.unlock("wrong passphrase").is_err()); } + #[test] + fn unlock_failures_share_one_message() { + // Every authentication failure must surface the IDENTICAL message via the open_bytes() + // contract, so the unlock UI can't reveal whether the passphrase was wrong or the vault was + // tampered/corrupt. (Message-level only — timing is a documented, accepted residual; see + // Vault::open_bytes. anyhow::Error has no PartialEq, so compare rendered strings.) + let (vault, _) = Vault::create(PW, WordCount::Twelve, KdfParams::FAST_TEST).unwrap(); + let good = vault.to_bytes(); + + // A correct passphrase still unlocks through the same path. + assert!(Vault::open_bytes(&good, PW).is_ok()); + + // `.err()` not `.unwrap_err()`: UnlockedVault is deliberately not Debug (no-leak), so + // unwrap_err (which would format the Ok value) won't compile — itself a guard. + let baseline = Vault::open_bytes(&good, "definitely the wrong passphrase") + .err() + .expect("wrong passphrase must fail to unlock") + .to_string(); + + // Tampers spanning the parser (magic/version/KDF/trailing/truncation) AND the AEAD layer — + // all must collapse to the same message. + let mut cases: Vec> = vec![ + b"not a deckard vault".to_vec(), // bad magic + good[..good.len() - 1].to_vec(), // truncated + ]; + let mut bad_version = good.clone(); + bad_version[4] = 0xFF; // version byte, right after the 4-byte magic + cases.push(bad_version); + let mut bad_kdf = good.clone(); + let m_off = 4 + 1 + 1 + VAULT_ID_LEN + 1; // m_kib: after magic+ver+kind+vault_id+kdf_id + bad_kdf[m_off..m_off + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + cases.push(bad_kdf); + let mut bad_aead = good.clone(); + let last = bad_aead.len() - 1; + bad_aead[last] ^= 0x01; // flip a ciphertext/tag byte → AEAD rejects + cases.push(bad_aead); + let mut trailing = good.clone(); + trailing.push(0x00); // trailing garbage + cases.push(trailing); + + for (i, bad) in cases.iter().enumerate() { + let got = Vault::open_bytes(bad, PW) + .err() + .expect("a tampered/corrupt vault must fail to unlock") + .to_string(); + assert_eq!( + got, baseline, + "case {i} produced a distinguishable unlock error" + ); + } + } + + #[test] + fn open_file_path_collapses_to_generic() { + use std::io::Write; + // Vault::open (the on-disk path do_unlock uses) must collapse read/size-cap/parse/AEAD + // failures to the same generic message as a wrong passphrase — never a distinct IO error. + let path = std::env::temp_dir().join("deckard-open-contract-test.bin"); + let write = |bytes: &[u8]| { + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(bytes).unwrap(); + }; + let open_err = || { + Vault::open(&path, PW) + .err() + .expect("must fail to unlock") + .to_string() + }; + + let (vault, _) = Vault::create(PW, WordCount::Twelve, KdfParams::FAST_TEST).unwrap(); + write(&vault.to_bytes()); + assert!( + Vault::open(&path, PW).is_ok(), + "a valid vault file must unlock" + ); + let baseline = Vault::open(&path, "wrong passphrase") + .err() + .expect("wrong passphrase must fail") + .to_string(); + + write(b"not a deckard vault"); + assert_eq!(open_err(), baseline, "garbage file leaked a distinct error"); + write(&vec![0u8; 5000]); // > MAX_VAULT_BYTES (4096) → size-cap reject + assert_eq!( + open_err(), + baseline, + "oversized file leaked a distinct error" + ); + let _ = std::fs::remove_file(&path); + assert_eq!(open_err(), baseline, "missing file leaked a distinct error"); + } + + fn unhex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + // Frozen REAL v1 vault blobs (FORMAT_VERSION 1, FAST_TEST KDF), captured once from known inputs: + // ENTROPY16 = the canonical 12-word "abandon…about"; ENTROPY32 = the 24-word "abandon…art"; + // RAWKEY = 0x46*32. To regenerate after an intentional format bump, temporarily restore the + // gen_compat_fixtures generator (git history) and re-bake. + const ENTROPY16_HEX: &str = "444b5244010079a6a367eeb270d770dcf64ea3eecea801002000000100000001000000d2d7d128338ed86d7c7dd636345084a700007f1a9945fceea918f61828d5b3e4f2ea6ea7df273870ee3c02832b5966fb0a6fe0e839403d6264de00245da2de44162d03df796bd78e979470ba93c90c3998440873de456b3011a7d9135356eab7cec475ed48444e3ec3d386c0776cf238a5da200000005ebb566131a4f7c68ffdc0d6c7087b6ce3166baac57a6ce3ff408316eb90249f"; + const ENTROPY32_HEX: &str = "444b52440101c23da1d7c70914433c40a2573da3e7330100200000010000000100000089acc8ec1a8b0a563601760609bf3d530000f8f1e8ca056fad16d8416189ea7e0f2d23f93d78bc4f5bdd6da976e6bd67bb502e354788a409a361d099f7a5a812e2dd37eeb9bd34070045b64b7820e59a10618cd3a8f496f1df57e759704f8d919604764d1c262e2b58f69d0d83c80192ff633000000033ed76cbcade5104bc27c0ae0391b2f0c44124d3c2d3c878c8f9d4a7d67811553b13e617ae10f719223a78f37e204fb4"; + const RAWKEY_HEX: &str = "444b524401024ded6e18c437a15f07f0ec8043534b7001002000000100000001000000e2c48852b4d1e57e184ae6b73ab00011000095dc73698f21c7cfb28984a9f2da03f00ab54c722330e1f0afe4e79025b6bf37811038ccdfe9f48b7a1ce6754998e049143b30f340ebcab827cb61ec2a1e8065436187dccec035995ba5b9199d6e6340bce0e3bac70d0c32f0bf3ce94455a3a430000000caf0392f2f3b04eb0ce6b9d00c13ca62a54cf862e25248063f0bfbfaf55ba18ac08e2e325c7f1a084cdfc1a25f3c2a54"; + + #[test] + fn decode_compat_v1_fixtures() { + // If a future format/parser change ever stops an old on-disk vault from parsing + unlocking + // to the same address, that's a backward-incompatible break (lost funds) — caught here. + let fixtures: &[(&str, SecretKind, &str)] = &[ + ( + ENTROPY16_HEX, + SecretKind::Entropy16, + "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + ), + ( + ENTROPY32_HEX, + SecretKind::Entropy32, + "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb", + ), + ( + RAWKEY_HEX, + SecretKind::RawKey, + "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + ), + ]; + for (hex, kind, want_addr) in fixtures { + let bytes = unhex(hex); + let vault = Vault::from_bytes(&bytes).expect("v1 fixture must still parse"); + assert_eq!(vault.secret_kind(), *kind); + let addr = vault + .unlock(PW) + .expect("v1 fixture must still unlock") + .primary_address() + .unwrap(); + assert_eq!(addr.to_string(), *want_addr, "v1 fixture address drifted"); + } + } + #[test] fn tamper_each_region_fails_closed() { let (vault, _) = Vault::create(PW, WordCount::Twelve, KdfParams::FAST_TEST).unwrap(); diff --git a/justfile b/justfile index 9a03dd0..8bd24f2 100644 --- a/justfile +++ b/justfile @@ -24,6 +24,13 @@ check: cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets --features tray -- -D warnings +# Fast inner loop for ENGINE work: deckard-core is GPUI-free, so this clippy+tests in seconds +# (no gpui build). Use it while iterating on keystore/eth/balances. The full Definition of Done +# still applies before "done": `just check` (both feature configs) + `cargo test --workspace`. +core: + cargo clippy -p deckard-core --all-targets --locked -- -D warnings + cargo test -p deckard-core --locked + # Bump the git GPUI stack to the latest upstream commits, then rebuild. # Reproducibility lives in Cargo.lock — commit it (and rust-toolchain.toml if you # bumped it) after this succeeds. If the build fails on an unstable-feature error, diff --git a/src/shell.rs b/src/shell.rs index 5edac98..0ec053e 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -523,8 +523,9 @@ impl Shell { }; let pass = Zeroizing::new(pass); let task = cx.background_spawn(async move { - let vault = Vault::read(&path)?; - vault.unlock(pass.as_str()) + // Single no-oracle auth step: a corrupt/tampered vault and a wrong passphrase surface + // the SAME generic error (see Vault::open), so the unlock screen can't distinguish them. + Vault::open(&path, pass.as_str()) }); cx.spawn(async move |this, cx| { let res = task.await;