10 Commits

Author SHA1 Message Date
davide feb765663c chore(chain): add mainnet checkpoint at height 475124 2026-07-17 15:14:24 +02:00
davide 322ce8f305 chore(release): bump version to 1.0.0
Fills in the CHANGELOG.md entry for the checkpoint-anchoring security fix,
fuzzing infrastructure and its findings, OP_RETURN/coinbase-tag decoding,
and localization fixes since 0.9.1 (see previous commits), and bumps
<Version>/versionCode across the App and Android head csproj files ahead
of the tag.
2026-07-17 14:02:52 +02:00
davide 077835a30b feat(wallet): decode OP_RETURN payloads and coinbase pool tags in tx details
Transaction detail view silently dropped OP_RETURN messages and coinbase
scriptSig text (e.g. pool tags), since TransactionInspector discarded
scriptPubKey/scriptSig bytes once no destination address could be derived.
Each output/input is decoded independently, so multiple OP_RETURN outputs
in one tx are all shown.
2026-07-09 08:35:39 +02:00
davide 663460d62e feat(ui): add user guide button to help overlay
Adds a "User guide" button next to "Report a bug" in the Help > Info
tab, linking to USERGUIDE.md on GitHub via the same Launcher pattern
used for bug reports and release pages.
2026-07-07 22:35:19 +02:00
davide be818a50a8 docs: tighten CLAUDE.md/AGENTS.md formatting for scannability
Convert dense prose paragraphs to bullet lists (Commands, Architecture,
GUI conventions, Working rules) and trim redundant wording; no content
removed. Both files kept in sync per the header sync rule.
2026-07-07 22:29:46 +02:00
davide 6f4ae679e5 docs: document fuzzing infrastructure and its security guarantees
Sync AGENTS.md/CLAUDE.md with the fuzzing workflow, add a README section
pointing to tests/PalladiumWallet.Fuzz, and record in SECURITY.md the two
guarantees the fuzz suite enforces: a bounded PBKDF2 iteration count on read
and a typed InvalidDataException for any malformed encrypted container.
2026-07-07 22:06:45 +02:00
davide cdede17683 feat(fuzz): add SharpFuzz-based fuzzing for untrusted-input parsers
New tests/PalladiumWallet.Fuzz project: one target per parser that
consumes untrusted input (block headers, Merkle proofs, peer lists,
wallet files, user-pasted mnemonics/keys/addresses/amounts), each
encoding the parser's documented error contract - any exception beyond
that contract is a finding, reported as a crash.

