Skip to content

feat(core): add human-assisted wallet entropy - #2547

Draft
BullishNode wants to merge 8 commits into
developfrom
feat/entropy-pool
Draft

feat(core): add human-assisted wallet entropy#2547
BullishNode wants to merge 8 commits into
developfrom
feat/entropy-pool

Conversation

@BullishNode

@BullishNode BullishNode commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Disclaimer

We have no reason to believe that BDK has any RNG or entropy-generation defect. This is an experimental hardening project intended to reduce reliance on any single entropy path and to reduce attack surface. It is not a response to an ongoing incident, known vulnerability, or suspected defect in BDK.

Summary

  • Generate 12-word BIP39 mnemonics from 128 bits of explicitly combined entropy.
  • Require both a fresh 64-byte operating-system CSPRNG draw and a completed 500-sample finger ceremony for every generated wallet.
  • Count only qualified pointer observations: Flutter-synthesized events, consecutive duplicate positions, and non-finite movement data cannot advance ceremony progress.
  • Mix additional touch-contact measurements already supplied by Flutter: minor radius, contact size, orientation, and tilt, alongside position, delta, event and arrival timing, pressure, and major radius.
  • Combine inputs through a domain-separated, length-framed SHA-512 pool with a counter and retained 256-bit state.
  • Use BDK only for deterministic Mnemonic.fromEntropy encoding; do not invoke BDK's random mnemonic constructor.
  • Fail closed if either required input is missing or the OS source fails its catastrophic sanity checks.
  • Remove CPU-jitter, system-statistics, IMU/magnetometer, time-strengthening, and ceremony-bypass paths.
  • Add no motion-sensor dependency, motion permission, camera access, microphone access, or persistent gesture storage.
  • Keep new-wallet output at the existing 12-word product standard.

Why

The goal is defense in depth against a hypothetical defect or supply-chain compromise affecting one randomness path. The human transcript is a physically distinct, deliberately uncredited input: it may preserve meaningful unpredictability if the OS RNG becomes predictable, but the implementation does not claim a fixed number of entropy bits from finger movement.

Five hundred is a conservative pacing threshold, not an assertion of 500 bits or any fixed per-event contribution. Pointer observations are correlated and device pipelines differ, so the gate rejects known low-value callbacks while the cryptographic pool mixes the complete accepted transcript.

Motion sensors were deliberately excluded after considering privacy and complexity. Their incremental entropy is difficult to quantify, they expand permissions and lifecycle behavior, and prior plugin defects demonstrate the reliability surface they add. The existing explicit finger ceremony supplies the independently motivated physical input without introducing another permission or dependency.

This is not designed to protect against a malicious application or runtime that can observe or replace both the OS draw and the pointer transcript.

Verification

  • make checks
  • Whole-project flutter analyze --fatal-warnings --fatal-infos — no issues
  • dart fix --dry-runNothing to fix!
  • Formatting — 1,613 files unchanged
  • 856 app tests passed
  • 17 bull_ui tests passed
  • 1 catalogue test passed
  • Independent Python hashlib known-answer vector for the 500-sample SHA-512 pool construction

Draft checklist

  • Real-device wallet creation on Android
  • Real-device wallet creation on iOS
  • Create-and-restore round trip using the generated 12-word mnemonic
  • Product/security review of the mandatory finger ceremony

- SHA-512 mix/extract pool modelled on Bitcoin Core's RNGState::MixExtract:
  every source is concatenated and hashed together with the previous state
  (strictly additive - no source can ever reduce pool entropy), the first
  half of each digest is the output and the second half becomes the next
  secret state, with a 10ms strengthening pass on extraction
- Mandatory floor: extraction refuses unless both the OS RNG
  (Random.secure) and a bdk RNG draw were mixed since the last extract,
  so seeds are never weaker than the platform CSPRNG. The two mandatory
  sources reach the kernel through independent bindings (Dart vs Rust
  getrandom)
- Best-effort additive sources: CPU clock jitter (jitterentropy-style
  busy loop on a worker isolate), process/system stats, and IMU sensor
  noise via sensors_plus
