Compare commits
6 Commits
d8917fbd9a
...
be818a50a8
| Author | SHA1 | Date | |
|---|---|---|---|
| be818a50a8 | |||
| 6f4ae679e5 | |||
| cdede17683 | |||
| a264669151 | |||
| d9c75eaec2 | |||
| c868c17505 |
@@ -93,3 +93,7 @@ settings.local.json
|
||||
# Local agent/tooling metadata
|
||||
.agents/
|
||||
.codex/
|
||||
|
||||
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
|
||||
findings/
|
||||
crash-*.bin
|
||||
|
||||
@@ -6,20 +6,22 @@ This file provides guidance to coding agents (OpenAI Codex and others following
|
||||
|
||||
## Role
|
||||
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
|
||||
## Language policy
|
||||
|
||||
- **Conversation with the user**: Italian.
|
||||
- **All code, comments, commit messages, and documentation files**: English only.
|
||||
- **Code, comments, commit messages, documentation files**: English only.
|
||||
|
||||
## How to assist
|
||||
|
||||
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
|
||||
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||
|
||||
## What it is
|
||||
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
|
||||
|
||||
## Stack and structure
|
||||
|
||||
@@ -34,33 +36,40 @@ src/Cli/ CLI on the same Core tests/ xUnit
|
||||
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
||||
```
|
||||
|
||||
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
|
||||
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted
|
||||
by `MainWindow` on desktop and as the single-view root on Android.
|
||||
|
||||
**Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
|
||||
- The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
|
||||
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
|
||||
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- Build: `dotnet build`
|
||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design. Two `internal` test seams (visible via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test), and `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests touching them share the xUnit collection `"AppPaths"` because that state is static).
|
||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
||||
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
- **Build:** `dotnet build`
|
||||
- **Test** (headless, primary verification layer): `dotnet test`
|
||||
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
|
||||
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
|
||||
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
|
||||
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
|
||||
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
|
||||
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
|
||||
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
|
||||
- Android workload dictates the SDK API level — error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`
|
||||
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
|
||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
||||
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
||||
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
|
||||
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
|
||||
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
|
||||
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
|
||||
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
|
||||
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
|
||||
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||
|
||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||
|
||||
@@ -68,22 +77,28 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
||||
|
||||
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
||||
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. Custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here. It is split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
|
||||
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. On startup `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release and `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling): any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
|
||||
- **Releases:** bump the version with `./update-version.sh` (interactive) — it updates `<Version>` in the App csproj (the single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, which **must strictly increase** every release or users can't update in place), and stubs a `CHANGELOG.md` entry. Fill in that entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling) — any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
|
||||
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
|
||||
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
|
||||
|
||||
@@ -6,20 +6,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Role
|
||||
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
|
||||
## Language policy
|
||||
|
||||
- **Conversation with the user**: Italian.
|
||||
- **All code, comments, commit messages, and documentation files**: English only.
|
||||
- **Code, comments, commit messages, documentation files**: English only.
|
||||
|
||||
## How to assist
|
||||
|
||||
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
|
||||
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||
|
||||
## What it is
|
||||
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
|
||||
|
||||
## Stack and structure
|
||||
|
||||
@@ -34,33 +36,40 @@ src/Cli/ CLI on the same Core tests/ xUnit
|
||||
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
||||
```
|
||||
|
||||
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
|
||||
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted
|
||||
by `MainWindow` on desktop and as the single-view root on Android.
|
||||
|
||||
**Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
|
||||
- The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
|
||||
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
|
||||
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- Build: `dotnet build`
|
||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design. Two `internal` test seams (visible via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test), and `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests touching them share the xUnit collection `"AppPaths"` because that state is static).
|
||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
||||
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
- **Build:** `dotnet build`
|
||||
- **Test** (headless, primary verification layer): `dotnet test`
|
||||
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
|
||||
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
|
||||
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
|
||||
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
|
||||
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
|
||||
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
|
||||
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
|
||||
- Android workload dictates the SDK API level — error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`
|
||||
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
|
||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
||||
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
||||
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
|
||||
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
|
||||
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
|
||||
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
|
||||
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
|
||||
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
|
||||
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||
|
||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||
|
||||
@@ -68,22 +77,28 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
||||
|
||||
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
||||
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. Custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here. It is split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
|
||||
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. On startup `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release and `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling): any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
|
||||
- **Releases:** bump the version with `./update-version.sh` (interactive) — it updates `<Version>` in the App csproj (the single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, which **must strictly increase** every release or users can't update in place), and stubs a `CHANGELOG.md` entry. Fill in that entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling) — any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
|
||||
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
|
||||
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
|
||||
|
||||
+134
-64
@@ -1,64 +1,134 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tests\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj", "{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -168,6 +168,18 @@ framing, retries, TLS pinning included — without any external dependency.
|
||||
|
||||
The suite also includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, SLIP-132 key roundtrip for every script kind and network, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
||||
|
||||
### Fuzzing
|
||||
|
||||
`tests/PalladiumWallet.Fuzz` fuzzes every parser that consumes untrusted input
|
||||
(server-supplied headers/proofs/peer lists, wallet files, user-pasted
|
||||
keys/mnemonics/addresses/amounts) via [SharpFuzz](https://github.com/Metalnem/sharpfuzz):
|
||||
each target enforces the parser's documented error contract, so any other
|
||||
exception escaping is a finding. The seed corpus — including a regression input
|
||||
for every crash found so far — replays automatically inside `dotnet test`;
|
||||
coverage-guided campaigns run separately with afl++ (`tests/PalladiumWallet.Fuzz/fuzz.sh`),
|
||||
and a built-in random-mutation mode (`dotnet run -- <target> --random N`) needs
|
||||
no external tooling. See `tests/PalladiumWallet.Fuzz/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
+2
-1
@@ -55,8 +55,9 @@ It cannot (given correct Merkle verification):
|
||||
## Encryption at rest
|
||||
|
||||
- Algorithm: AES-256-GCM
|
||||
- Key derivation: PBKDF2-HMAC-SHA512, 600 000 iterations, 16-byte random salt (fresh salt and nonce on every save; the iteration count is stored in the file container, so future increases remain backward-compatible)
|
||||
- Key derivation: PBKDF2-HMAC-SHA512, 600 000 iterations, 16-byte random salt (fresh salt and nonce on every save; the iteration count is stored in the file container, so future increases remain backward-compatible — bounded at 10 000 000 on read, since the count is attacker-controlled in a tampered file and an absurd value would hang the wallet at open)
|
||||
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
|
||||
- A malformed container (broken JSON, missing fields, bad base64, wrong nonce/tag size) always fails with a typed `InvalidDataException`, never a raw parser exception — the decrypt path is fuzz-tested (`tests/PalladiumWallet.Fuzz`)
|
||||
- The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent
|
||||
|
||||
---
|
||||
|
||||
+31
-14
@@ -58,28 +58,45 @@ public static class Bip39
|
||||
// ideographic spaces — the text must not be altered further (§4.1).
|
||||
text = text.Trim();
|
||||
|
||||
IEnumerable<Wordlist> candidates = language is not null
|
||||
? [ToWordlist(language.Value)]
|
||||
: [Wordlist.AutoDetect(text)];
|
||||
|
||||
foreach (var wordlist in candidates)
|
||||
Wordlist wordlist;
|
||||
if (language is not null)
|
||||
{
|
||||
wordlist = ToWordlist(language.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = new Mnemonic(text, wordlist);
|
||||
// The constructor does NOT verify the checksum — explicit check is mandatory.
|
||||
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
||||
{
|
||||
mnemonic = parsed;
|
||||
return true;
|
||||
}
|
||||
wordlist = Wordlist.AutoDetect(text);
|
||||
}
|
||||
catch (FormatException)
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Words outside the wordlist or invalid count — try the next candidate.
|
||||
// AutoDetect lands on NBitcoin's internal "Unknown" language for
|
||||
// text resembling no wordlist and throws instead of returning a
|
||||
// default (found by fuzzing) — not a valid mnemonic, plain and simple.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = new Mnemonic(text, wordlist);
|
||||
// The constructor does NOT verify the checksum — explicit check is mandatory.
|
||||
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
||||
{
|
||||
mnemonic = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Words outside the wordlist or invalid count.
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Defensive: same NBitcoin failure mode surfacing from the constructor.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,17 +124,22 @@ public static class ElectrumApi
|
||||
/// Parses the server.peers.subscribe response: a list of
|
||||
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
|
||||
/// "t"/"s" without a number = network default port (resolved by the caller).
|
||||
/// The response is untrusted server data: any unexpected shape (non-array,
|
||||
/// wrong element types) is skipped, never thrown on.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
||||
{
|
||||
var peers = new List<PeerInfo>();
|
||||
if (response.ValueKind != JsonValueKind.Array)
|
||||
return peers;
|
||||
foreach (var entry in response.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3)
|
||||
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3
|
||||
|| entry[2].ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
var host = entry[1].GetString();
|
||||
var host = AsString(entry[1]);
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
host = entry[0].GetString();
|
||||
host = AsString(entry[0]);
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
continue;
|
||||
|
||||
@@ -142,7 +147,7 @@ public static class ElectrumApi
|
||||
string? version = null;
|
||||
foreach (var feature in entry[2].EnumerateArray())
|
||||
{
|
||||
var f = feature.GetString();
|
||||
var f = AsString(feature);
|
||||
if (string.IsNullOrEmpty(f))
|
||||
continue;
|
||||
switch (f[0])
|
||||
@@ -157,4 +162,21 @@ public static class ElectrumApi
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
|
||||
private static string? AsString(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return element.GetString();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Syntactically valid JSON string that cannot be transcoded to UTF-16
|
||||
// (invalid UTF-8 bytes / lone surrogates) — untrusted server data,
|
||||
// treat as absent (found by fuzzing).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ public static class EncryptedFile
|
||||
{
|
||||
private const string Format = "plm-wallet-aesgcm-v1";
|
||||
private const int DefaultIterations = 600_000;
|
||||
// Upper bound on the iteration count read from the container: the value is
|
||||
// attacker-controlled in a tampered file, and an absurd count would hang the
|
||||
// wallet at open (PBKDF2 DoS). Leaves ample room for future increases.
|
||||
private const int MaxIterations = 10_000_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int NonceSize = 12;
|
||||
private const int TagSize = 16;
|
||||
@@ -62,23 +66,51 @@ public static class EncryptedFile
|
||||
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
||||
}
|
||||
|
||||
/// <summary>Throws <see cref="WrongPasswordException"/> if the password is wrong or the file is tampered with.</summary>
|
||||
/// <summary>
|
||||
/// Throws <see cref="WrongPasswordException"/> if the password is wrong or the
|
||||
/// ciphertext is tampered with, <see cref="InvalidDataException"/> for any
|
||||
/// malformed container (broken JSON, missing fields, bad base64, wrong
|
||||
/// nonce/tag size, out-of-range iteration count) — never a raw parsing exception.
|
||||
/// </summary>
|
||||
public static string Decrypt(string fileContent, string password)
|
||||
{
|
||||
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
||||
?? throw new InvalidDataException("Contenitore cifrato non valido.");
|
||||
Container? container;
|
||||
try
|
||||
{
|
||||
container = JsonSerializer.Deserialize<Container>(fileContent);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
}
|
||||
if (container is null)
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
if (container.Format != Format)
|
||||
throw new InvalidDataException($"Formato sconosciuto: {container.Format}");
|
||||
throw new InvalidDataException($"Unknown container format: {container.Format}");
|
||||
if (container.Iterations is <= 0 or > MaxIterations)
|
||||
throw new InvalidDataException($"Iteration count out of range: {container.Iterations}");
|
||||
|
||||
var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations);
|
||||
var cipher = Convert.FromBase64String(container.Data);
|
||||
byte[] salt, nonce, tag, cipher;
|
||||
try
|
||||
{
|
||||
salt = Convert.FromBase64String(container.Salt);
|
||||
nonce = Convert.FromBase64String(container.Nonce);
|
||||
tag = Convert.FromBase64String(container.Tag);
|
||||
cipher = Convert.FromBase64String(container.Data);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentNullException)
|
||||
{
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
}
|
||||
if (nonce.Length != NonceSize || tag.Length != TagSize)
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
|
||||
var key = DeriveKey(password, salt, container.Iterations);
|
||||
var plain = new byte[cipher.Length];
|
||||
try
|
||||
{
|
||||
using var aes = new AesGcm(key, TagSize);
|
||||
aes.Decrypt(
|
||||
Convert.FromBase64String(container.Nonce), cipher,
|
||||
Convert.FromBase64String(container.Tag), plain);
|
||||
aes.Decrypt(nonce, cipher, tag, plain);
|
||||
}
|
||||
catch (AuthenticationTagMismatchException)
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay
|
||||
@@ -0,0 +1 @@
|
||||
hello world
|
||||
@@ -0,0 +1 @@
|
||||
PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q
|
||||
@@ -0,0 +1 @@
|
||||
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||
@@ -0,0 +1 @@
|
||||
ábÊco ábaco0ábacoábÊ
|
||||
@@ -0,0 +1 @@
|
||||
ábaco ábaco ábaco
|
||||
@@ -0,0 +1 @@
|
||||
0,001
|
||||
@@ -0,0 +1 @@
|
||||
79228162514264337593543950335
|
||||
@@ -0,0 +1 @@
|
||||
1.50000000
|
||||
@@ -0,0 +1 @@
|
||||
{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}
|
||||
@@ -0,0 +1 @@
|
||||
hello
|
||||
@@ -0,0 +1 @@
|
||||
{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}
|
||||
@@ -0,0 +1 @@
|
||||
0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]
|
||||
@@ -0,0 +1 @@
|
||||
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","‡50002"]]]
|
||||
@@ -0,0 +1 @@
|
||||
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]
|
||||
@@ -0,0 +1 @@
|
||||
not-a-key
|
||||
@@ -0,0 +1 @@
|
||||
xpub661MyMwAqRbcF
|
||||
@@ -0,0 +1 @@
|
||||
zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs
|
||||
@@ -0,0 +1 @@
|
||||
{"Version":99}
|
||||
@@ -0,0 +1 @@
|
||||
{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}
|
||||
@@ -0,0 +1 @@
|
||||
hello
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Fuzz;
|
||||
|
||||
/// <summary>
|
||||
/// Fuzz targets over the parsers that consume untrusted input (server responses,
|
||||
/// wallet files, user-pasted keys/addresses/amounts). Each target enforces the
|
||||
/// parser's error contract: the exception types documented as its failure mode
|
||||
/// are swallowed, anything else escapes and is reported as a crash by the fuzzer.
|
||||
/// </summary>
|
||||
public static class FuzzTargets
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, Action<byte[]>> All =
|
||||
new Dictionary<string, Action<byte[]>>
|
||||
{
|
||||
["header"] = Header,
|
||||
["merkle"] = Merkle,
|
||||
["slip132"] = Slip132Keys,
|
||||
["bip39"] = Bip39Mnemonic,
|
||||
["address"] = Address,
|
||||
["coinamount"] = CoinAmountParse,
|
||||
["walletdoc"] = WalletDoc,
|
||||
["encfile"] = EncFile,
|
||||
["peers"] = Peers,
|
||||
};
|
||||
|
||||
/// <summary>Runs one input through a named target (used by replay and the regression test).</summary>
|
||||
public static void Run(string target, byte[] data) => All[target](data);
|
||||
|
||||
private static string Utf8(byte[] data) => Encoding.UTF8.GetString(data);
|
||||
|
||||
// 80-byte block headers arrive from the server both as raw bytes and as hex.
|
||||
// Contract: exactly 80 bytes never throws; any other length is ArgumentException;
|
||||
// the hex overload may also reject non-hex text with FormatException.
|
||||
private static void Header(byte[] data)
|
||||
{
|
||||
try { BlockHeaderInfo.Parse(data); }
|
||||
catch (ArgumentException) when (data.Length != BlockHeaderInfo.Size) { }
|
||||
|
||||
try { BlockHeaderInfo.Parse(Utf8(data)); }
|
||||
catch (FormatException) { }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
|
||||
// Merkle proof verification runs on server-supplied txid/pos/branch.
|
||||
// Contract: never throws, for any position (including negative) and branch.
|
||||
private static void Merkle(byte[] data)
|
||||
{
|
||||
if (data.Length < 68)
|
||||
return;
|
||||
var txid = new uint256(data.AsSpan(0, 32));
|
||||
var position = BitConverter.ToInt32(data, 32);
|
||||
var root = new uint256(data.AsSpan(36, 32));
|
||||
var branch = new List<uint256>();
|
||||
for (var offset = 68; offset + 32 <= data.Length; offset += 32)
|
||||
branch.Add(new uint256(data.AsSpan(offset, 32)));
|
||||
|
||||
MerkleProof.Verify(txid, position, branch, root);
|
||||
}
|
||||
|
||||
// SLIP-132 extended keys are pasted by the user on import.
|
||||
// Contract: the Try* pair never throws, on any profile.
|
||||
private static void Slip132Keys(byte[] data)
|
||||
{
|
||||
var text = Utf8(data);
|
||||
foreach (var profile in new[] { ChainProfiles.Mainnet, ChainProfiles.Testnet, ChainProfiles.Regtest })
|
||||
{
|
||||
Slip132.TryDecodePublic(text, profile, out _, out _);
|
||||
Slip132.TryDecodePrivate(text, profile, out _, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// Mnemonics are pasted by the user on restore. Contract: TryParse never throws,
|
||||
// with auto-detected and explicit wordlist language alike.
|
||||
private static void Bip39Mnemonic(byte[] data)
|
||||
{
|
||||
var text = Utf8(data);
|
||||
Bip39.TryParse(text, out _);
|
||||
if (data.Length > 0)
|
||||
Bip39.TryParse(text, out _, (MnemonicLanguage)(data[0] % 8));
|
||||
}
|
||||
|
||||
// Recipient addresses are pasted by the user (or scanned from a QR).
|
||||
// Contract: NBitcoin rejects anything invalid with FormatException.
|
||||
private static void Address(byte[] data)
|
||||
{
|
||||
try { BitcoinAddress.Create(Utf8(data), PalladiumNetworks.Mainnet); }
|
||||
catch (FormatException) { }
|
||||
}
|
||||
|
||||
// Amounts are typed by the user in the currently selected unit.
|
||||
// Contract: TryParse* never throws for any of the official units.
|
||||
private static void CoinAmountParse(byte[] data)
|
||||
{
|
||||
var text = Utf8(data);
|
||||
foreach (var unit in CoinAmount.Units)
|
||||
CoinAmount.TryParseIn(text, unit, out _);
|
||||
CoinAmount.TryParseCoins(text, out _);
|
||||
}
|
||||
|
||||
// The plaintext wallet document is read from disk at every open.
|
||||
// Contract: malformed JSON or schema is JsonException/InvalidDataException.
|
||||
private static void WalletDoc(byte[] data)
|
||||
{
|
||||
try { WalletDocument.FromJson(Utf8(data)); }
|
||||
catch (JsonException) { }
|
||||
catch (InvalidDataException) { }
|
||||
}
|
||||
|
||||
// The encrypted container wraps the document on disk; a tampered file must
|
||||
// fail with the two typed exceptions, never a raw parser error, and
|
||||
// IsEncrypted (the format sniffer) must never throw at all.
|
||||
private static void EncFile(byte[] data)
|
||||
{
|
||||
var text = Utf8(data);
|
||||
EncryptedFile.IsEncrypted(text);
|
||||
try { EncryptedFile.Decrypt(text, "fuzz-password"); }
|
||||
catch (WrongPasswordException) { }
|
||||
catch (InvalidDataException) { }
|
||||
}
|
||||
|
||||
// server.peers.subscribe responses are attacker-controlled JSON.
|
||||
// Contract: any well-formed JSON of any shape parses to a (possibly empty)
|
||||
// peer list without throwing.
|
||||
private static void Peers(byte[] data)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(data); }
|
||||
catch (JsonException) { return; }
|
||||
using (doc)
|
||||
ElectrumApi.ParsePeers(doc.RootElement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Fuzzing wants stable, non-inlined instrumentation points. -->
|
||||
<TieredCompilation>false</TieredCompilation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SharpFuzz" Version="2.3.0" />
|
||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Seed corpus travels next to the binary so replay and the regression
|
||||
test in PalladiumWallet.Tests can find it via AppContext.BaseDirectory. -->
|
||||
<None Include="Corpus\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Text;
|
||||
using PalladiumWallet.Fuzz;
|
||||
|
||||
// Fuzzing entry point. One binary, one target per process (fuzzers want a
|
||||
// single deterministic execution path). Modes:
|
||||
//
|
||||
// <target> --afl run under afl-fuzz (instrumented Core, see fuzz.sh)
|
||||
// <target> --libfuzzer run under libfuzzer-dotnet
|
||||
// <target> --random N [seed] built-in dumb fuzzer: N corpus mutations (no tooling needed)
|
||||
// <target> <file|dir> ... replay saved inputs (used by the regression test / triage)
|
||||
// --make-seeds <dir> regenerate the seed corpus
|
||||
//
|
||||
// A "crash" is any exception escaping the target: each target already swallows
|
||||
// the exception types that are part of the parser's documented contract.
|
||||
|
||||
if (args.Length >= 2 && args[0] == "--make-seeds")
|
||||
{
|
||||
SeedCorpus.WriteAll(args[1]);
|
||||
Console.WriteLine($"Seed corpus written to {args[1]}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.Length < 2 || !FuzzTargets.All.TryGetValue(args[0], out var target))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: PalladiumWallet.Fuzz <target> --afl | --libfuzzer | --random N [seed] | <file|dir>...");
|
||||
Console.Error.WriteLine(" PalladiumWallet.Fuzz --make-seeds <dir>");
|
||||
Console.Error.WriteLine($"Targets: {string.Join(' ', FuzzTargets.All.Keys)}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
switch (args[1])
|
||||
{
|
||||
case "--afl":
|
||||
SharpFuzz.Fuzzer.Run(stream =>
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
target(ms.ToArray());
|
||||
});
|
||||
return 0;
|
||||
|
||||
case "--libfuzzer":
|
||||
SharpFuzz.Fuzzer.LibFuzzer.Run(span => target(span.ToArray()));
|
||||
return 0;
|
||||
|
||||
case "--random":
|
||||
{
|
||||
var iterations = args.Length > 2 ? int.Parse(args[2]) : 100_000;
|
||||
var seed = args.Length > 3 ? ulong.Parse(args[3]) : 0xF022_5EEDUL;
|
||||
return RandomFuzz.Run(args[0], target, iterations, seed);
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
var failures = 0;
|
||||
foreach (var path in args[1..])
|
||||
foreach (var file in Directory.Exists(path)
|
||||
? Directory.EnumerateFiles(path).OrderBy(f => f, StringComparer.Ordinal)
|
||||
: (IEnumerable<string>)[path])
|
||||
{
|
||||
var data = File.ReadAllBytes(file);
|
||||
try
|
||||
{
|
||||
target(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures++;
|
||||
Console.Error.WriteLine($"CRASH {args[0]} on {file}:");
|
||||
Console.Error.WriteLine(ex);
|
||||
}
|
||||
}
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal corpus-mutation fuzzer for smoke runs and CI: not coverage-guided,
|
||||
/// but catches shallow contract violations without afl++/libFuzzer installed.
|
||||
/// Deterministic for a given seed; on crash it saves the input for replay.
|
||||
/// </summary>
|
||||
internal static class RandomFuzz
|
||||
{
|
||||
public static int Run(string name, Action<byte[]> target, int iterations, ulong seed)
|
||||
{
|
||||
var corpus = SeedCorpus.For(name);
|
||||
var rng = seed;
|
||||
ulong Next() { rng ^= rng << 13; rng ^= rng >> 7; rng ^= rng << 17; return rng; }
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var data = (byte[])corpus[(int)(Next() % (ulong)corpus.Count)].Clone();
|
||||
var mutations = 1 + (int)(Next() % 8);
|
||||
for (var m = 0; m < mutations && data.Length > 0; m++)
|
||||
switch (Next() % 4)
|
||||
{
|
||||
case 0: data[Next() % (ulong)data.Length] = (byte)Next(); break;
|
||||
case 1: data[Next() % (ulong)data.Length] ^= (byte)(1 << (int)(Next() % 8)); break;
|
||||
case 2: Array.Resize(ref data, (int)(Next() % 512) + 1); break;
|
||||
case 3: data = [.. data, .. data[..(int)(Next() % (ulong)data.Length)]]; break;
|
||||
}
|
||||
try
|
||||
{
|
||||
target(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var crashFile = $"crash-{name}-{i}.bin";
|
||||
File.WriteAllBytes(crashFile, data);
|
||||
Console.Error.WriteLine(
|
||||
$"CRASH {name} at iteration {i}: {ex.GetType().Name}: {ex.Message} (input saved to {crashFile})");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"{name}: {iterations} random mutations, no contract violations");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed inputs per target: small valid (or near-valid) examples that give the
|
||||
/// mutation engines a meaningful starting shape. Regenerate with --make-seeds.
|
||||
/// </summary>
|
||||
internal static class SeedCorpus
|
||||
{
|
||||
private static readonly Dictionary<string, (string Name, byte[] Data)[]> Seeds = new()
|
||||
{
|
||||
["header"] =
|
||||
[
|
||||
("genesis", Convert.FromHexString(
|
||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
|
||||
("genesis-hex", Encoding.UTF8.GetBytes(
|
||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
|
||||
("short", [0x01, 0x00, 0x00, 0x00]),
|
||||
],
|
||||
["merkle"] =
|
||||
[
|
||||
("one-level", [.. new byte[32], .. BitConverter.GetBytes(1), .. new byte[32], .. Enumerable.Repeat((byte)0xAA, 32)]),
|
||||
("no-branch", [.. Enumerable.Repeat((byte)0x11, 32), .. BitConverter.GetBytes(0), .. Enumerable.Repeat((byte)0x11, 32)]),
|
||||
],
|
||||
["slip132"] =
|
||||
[
|
||||
("zpub", Encoding.UTF8.GetBytes(
|
||||
"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs")),
|
||||
("xpub-prefix", Encoding.UTF8.GetBytes("xpub661MyMwAqRbcF")),
|
||||
("garbage", Encoding.UTF8.GetBytes("not-a-key")),
|
||||
],
|
||||
["bip39"] =
|
||||
[
|
||||
("english-12", Encoding.UTF8.GetBytes(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")),
|
||||
("spanish-word", Encoding.UTF8.GetBytes("ábaco ábaco ábaco")),
|
||||
("empty", []),
|
||||
// Regression: NBitcoin AutoDetect → "Unknown" language → NotSupportedException
|
||||
// escaped TryParse (fixed by catching it).
|
||||
("regression-notsupported", Convert.FromHexString(
|
||||
"c3a162ca636f20c3a16261636f30c3a16261636fc3a162ca")),
|
||||
],
|
||||
["address"] =
|
||||
[
|
||||
("bech32", Encoding.UTF8.GetBytes("plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay")),
|
||||
("legacy-ish", Encoding.UTF8.GetBytes("PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q")),
|
||||
("garbage", Encoding.UTF8.GetBytes("hello world")),
|
||||
],
|
||||
["coinamount"] =
|
||||
[
|
||||
("plain", Encoding.UTF8.GetBytes("1.50000000")),
|
||||
("comma", Encoding.UTF8.GetBytes("0,001")),
|
||||
("huge", Encoding.UTF8.GetBytes("79228162514264337593543950335")),
|
||||
],
|
||||
["walletdoc"] =
|
||||
[
|
||||
("minimal", Encoding.UTF8.GetBytes(
|
||||
"""{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}""")),
|
||||
("bad-version", Encoding.UTF8.GetBytes("""{"Version":99}""")),
|
||||
("not-json", Encoding.UTF8.GetBytes("hello")),
|
||||
],
|
||||
["encfile"] =
|
||||
[
|
||||
// Well-formed container with a tiny iteration count so every replay and
|
||||
// mutated descendant skips the full PBKDF2 cost (decryption then fails
|
||||
// with the typed WrongPasswordException — exactly the contract under test).
|
||||
("container", Encoding.UTF8.GetBytes(
|
||||
"""{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}""")),
|
||||
("wrong-format", Encoding.UTF8.GetBytes(
|
||||
"""{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}""")),
|
||||
("not-json", Encoding.UTF8.GetBytes("hello")),
|
||||
],
|
||||
["peers"] =
|
||||
[
|
||||
("typical", Encoding.UTF8.GetBytes(
|
||||
"""[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]""")),
|
||||
("odd-shapes", Encoding.UTF8.GetBytes("""[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]""")),
|
||||
// Regression: raw invalid-UTF-8 byte inside a JSON string parses but cannot
|
||||
// be transcoded by GetString() → InvalidOperationException (fixed in AsString).
|
||||
("regression-bad-utf8", Convert.FromHexString(
|
||||
"5b5b22312e322e332e34222c22706565722e6578616d706c65222c5b2276312e342e32222c22703130222c227435303030" +
|
||||
"31222c22873530303032225d5d5d")),
|
||||
],
|
||||
};
|
||||
|
||||
public static IReadOnlyList<byte[]> For(string target) =>
|
||||
Seeds[target].Select(s => s.Data).ToList();
|
||||
|
||||
public static void WriteAll(string root)
|
||||
{
|
||||
foreach (var (target, seeds) in Seeds)
|
||||
{
|
||||
var dir = Path.Combine(root, target);
|
||||
Directory.CreateDirectory(dir);
|
||||
foreach (var (name, data) in seeds)
|
||||
File.WriteAllBytes(Path.Combine(dir, name + ".bin"), data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Fuzzing
|
||||
|
||||
Fuzz targets over every parser that consumes **untrusted input**: server
|
||||
responses (block headers, Merkle proofs, peer lists), wallet files (plaintext
|
||||
document and encrypted container), and user-pasted text (mnemonics, SLIP-132
|
||||
keys, addresses, amounts).
|
||||
|
||||
Each target encodes the parser's **error contract**: the exception types
|
||||
documented as its failure mode are swallowed, anything else escaping is a
|
||||
finding. Targets: `header` `merkle` `slip132` `bip39` `address` `coinamount`
|
||||
`walletdoc` `encfile` `peers` (see `FuzzTargets.cs` for each contract).
|
||||
|
||||
## Three ways to run
|
||||
|
||||
**1. Corpus replay — automatic.** The seed corpus (including regression inputs
|
||||
for every crash found so far) replays through all targets on every
|
||||
`dotnet test` run via `FuzzCorpusTests`, so fixed findings cannot come back.
|
||||
|
||||
**2. Built-in random smoke — no tooling.** Not coverage-guided, but catches
|
||||
shallow contract violations in seconds:
|
||||
|
||||
```bash
|
||||
dotnet run -- bip39 --random 50000 # target, iterations [, seed]
|
||||
```
|
||||
|
||||
**3. Coverage-guided campaign — afl++.** The real thing; run it before a
|
||||
release or after touching any parser:
|
||||
|
||||
```bash
|
||||
apt install afl++
|
||||
dotnet tool install --global SharpFuzz.CommandLine
|
||||
./fuzz.sh header # one target per campaign
|
||||
./fuzz.sh peers -V 3600 # extra args go to afl-fuzz
|
||||
```
|
||||
|
||||
`fuzz.sh` builds Release, instruments `PalladiumWallet.Core.dll` (and NBitcoin)
|
||||
with SharpFuzz, and launches afl-fuzz with the target's seed corpus. Findings
|
||||
land in `findings/<target>/crashes/`.
|
||||
|
||||
## Triage workflow
|
||||
|
||||
```bash
|
||||
dotnet run -- <target> findings/<target>/crashes/<file> # replay: full stack trace
|
||||
```
|
||||
|
||||
Fix the parser (typed exception or graceful rejection — see the hardening
|
||||
commits for `Bip39.TryParse`, `ElectrumApi.ParsePeers`, `EncryptedFile.Decrypt`
|
||||
as examples), then add the input to `SeedCorpus` in `Program.cs` as a
|
||||
`regression-*` seed and regenerate with `dotnet run -- --make-seeds Corpus`.
|
||||
|
||||
## Findings so far
|
||||
|
||||
The first smoke run found two real bugs, both reachable from untrusted input:
|
||||
|
||||
- `Bip39.TryParse` crashed with `NotSupportedException` on restore text
|
||||
resembling no wordlist (NBitcoin's `Wordlist.AutoDetect` throws for its
|
||||
internal "Unknown" language).
|
||||
- `ElectrumApi.ParsePeers` crashed with `InvalidOperationException` on a peer
|
||||
list containing a JSON string with invalid UTF-8 bytes (parses fine,
|
||||
fails at `GetString()` transcoding) — attacker-controlled server data.
|
||||
|
||||
Plus two hardening changes made so the `encfile`/`peers` contracts could be
|
||||
strict at all: `EncryptedFile.Decrypt` maps every malformed-container failure
|
||||
to `InvalidDataException` and bounds the PBKDF2 iteration count (a tampered
|
||||
file could previously demand 2^31 iterations, hanging the wallet at open), and
|
||||
`ParsePeers` tolerates any JSON shape.
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Coverage-guided fuzzing with afl++ + SharpFuzz.
|
||||
#
|
||||
# One-time setup:
|
||||
# apt install afl++ (or build from source)
|
||||
# dotnet tool install --global SharpFuzz.CommandLine
|
||||
#
|
||||
# Usage:
|
||||
# ./fuzz.sh <target> [afl-fuzz args...]
|
||||
# ./fuzz.sh header
|
||||
# ./fuzz.sh peers -V 3600 # time-limited campaign (seconds)
|
||||
#
|
||||
# Targets: header merkle slip132 bip39 address coinamount walletdoc encfile peers
|
||||
#
|
||||
# Findings land in findings/<target>/crashes/: replay one with
|
||||
# dotnet run -- <target> findings/<target>/crashes/<file>
|
||||
# then add it to the seed corpus in Program.cs as a regression input.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TARGET="${1:?usage: ./fuzz.sh <target> [afl-fuzz args...]}"
|
||||
shift || true
|
||||
|
||||
command -v afl-fuzz >/dev/null || { echo "afl-fuzz not found: apt install afl++"; exit 1; }
|
||||
command -v sharpfuzz >/dev/null || { echo "sharpfuzz not found: dotnet tool install --global SharpFuzz.CommandLine"; exit 1; }
|
||||
|
||||
dotnet build -c Release
|
||||
BIN="bin/Release/net10.0"
|
||||
|
||||
# Instrument the assemblies under test (idempotent: SharpFuzz skips already-
|
||||
# instrumented DLLs). Core is the real target; NBitcoin gives the fuzzer
|
||||
# visibility into the library our parsers delegate to.
|
||||
sharpfuzz "$BIN/PalladiumWallet.Core.dll"
|
||||
sharpfuzz "$BIN/NBitcoin.dll" || true
|
||||
|
||||
mkdir -p "findings/$TARGET"
|
||||
afl-fuzz -i "Corpus/$TARGET" -o "findings/$TARGET" -t 5000 "$@" \
|
||||
-- dotnet "$BIN/PalladiumWallet.Fuzz.dll" "$TARGET" --afl
|
||||
@@ -126,4 +126,15 @@ public class Bip39Tests
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => Bip39.Generate(MnemonicLength.Twelve, (MnemonicLanguage)99));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Testo_che_non_somiglia_a_nessuna_wordlist_viene_rifiutato_senza_eccezioni()
|
||||
{
|
||||
// NBitcoin's Wordlist.AutoDetect throws NotSupportedException("Unknown")
|
||||
// for text resembling no wordlist instead of failing gracefully (found by
|
||||
// fuzzing): TryParse must swallow it and return false — this string reaches
|
||||
// TryParse straight from the user's restore input.
|
||||
Assert.False(Bip39.TryParse("ábÊco ábaco0ábaco ábÊ", out var mnemonic));
|
||||
Assert.Null(mnemonic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using PalladiumWallet.Fuzz;
|
||||
|
||||
namespace PalladiumWallet.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Replays the fuzzing seed corpus (tests/PalladiumWallet.Fuzz/Corpus, copied
|
||||
/// next to the test binary) through every fuzz target on each test run: the
|
||||
/// corpus includes the regression inputs for crashes found by past campaigns,
|
||||
/// so a fixed contract violation can never silently come back. The real
|
||||
/// coverage-guided campaigns run separately via tests/PalladiumWallet.Fuzz/fuzz.sh.
|
||||
/// </summary>
|
||||
public class FuzzCorpusTests
|
||||
{
|
||||
public static TheoryData<string> Targets()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var name in FuzzTargets.All.Keys)
|
||||
data.Add(name);
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void Il_corpus_del_target_rispetta_il_contratto_del_parser(string target)
|
||||
{
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "Corpus", target);
|
||||
Assert.True(Directory.Exists(dir), $"seed corpus missing for '{target}' at {dir}");
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(dir))
|
||||
{
|
||||
// Any escaping exception is a contract violation: the target itself
|
||||
// swallows the exception types documented as the parser's failure mode.
|
||||
FuzzTargets.Run(target, File.ReadAllBytes(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,35 @@ public class PeerParsingTests
|
||||
Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]);
|
||||
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"not":"an array"}""")]
|
||||
[InlineData(""" "just a string" """)]
|
||||
[InlineData("42")]
|
||||
[InlineData("""[[1,2,3]]""")] // numeric ip/hostname
|
||||
[InlineData("""[["h","n",42]]""")] // features not an array
|
||||
[InlineData("""[["h","n",[42,{"x":1},null]]]""")] // non-string features
|
||||
public void Una_risposta_peers_di_forma_inattesa_produce_una_lista_vuota(string json)
|
||||
{
|
||||
// The response is untrusted server data: any shape must degrade to an
|
||||
// empty list, never throw (found by fuzzing).
|
||||
Assert.Empty(ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_stringa_json_con_utf8_invalido_viene_ignorata_senza_eccezioni()
|
||||
{
|
||||
// Raw 0x87 inside a JSON string: JsonDocument.Parse accepts the bytes but
|
||||
// GetString() cannot transcode them to UTF-16 (found by fuzzing). The
|
||||
// whole entry degrades to "no usable host/feature", not an exception.
|
||||
var bytes = "[[\"1.2.3.4\",\"#\",[\"v1\",\"t#\"]]]"u8.ToArray();
|
||||
var hostIdx = Array.IndexOf(bytes, (byte)'#');
|
||||
bytes[hostIdx] = 0x87;
|
||||
bytes[Array.LastIndexOf(bytes, (byte)'#')] = 0x87;
|
||||
|
||||
using var doc = JsonDocument.Parse(bytes);
|
||||
Assert.Empty(ElectrumApi.ParsePeers(doc.RootElement));
|
||||
}
|
||||
}
|
||||
|
||||
public class ServerRegistryTests
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -57,6 +57,22 @@ public class StorageTests
|
||||
Assert.NotEqual(c1, c2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")] // broken JSON
|
||||
[InlineData("null")] // JSON null
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1"}""")] // missing fields → null strings
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":600000,"Salt":"$$$","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // bad base64
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":600000,"Salt":"AA==","Nonce":"AA==","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // wrong nonce size
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":0,"Salt":"AA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // iterations 0
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":2147483647,"Salt":"AA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // PBKDF2 DoS
|
||||
public void Un_contenitore_malformato_produce_sempre_InvalidDataException(string container)
|
||||
{
|
||||
// A tampered/corrupted file must map to the two typed exceptions, never to a
|
||||
// raw JsonException/FormatException/ArgumentNullException (found by fuzzing);
|
||||
// the absurd iteration count would otherwise hang the wallet at open.
|
||||
Assert.Throws<InvalidDataException>(() => EncryptedFile.Decrypt(container, "pass"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ogni_encrypt_produce_salt_diverso()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user