Three ways to run: the seed corpus (with regression inputs for every
crash found so far, including the two fixed in prior commits) replays
automatically inside dotnet test via FuzzCorpusTests, so a fixed
contract violation can't silently return; a built-in random-mutation
mode needs no external tooling (dotnet run -- <target> --random N);
and fuzz.sh drives real coverage-guided campaigns via afl++ +
SharpFuzz instrumentation for pre-release or post-parser-change runs.
2026-07-07 22:05:38 +02:00
davide a264669151 fix(storage): EncryptedFile.Decrypt must reject malformed containers cleanly
A tampered or corrupted wallet file could reach Decrypt with broken
JSON, missing fields, invalid base64, or a wrong nonce/tag size, all of
which previously escaped as raw JsonException/FormatException/
ArgumentNullException instead of the documented WrongPasswordException/
InvalidDataException contract. Worse, the iteration count is read
straight from the (attacker-controlled) container with no upper bound:
a tampered file demanding e.g. 2^31 PBKDF2 iterations would hang the
wallet at open - a DoS via a file the user merely tries to open. Now
every malformed shape maps to InvalidDataException and the iteration
count is clamped to a sane maximum (10,000,000, well above the current
600,000 default).
2026-07-07 22:05:08 +02:00
davide d9c75eaec2 fix(net): ParsePeers must tolerate any JSON shape from the server
server.peers.subscribe is attacker-controlled data: ParsePeers assumed
the standard [ip, hostname, [features...]] shape and threw on anything
else (non-array response, wrong element types), and GetString() on a
JSON string containing invalid UTF-8 bytes threw InvalidOperationException
instead of failing to parse — both found by fuzzing. Any unexpected
shape or unparseable string now degrades to "no usable data" instead of
propagating an exception into the sync/discovery path.
2026-07-07 21:52:00 +02:00
davide c868c17505 fix(crypto): Bip39.TryParse must not throw on unrecognisable text
Wordlist.AutoDetect lands on NBitcoin's internal "Unknown" language for
text resembling no wordlist and throws NotSupportedException instead
of failing gracefully — found by fuzzing the restore-mnemonic input.
TryParse now treats that (and the same failure mode from the Mnemonic
constructor) as "not a valid mnemonic", consistent with every other
rejection path.
2026-07-07 21:51:31 +02:00
57 changed files with 1266 additions and 172 deletions
+4
View File
@@ -93,3 +93,7 @@ settings.local.json
# Local agent/tooling metadata
.agents/
.codex/
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
findings/
crash-*.bin
+47 -32
View File
@@ -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 codesay 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
+102
View File
@@ -5,6 +5,108 @@ Technical changelog for PalladiumWallet. Format loosely follows
by subsystem rather than strictly by date, since `0.9.0` is the first
release and covers the full history from the initial commit.
## [1.0.0] — 2026-07-09
First stable release. Closes the last open security gap from 0.9.x (header
trust was not actually anchored to any checkpoint), fixes several crash
paths found by a new fuzzing harness, and adds OP_RETURN/coinbase-tag
decoding to the transaction detail view.
### Security
- `WalletSynchronizer.AnchorToCheckpointAsync`: header trust is now actually
anchored to `ChainProfiles.Mainnet.Checkpoints` (24 real `[height, hash,
bits]` checkpoints pulled from a fully-synced node, every 20,000 blocks
plus one near tip). Previously the checkpoint array was empty and the
methods meant to enforce it (`MatchesCheckpoint`/`IsValidChild`) were
never called — on this LWMA chain, where PoW can't be recomputed
client-side, a malicious or eclipsing server could hand back any
internally-consistent header for a Merkle proof with nothing tying it to
the real chain. For every header used in a Merkle proof, the intervening
headers are now downloaded back to the nearest checkpoint and verified as
an unbroken prev-hash chain terminating in that checkpoint's exact hash
(memoized per sync session). Testnet/Regtest remain unanchored (no node
available to source checkpoints from); a missing checkpoint is a no-op,
not a failure.
- New fuzzing harness (`tests/PalladiumWallet.Fuzz`, SharpFuzz-based): one
target per untrusted-input parser (header, Merkle proof, peer list,
wallet file, mnemonic/key/address/amount), each encoding that parser's
documented error contract. Found and fixed:
- `Bip39.TryParse` threw `NotSupportedException` on text resembling no
wordlist instead of failing gracefully.
- `ElectrumApi.ParsePeers` threw on any `server.peers.subscribe` response
shape other than the expected `[ip, hostname, [features...]]`, and on a
JSON string containing invalid UTF-8.
- `EncryptedFile.Decrypt` let a tampered/corrupted wallet file escape as
raw `JsonException`/`FormatException`/`ArgumentNullException` instead of
the documented `WrongPasswordException`/`InvalidDataException`
contract; worse, the PBKDF2 iteration count was read from the
(attacker-controlled) container with no upper bound — a tampered file
demanding e.g. 2³¹ iterations would hang the wallet on open. Iteration
count is now clamped to 10,000,000.
- `CertificatePinStore.Load`: a corrupted pin file blocked every SSL
connection until manually deleted; now falls back to first-contact
TOFU like `ServerRegistry` already did.
- The seed corpus (incl. a regression input per fixed finding) replays
inside `dotnet test` via `FuzzCorpusTests`, so a fix can't silently
regress.
- `SECURITY.md` corrected: PBKDF2 parameters (600,000 iterations / 16-byte
salt, not the pre-hardening 100,000 / 32-byte), a stale file reference,
and disclosure of the AI-assisted testing methodology used (adversarial
fake-server simulation, property-based fuzzing, targeted security
review) as a complement to, not a replacement for, independent review.
### Added
- Transaction detail view now decodes OP_RETURN output payloads (UTF-8, or
hex if the bytes aren't valid text — multiple OP_RETURN outputs in one tx
are each decoded independently) and coinbase scriptSig pool tags (e.g.
`/slush/`, extracted as printable-ASCII runs amid the binary BIP34
height/extranonce). Both were previously dropped entirely — discarded
once no destination address could be derived from the script.
- Help overlay: "User guide" button next to "Report a bug", linking to
`USERGUIDE.md` on GitHub.
- `USERGUIDE.md`: full end-user reference for GUI and CLI (wizard flows,
script types, fees, gap limit, TOFU cert pinning, CLI commands) with the
exact numbers the software enforces.
### Fixed
- Send/Donate/Sync/Wizard view models wrote status/error strings directly
in Italian regardless of the active language; routed through `Loc` with
the missing keys added. `CertificatePinMismatchException` no longer
bakes an Italian message into `.Message` — it exposes `Host`/`Port` for
the UI to translate.
- CLI (`src/Cli/Program.cs`) printed all output in Italian regardless of
the code/docs-are-English-only policy; translated every user-facing
string and comment.
### Testing
- Test suite expanded from 307 to 392 tests, closing coverage gaps in:
checkpoint-anchoring (including the memoization and non-generic-retry
branches), the PoW-checked branch of `BlockHeaderInfo.IsValidChild`
(never run since every profile sets `SkipPowValidation`), all 8 BIP-39
wordlist languages plus the empty-input guard, SLIP-132 rejection of
malformed/corrupted keys, `TransactionFactory`'s standardness-policy
rejection, `ImportedKeyAccount`'s fund-safety fallbacks,
`WalletLoader`'s defensive branches for corrupted files,
`PalladiumNetworks.For`/`INetworkSet`, `AppPaths`' full data-root
precedence chain (via new internal override seams), `UpdateChecker`
end-to-end via a stub-transport seam, and `ElectrumClient`'s
multi-segment response dispatch.
- OP_RETURN/coinbase-tag decoding covered: UTF-8 text, binary fallback to
hex, multiple OP_RETURN outputs in one tx, pool-tag extraction, and the
absence of false positives on standard outputs/inputs.
### Documentation
- `AGENTS.md`/`CLAUDE.md` re-synced (had drifted) and reformatted from
dense prose to scannable bullet lists; a stale `SECURITY.md` file
reference fixed.
- `README.md` test-coverage section and `SECURITY.md` updated to describe
the checkpoint anchoring and fuzzing guarantees actually enforced now.
## [0.9.1] — 2026-07-02
### Testing
+47 -32
View File
@@ -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 codesay 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
+73 -3
View File
@@ -19,39 +19,107 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop
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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
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}
@@ -60,5 +128,7 @@ Global
{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
+12
View File
@@ -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
View File
@@ -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
---
+1
View File
@@ -205,6 +205,7 @@ build_android() {
-t:SignAndroidPackage \
-p:AndroidSdkDirectory=\${ANDROID_HOME} \
-p:ApplicationVersion=${VERSION_CODE} \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=/keystore/release.keystore \
-p:AndroidSigningKeyAlias=palladiumwallet \
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
@@ -8,8 +8,8 @@
<Nullable>enable</Nullable>
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
<ApplicationVersion>2</ApplicationVersion>
<ApplicationDisplayVersion>0.9.1</ApplicationDisplayVersion>
<ApplicationVersion>3</ApplicationVersion>
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
+1
View File
@@ -63,6 +63,7 @@ public sealed class Loc
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch"],
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar"],
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:"],
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
+1 -1
View File
@@ -6,7 +6,7 @@
<TargetFramework>net10.0</TargetFramework>
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
titolo della finestra ed è incisa nei binari pubblicati. -->
<Version>0.9.1</Version>
<Version>1.0.0</Version>
<Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
@@ -64,13 +64,17 @@ public sealed class TransactionDetailsViewModel
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—",
i.IsCoinbase
? i.CoinbaseTag is { Length: > 0 } tag
? $"{loc["tx.coinbase.newcoins"]} — {tag}"
: loc["tx.coinbase.newcoins"]
: i.Address ?? "—",
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
i.IsMine)));
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
$"#{o.Index}",
o.Address ?? $"({o.ScriptType})",
o.Address ?? (o.OpReturnText is { Length: > 0 } msg ? $"OP_RETURN: {msg}" : $"({o.ScriptType})"),
CoinAmount.FormatIn(o.AmountSats, unit),
o.IsMine)));
}
+5
View File
@@ -1563,9 +1563,14 @@
Foreground="{DynamicResource TextSecondaryBrush}"/>
</StackPanel>
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="{Binding Loc[help.bug.report]}"
Click="OnOpenBugReportClick"
HorizontalAlignment="Left"/>
<Button Content="{Binding Loc[help.user.guide]}"
Click="OnOpenUserGuideClick"
HorizontalAlignment="Left"/>
</StackPanel>
<Border BorderBrush="{DynamicResource BorderSubtleBrush}"
BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
<StackPanel Spacing="4">
+8
View File
@@ -144,6 +144,14 @@ public partial class MainView : UserControl
await launcher.LaunchUriAsync(new Uri(issueUrl));
}
private async void OnOpenUserGuideClick(object? sender, RoutedEventArgs e)
{
const string userGuideUrl = "https://github.com/davide3011/PalladiumWallet/blob/main/USERGUIDE.md";
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
if (launcher is not null)
await launcher.LaunchUriAsync(new Uri(userGuideUrl));
}
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
+4 -2
View File
@@ -48,8 +48,9 @@ public static class ChainProfiles
new ServerEndpoint("89.117.149.130", 50001, 50002),
],
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
// RPC (getblockhash/getblockheader), spaced every 20,000 blocks (~660h) plus one
// recent block. Anchors WalletSynchronizer's header-chain verification (§7.3):
// RPC (getblockhash/getblockheader) or verified against a trusted indexing server,
// spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
// the header-anchoring walk short. Anchors WalletSynchronizer's header-chain verification (§7.3):
// bounds how far back a forged header chain must be walked to be caught, since
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
Checkpoints =
@@ -78,6 +79,7 @@ public static class ChainProfiles
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
new Checkpoint(475124, "00000000000009e66da1e1a430fd1932aa75bf513053df088764545d941f13ca", 0x1a1a98cb),
],
};
+23 -6
View File
@@ -58,12 +58,26 @@ 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
{
wordlist = Wordlist.AutoDetect(text);
}
catch (NotSupportedException)
{
// 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);
@@ -76,8 +90,11 @@ public static class Bip39
}
catch (FormatException)
{
// Words outside the wordlist or invalid count — try the next candidate.
// Words outside the wordlist or invalid count.
}
catch (NotSupportedException)
{
// Defensive: same NBitcoin failure mode surfacing from the constructor.
}
return false;
+26 -4
View File
@@ -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;
}
}
}
+41 -9
View File
@@ -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)
{
+55 -4
View File
@@ -5,12 +5,18 @@ using PalladiumWallet.Core.Spv;
namespace PalladiumWallet.Core.Wallet;
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
/// <param name="CoinbaseTag">
/// Printable ASCII runs (e.g. pool tags like "/slush/") extracted from the coinbase
/// scriptSig; null for non-coinbase inputs or if no printable text was found.
/// </param>
public sealed record TxInputInfo(
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase,
string? CoinbaseTag = null);
/// <summary>An output of a transaction.</summary>
/// <param name="OpReturnText">Decoded OP_RETURN payload (UTF-8, or hex if not valid text); null otherwise.</param>
public sealed record TxOutputInfo(
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
uint Index, long AmountSats, string? Address, string ScriptType, string? OpReturnText, bool IsMine);
/// <summary>
/// Complete transaction data assembled by querying the server: the raw transaction
@@ -105,8 +111,9 @@ public static class TransactionInspector
{
var o = tx.Outputs[i];
var addr = AddrOf(o.ScriptPubKey);
var opReturn = addr is null ? OpReturnTextOf(o.ScriptPubKey) : null;
outputs.Add(new TxOutputInfo(
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey),
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey), opReturn,
addr is not null && ownedAddresses.Contains(addr)));
}
@@ -134,7 +141,7 @@ public static class TransactionInspector
{
if (tx.IsCoinBase)
{
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true));
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true, CoinbaseTagOf(inp.ScriptSig.ToBytes())));
continue;
}
@@ -189,4 +196,48 @@ public static class TransactionInspector
}
catch { return "—"; }
}
/// <summary>Decodes an OP_RETURN output's pushed data as UTF-8 text, falling back to hex if it isn't valid text.</summary>
private static string? OpReturnTextOf(Script script)
{
if (!script.IsUnspendable) return null;
try
{
var data = script.ToOps().Skip(1)
.Where(op => op.PushData is { Length: > 0 })
.SelectMany(op => op.PushData)
.ToArray();
return data.Length == 0 ? null : DecodeUtf8OrHex(data);
}
catch { return null; }
}
private static string DecodeUtf8OrHex(byte[] data)
{
var text = System.Text.Encoding.UTF8.GetString(data);
var looksLikeText = !text.Contains('') && text.All(c => !char.IsControl(c) || c is '\n' or '\r' or '\t');
return looksLikeText ? text : "0x" + Convert.ToHexString(data);
}
/// <summary>
/// Extracts printable-ASCII runs (≥4 chars) from a coinbase scriptSig, e.g. pool
/// tags like "/slush/" embedded among the binary BIP34 height and extranonce.
/// </summary>
private static string? CoinbaseTagOf(byte[] scriptSig)
{
var runs = new List<string>();
var run = new System.Text.StringBuilder();
void Flush()
{
if (run.Length >= 4) runs.Add(run.ToString());
run.Clear();
}
foreach (var b in scriptSig)
{
if (b is >= 0x20 and <= 0x7E) run.Append((char)b);
else Flush();
}
Flush();
return runs.Count == 0 ? null : string.Join(" ", runs);
}
}
@@ -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.
@@ -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
+141
View File
@@ -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>
+215
View File
@@ -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);
}
}
}
+66
View File
@@ -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.
+38
View File
@@ -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
@@ -23,6 +23,7 @@
<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()
{
@@ -195,6 +195,139 @@ public class TransactionInspectorTests
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Un_output_OP_RETURN_con_testo_UTF8_viene_decodificato()
{
var account = Account();
var tx = Net.CreateTransaction();
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("hello palladium"u8.ToArray()));
var (server, client) = await StartAsync(tx);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
Owned(account), netSats: 1_000_000, verified: true);
var opReturn = Assert.Single(details.Outputs, o => o.Index == 1);
Assert.Null(opReturn.Address);
Assert.Equal("hello palladium", opReturn.OpReturnText);
}
[Fact]
public async Task Un_output_OP_RETURN_con_dati_binari_ricade_su_hex()
{
var account = Account();
byte[] data = [0x00, 0x01, 0xFF, 0xFE, 0x02];
var tx = Net.CreateTransaction();
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey(data));
var (server, client) = await StartAsync(tx);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
Owned(account), netSats: 1_000_000, verified: true);
var opReturn = Assert.Single(details.Outputs, o => o.Index == 1);
Assert.Equal("0x" + Convert.ToHexString(data), opReturn.OpReturnText);
}
[Fact]
public async Task Piu_output_OP_RETURN_nella_stessa_tx_sono_decodificati_singolarmente()
{
var account = Account();
var tx = Net.CreateTransaction();
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("first"u8.ToArray()));
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("second"u8.ToArray()));
var (server, client) = await StartAsync(tx);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
Owned(account), netSats: 1_000_000, verified: true);
Assert.Equal("first", details.Outputs.Single(o => o.Index == 1).OpReturnText);
Assert.Equal("second", details.Outputs.Single(o => o.Index == 2).OpReturnText);
}
[Fact]
public async Task Un_output_normale_non_ha_testo_OP_RETURN()
{
var (funding, spend, _, account) = SpendPair();
var (server, client) = await StartAsync(funding, spend);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
Owned(account), netSats: -610_000, verified: true);
Assert.All(details.Outputs, o => Assert.Null(o.OpReturnText));
}
[Fact]
public async Task Il_tag_pool_nello_scriptSig_della_coinbase_viene_estratto()
{
var account = Account();
var coinbase = Net.CreateTransaction();
// BIP34 height push (binary) followed by a pool tag (printable ASCII).
var scriptSig = new Script(Op.GetPushOp(190), Op.GetPushOp("/slush/"u8.ToArray()));
coinbase.Inputs.Add(new TxIn(new OutPoint(uint256.Zero, 0xffffffff), scriptSig));
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
var (server, client) = await StartAsync(coinbase);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
Owned(account), netSats: 5_000_000_000, verified: true);
var input = Assert.Single(details.Inputs);
Assert.True(input.IsCoinbase);
Assert.Contains("/slush/", input.CoinbaseTag);
}
[Fact]
public async Task Uno_scriptSig_coinbase_senza_testo_stampabile_non_produce_tag()
{
var account = Account();
var coinbase = Net.CreateTransaction();
var scriptSig = new Script(Op.GetPushOp([0x00, 0x01, 0x02]));
coinbase.Inputs.Add(new TxIn(new OutPoint(uint256.Zero, 0xffffffff), scriptSig));
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
var (server, client) = await StartAsync(coinbase);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
Owned(account), netSats: 5_000_000_000, verified: true);
var input = Assert.Single(details.Inputs);
Assert.Null(input.CoinbaseTag);
}
[Fact]
public async Task Un_input_normale_non_ha_tag_coinbase()
{
var (funding, spend, _, account) = SpendPair();
var (server, client) = await StartAsync(funding, spend);
await using var _ = server; await using var __ = client;
var details = await TransactionInspector.FetchAsync(
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
Owned(account), netSats: -610_000, verified: true);
Assert.All(details.Inputs, i => Assert.Null(i.CoinbaseTag));
}
[Fact]
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
{