- New onboarding entropy ceremony: the user drags a finger on screen
  (VeraCrypt-style) and every raw pointer sample is mixed into the pool,
  with a minimalist trail, hint animation, hairline progress bar and
  milestone messages every 10%
- New wallets are 24 words (256-bit entropy) via Mnemonic.fromEntropy;
  import, recovery and BIP85 paths are unchanged
- Tests: known-answer vectors against an independent spec implementation,
  additivity property with adversarial sources, mandatory-source gating,
  collector failure policy, ceremony cubit pacing
- iOS: add NSMotionUsageDescription (sensors_plus accesses motion data;
  missing key crashes the app on first sensor read)
- Bump sensors_plus 6.1.2 -> 7.1.0: 6.x never stopped the iOS magnetometer
  on stream cancellation (its cancel handler called
  stopDeviceMotionUpdates instead of stopMagnetometerUpdates)
- Pause sensor sampling while the app is backgrounded via a lifecycle
  observer on the ceremony screen
- Make the mandatory entropy gate unspoofable: only the new
  pool.mixMandatory (validated source identity + >=32-byte minimum) can
  satisfy the security floor; the UI-facing supplemental mix path never
  can, whatever source name it passes
- Serialize mnemonic generation: collect -> mandatory mix -> extract is
  one queued transaction, so concurrent calls cannot interleave pool state
- Memory hygiene: zeroize entropy and the bdk draw on every exit path
  (try/finally), dispose FFI mnemonic handles explicitly, wipe mixer
  input buffers and strengthening intermediates, wipe collector output
  after mixing, build OS RNG bytes without an intermediate list
- Neutralize hardcoded '12 words' user copy in backup/recovery screens:
  new wallets are 24 words and the old copy instructed users to write
  down half their mnemonic
- Accessibility: ceremony canvas gets a semantic label; taps count toward
  progress; after 20s a 'Continue without drawing' fallback appears so
  users who cannot perform gestures can still create a wallet (ceremony
  input is supplemental; the RNG floor is enforced at extraction)
- Known-answer tests now pin extraction outputs to vectors computed by an
  independent Python hashlib implementation of the specification, plus
  gate-spoofing and short-read regression tests
- Correct overclaiming doc comments: the two mandatory sources share the
  OS entropy root (thread_rng is userspace, OS-reseeded); they provide
  binding-diversity, not independent roots, and additivity is
  computational under SHA-512 assumptions
Source-level invariants that fail loudly instead of silently when a
refactor introduces the RNG failure classes seen in the Coldcard
firmware disclosure (predictable fallback binding, narrow reseed pipe,
call-site drift):

- fresh mnemonic generation has exactly one call site
- only the generator and locator may reach the entropy pool from
  outside the entropy module
- only the collector and generator may feed the mandatory gate
- no non-secure Random anywhere in entropy or seed modules
- production wiring cannot override the pool's strengthening budget
Dart's Random.secure is implemented by the Flutter engine registering
dart::bin::GetEntropy, which reads /dev/urandom directly on both Android
and iOS (runtime/bin/crypto_linux.cc and crypto_macos.cc in the Dart
SDK) - not getrandom(2) or SecRandomCopyBytes as previously stated.
Failure of the open/read throws with no fallback, so the fail-closed
property is unchanged; only the mechanism description was wrong.
@BullishNode

BullishNode commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Entropy strategy and design rationale

Threat model

This change is a hedge against a narrow but serious failure mode: the wallet's nominal CSPRNG path returns predictable bytes because of a platform defect, integration bug, or supply-chain compromise. It is not evidence of such a defect in BDK, Dart, Android, or iOS.

The construction requires two inputs for every generated wallet:

  1. A fresh 64-byte draw from Dart's platform-backed Random.secure() path.
  2. A completed transcript of 500 qualified pointer samples generated by deliberate finger movement.

Neither input is treated as optional. There is no predictable fallback, low-bit reseed, or path that silently continues after a source failure. This is directly motivated by the class of failure documented in Block's analysis of Coldcard's predictable fallback and 32-bit reseed: https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware

The security claim is deliberately conditional: if either the OS draw or the touch transcript remains unknown to the attacker, the combined result should remain computationally unpredictable under the SHA-512 assumptions. If the entire app/runtime is malicious and can observe or replace both inputs, this design cannot help.

Bitcoin Core inspiration

This is inspired by the shape of Bitcoin Core's RNG, not a line-for-line port.

Bitcoin Core maintains a process-wide 256-bit RNG state. Its MixExtract operation finalizes SHA-512 over newly gathered material plus the prior state and a counter, returns up to the first 32 digest bytes, and retains the last 32 bytes as the next state. Its strong path mixes fresh OS randomness, and the OS failure path terminates rather than substituting weak randomness. See Bitcoin Core's current design documentation and implementation:

Our pool follows the same useful core pattern:

  • 32-byte retained state.
  • Monotonic counter.
  • SHA-512 mix/extract.
  • Output from the first digest half and rekeyed state from the second half.
  • Fresh OS entropy required at strong extraction time.
  • Best-effort cleanup of intermediate buffers.

We add explicit domain and length framing for ceremony start, individual samples, ceremony completion, and OS input. This prevents ambiguous concatenations and cross-purpose reuse. A completed ceremony is a one-use capability: extraction consumes it, and starting a new ceremony invalidates an older unconsumed completion.

We intentionally do not copy every Bitcoin Core source. CPU jitter, system statistics, generic timestamps, and mobile IMU data were removed because we could not justify a defensible entropy estimate for them in this environment. Keeping many impressive-looking inputs without a clear threat-model contribution would be noise theater. The design instead has two understandable mandatory components.

Why finger swipes instead of the camera

A camera can contain physical sensor noise, but the application normally receives frames after a device-specific image-processing pipeline rather than controlled raw sensor samples. Compression, denoising, auto-exposure, stabilization, frame buffering, and vendor behavior make the actual contribution difficult to characterize. Camera frames also create large sensitive buffers with more copies and a less convincing best-effort wiping story. Activating a camera during seed creation also has a materially different privacy and trust signal for users.

Finger input is smaller and more explicit. Each sample commits to the event kind, pointer identifier, coordinates, deltas, framework timestamp, an independent monotonic-clock reading, pressure, major and minor touch radius, contact size, orientation, and tilt. Samples are serialized into fixed 112-byte buffers, mixed immediately, and then wiped on a best-effort basis. No motion-sensor plugin or additional ceremony sensor lifecycle is required.

The 500-sample threshold is a pacing and freshness gate, not an estimate of 300 events' entropy. Flutter-synthesized events, consecutive duplicate positions, and non-finite movement data do not advance the gate. Completion time remains device-dependent. Taps remain accepted for users who cannot perform a continuous gesture. We make no claim such as “one bit per event.”

The camera could be reconsidered as an optional additional source only if we define the exact capture pipeline, retention rules, supported platforms, and a conservative entropy argument. It should not replace the OS CSPRNG floor or the explicit human contribution merely because it yields more bytes.

Why 12 words

The product currently uses 12-word BIP39 mnemonics. This change therefore extracts exactly 16 bytes (128 bits) and passes them to BDK's deterministic Mnemonic.fromEntropy encoder. The OS source is intended to provide the full security floor. The finger transcript is supplemental and uncredited, not a reason to market the mnemonic as having more than 128 bits of entropy.

What each component actually contributes

  • OS CSPRNG: the primary entropy source and intended 128-bit security floor. A 64-byte draw is required, checked for exact length, rejected if all bytes are identical, and rejected if it exactly repeats the previous process-local draw. These checks detect catastrophic failures only; they are not statistical certification.
  • Finger transcript: a physically distinct hedge. If the OS bytes are predictable but the attacker cannot predict the gesture transcript, it can still prevent precomputation or mass exploitation. Its effective security is the transcript's real unpredictability in that scenario, not a guaranteed 128 bits.
  • SHA-512 pool and retained state: a cryptographic combiner and state-evolution mechanism. Under the hash assumptions, mixing a weak or attacker-known component does not cancel a strong unknown component. Retained state also separates repeated inputs across ceremonies.
  • BDK: deterministic BIP39 encoding only. The PR deliberately avoids creating a second nominal RNG draw through BDK because two user-space CSPRNGs may ultimately share the same OS entropy root and should not be described as independent sources.

Limitations

  • Dart's garbage collector may copy sensitive values; zeroization is best effort. A forensics-grade memory threat model would require moving the pool and secret handling into controlled native memory.
  • Pointer delivery and Random.secure() both execute inside the application trust boundary. Full process compromise defeats both.
  • The sanity checks catch obvious catastrophic RNG behavior, not biased or adversarially structured output.
  • Real-device Android/iOS validation and a create-and-restore round trip remain required before this leaves draft status.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @BullishNode's task in 8m 18s —— View job


Review: human-assisted wallet entropy (PR #2547)

  • Read repo guidelines and PR diff against origin/develop
  • Review entropy pool + OS RNG source (crypto correctness, fail-closed behavior)
  • Review mnemonic generator integration and wallet-creation flow for regressions
  • Review ceremony cubit/UI and locator wiring
  • Review tests for gaps
  • Post findings

Note: this environment doesn't permit creating inline review comments (the pulls/comments API call is not in my allowed tools), so all findings are below with file/line references.

Summary

The cryptographic core is sound. I verified the SHA-512 pool construction (domain- and length-framed inputs, monotonic counter, output from the first digest half, retained state from the second half), the fail-closed paths (no fallback when either source is missing or the OS draw fails sanity checks), and the one-use ceremony semantics — including the good detail that a short OS draw throws before mixing, so the ceremony isn't consumed by a retryable failure (entropy_pool.dart:93-102). DI wiring is correct: the cubit's MixEntropyUsecase and MnemonicGenerator share the same EntropyPool lazy singleton. Regression surface is contained: MnemonicGenerator.generate() has exactly one call site (create_default_wallets_usecase.dart:47), which is only reached from OnboardingCreateNewWallet, which is only dispatched by the ceremony screen; all restore paths pass explicit mnemonicWords and never touch the pool. The guard tests pinning the absence of Mnemonic(wordCount:) and non-secure Random are a nice tripwire.

No blocking correctness or security bugs found. The findings below are flow/robustness issues in the UI layer, plus nits.

Findings

1. Back navigation during the 1.2 s completion pause silently drops wallet creationlib/features/onboarding/ui/screens/onboarding_entropy_ceremony.dart:150-155 (low/medium, UX flow)

After the bar fills, OnboardingCreateNewWallet is dispatched via Future.delayed(_completePause) guarded by if (!mounted) return;. If the user back-swipes during that window (there is no PopScope, and pointer input is already disabled so the screen feels "done"), the event is never dispatched: the user lands on splash with no feedback, believing the wallet may have been created, and the pool holds an unconsumed completed ceremony. Recovery works (re-entering starts a fresh ceremony), but the silent drop is confusing. Consider dispatching immediately on completion (the sticky milestone can still show during creating), or blocking pop between completion and the create dispatch. Fix this →

2. Pool state exceptions escape into the pointer-event dispatcherlib/features/onboarding/presentation/entropy_ceremony_cubit.dart:72-81 (low, robustness)

_mixEntropyUsecase.execute() / complete() throw TouchEntropyCeremonyStateException if the process-wide pool's ceremony state ever disagrees with this cubit's local eventCount. That's reachable: create_wallet_button.dart:33 has no re-entry guard on pushNamed, so a fast double-tap stacks two ceremony routes (two cubits, second begin() wins). Complete the top one, back-swipe during the pause (finding 1), and every touch on the revealed stale screen throws an uncaught exception from inside Flutter's pointer dispatch — frozen progress bar, error spam, no recovery hint. Two cheap hardenings: catch the pool state exception in addPointerSample and restart the ceremony (begin() + reset count), and debounce the button's pushNamed. Fix this →

3. Progress label shows 100% before the ceremony is completelib/features/onboarding/ui/screens/onboarding_entropy_ceremony.dart:294 (nit)

(value * 100).round() displays "100%" from sample 299 (progress 0.9967) while input is still required. Use .floor() so 100% coincides with actual completion.

4. Trail buffer retains raw gesture coordinateslib/features/onboarding/ui/screens/onboarding_entropy_ceremony.dart:36,80 (informational)

The cubit carefully zeroes each 80-byte sample buffer, but _trail keeps up to 400 raw positions in GC-managed memory for the life of the screen, including through the whole creating phase. Since framework pointer events expose the same data anyway and the touch input is deliberately uncredited, the impact is negligible — but clearing _trail on completion would make the hygiene story consistent.

5. Screen-reader accessibility of the mandatory ceremony (product note)

With TalkBack/VoiceOver, touch exploration intercepts pointer events, so delivering 300 down/move samples may be very difficult for screen-reader users — and there is deliberately no bypass. The Semantics label is good, but this deserves an explicit answer in the planned "product/security review of the mandatory finger ceremony" checklist item (e.g. pass-through gesture support), since it gates wallet creation entirely.

Security assessment details

  • Framing: _mixInternal frames len(domain) ‖ domain ‖ counter ‖ len(data) ‖ data ‖ state — no ambiguous concatenations. The extract digest (counter ‖ state, 40 bytes) can't collide with any mix preimage (all ≥ 66 bytes), and the counter makes every hash input unique.
  • Output/state separation: output is digest[0..16], next state is digest[32..64] — no overlap even at the 32-byte max extract; matches the Bitcoin Core MixExtract pattern as described.
  • BytesBuilder(copy: false) + takeBytes(): safe here — multiple chunks are always added, so takeBytes concatenates into a fresh buffer and _zero(buffer) never zeroes _state or caller data.
  • OsRngSource: repeat detection compares SHA-256 digests (constant-time) rather than retaining raw draws; sanity failures zero both the draw and the candidate digest; provider failure propagates with no fallback. All verified by tests.
  • Serialization: MnemonicGenerator._queue correctly serializes concurrent generate() calls, and since the app is single-isolate, no pool mutation can interleave between the OS draw and extraction in a way that produces weak output — a mid-flight begin() would just make extraction fail closed.
  • No web target in the repo, so the ByteData.setUint64 calls (unsupported under dart2js) are fine on the supported platforms.
  • The unrelated "12 or 24 recovery words" copy edits bundled in localization/app_en.arb are harmless but arguably belong in a separate PR.

Test coverage

Coverage is strong (known-answer vector, fail-closed paths, consumption semantics, state chaining, source-drift tripwires). Two small gaps worth adding: a cubit test for pointer samples arriving after the pool ceremony was externally invalidated (finding 2's scenario), and a widget/bloc test that completion actually dispatches OnboardingCreateNewWallet (the delayed dispatch in finding 1 is currently untested).


@BullishNode

Copy link
Copy Markdown
Contributor Author

Follow-up after adversarial gesture capture

I instrumented the ceremony locally in a debug-only build (the capture harness and traces are not part of this PR; wallet generation was disabled during capture). The useful result was negative: 500 accepted pointer events are not an entropy estimate. Human emulator traces completed in roughly 6–8 seconds, a tiny repetitive gesture still passed, and one deterministic Android-injected straight swipe reached 500 accepted samples in 3.5 seconds.

Commit 3523d5f therefore adds anti-degeneracy gates while preserving the original design:

  • completion requires at least 500 accepted samples;
  • at least 10 monotonic seconds must separate the first and final accepted samples;
  • movement must span at least 50% of both canvas axes;
  • progress reflects the slowest incomplete gate, so it cannot display 100% early;
  • every accepted sample is still mixed, now with explicit sequence order and pointer device kind in addition to coordinates, deltas, timestamps and contact metadata;
  • regression tests prove that fast 500-sample and narrow repetitive transcripts cannot complete the ceremony.

These conditions improve ordinary gesture diversity but are deliberately not credited as entropy bits. They cannot distinguish a sufficiently adaptive script or protect against an OS that observes or fabricates touch input. The human input remains a supplemental hedge against a broken RNG when the rest of the input path is honest. This follow-up intentionally does not add camera/sensor permissions or re-architect the RNG stack.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants