Skip to content

V4.1.1 enhancements & hardenings - #83

Merged
boldbitcoin merged 142 commits into
mainfrom
v4.1.1-enhancements
Oct 29, 2026
Merged

boldbitcoin merged 142 commits into
mainfrom
v4.1.1-enhancements

Conversation

@boldbitcoin

Copy link
Copy Markdown
Contributor

v4.1.1 is the security hardenings and wallet-UX enhancements from v4.1.0. Full notes are in CHANGELOG.md under 4.1.1.

  • Native keyshare storage, ceremony tokens, and LAN/Nostr checks are tightened.
  • Testnet4 uses the same provider checklist as mainnet, in its own pool.
  • The home header pill shows a Wi-Fi icon with Connected (+N) or Disconnected, where N is the number of active providers.
  • Sync, send fees, and transaction details are tighter.
  • A wallet created on this device indexes only the first receive and first change address until one is used. Restore still walks the full gap.
  • Broadcast waits are bounded, the change index reconciles after restart, and airgap or share import no longer starts the ceremony while the camera is still in front.

bbtc added 30 commits October 24, 2026 01:56
Wave 1 (B1 = F1+F2+F16) remediation tracker and 15-case regression test plan. T1-T3 cover this wave.
New BW2 backup format: Argon2id(t=3,m=64MiB,p=1) KDF with random salt, AES-256-GCM with header AAD, versioned magic header, fail-closed tamper rejection. Legacy CBC+SHA-256 backups import read-only with an in-app re-export warning. KDF/AEAD run in Go; JS passes the password, never a derived key. AesEncrypt/AesDecrypt untouched (LAN transport shares them).
VerifyKeyshareBinding inside ImportKeyshare asserts pub_key == hex(GroupKey()) and present suite labels match the share ciphersuite; same assertion at all four caller sites (nostr.go, lan.go, keysign.go x2). Kills the backup-tamper fund-diversion path.
Export button now uses isBackupExportAllowed (full 5-rule isValid) instead of strength>=3; removes the misleading MIN_STRENGTH gate. Note: handleBackup already enforced full policy, so this aligns the gate with enforcement (Low).
Release tasks now throw instead of silently falling back to the debug keystore when release.properties is incomplete. Debug builds unaffected. (Shell-script hardening lives in gitignored scripts; applied on disk only.)
Tighten header-declared Argon2id caps to m<=256MiB/t<=10/p<=4, enforced before KDF invocation, with immediate-rejection + boundary tests. Legacy content failures now read 'Wrong password or corrupted backup'. Tracker: F1b row, F15-still-Critical, F11 scope expansion, timing + .sh notes.
Rotate prod keystore password (new value in untracked android/release.properties only). Scripts take KEYSTORE_PASSWORD/KEY_PASSWORD from env (bundle script also accepts args), no defaults. Drop dead hardcoded GPG_PASSWORD. .gitignore exception + BUILD-SECRETS.md document the contract.
<100ms bound proves the param cap runs before Argon2id; measures ~10ms.
- KeyshareJSONFromHandle via libtss.ExportKeyShareSecure + Destroy()
- BbmtFree memsets tracked allocation before free (internal/cstrwipe)
- Remove defeated withZeroed staging wrappers on iOS (Android in F3 commit)
- Pin backup-export opacity: password in, BW2 blob out, never parsed
- share_b64 still crosses JS on persist/import/read -> F15b open
Shared generated artifact, one file per package by construction:
- Android .java: ESP StrongBox-first, typed fail-closed errors, no fallback
- iOS .m: keyshare-scoped WhenPasscodeSetThisDeviceOnly + typed rejects
- F4 evidence harness (sources + README); fresh-install apply proven
- SecureStorage.kt: typed errors, StrongBox-first to TEE, fail-closed
- security-crypto 1.1.0 stable; no per-read auth (backgrounded reads)
- JS: secureStorageErrors mapper + alerts + fail-closed persist rewrite
- Also carries the F15 call-site unwraps on Android (same file)
- B1 rows -> Done (merged c6d2613); F15 Partial + new F15b row
- wave2-pr-notes.md: bridge audit, failure matrix, evidence, deviations
…etry, wave reorder

- F15b High -> Critical; F4 notes biometry deferred to F15b
- New F18: Android nostrJoinKeysign takes keyshare from JS (iOS native)
- Wave 3 = F15b+F18+F10; Wave 4 = F11+F6
- Dev keyshare Copy JSON -> Copy metadata (npub/pub_key/suite only)
- DiceEntropySheet: remove raw-rolls clipboard copy (optical-only now)
- formatKeyshareMetadataForCopy + 5 tests incl. source guardrails
…EncryptStoredKeyshare

Zero callers repo-wide (re-verified at removal; no WebView/eval/
reflection path). Go BbmtAes*/TssAes* stay for native use. Both
platforms compile; jest unaffected.
- internal/keyhandles: single-slot staged bytes, IDs, metadata allowlist
- dkls: ImportBackupStaged (B1+F2), StageKeygenOutput, FinalizeStaged
- cgo: BbmtImportBackupStaged/FinalizeStaged/Destroy(All)Staged
- DKG cgo outputs staged ({handle,metadata}); guardrail test
- bbmt_tss_jni.cpp + DklsNative.kt wrappers x4
- BbmtBridge.h/.mm + TssShim.swift x4 (Swift rename adjusted)
- Mobile libs rebuilt (android .so x3 ABIs, ios xcframework);
  libbbmtmobile.h is build-generated, not committed
- Android: AesGcmEnvelope (pure) + KeyshareEnvelope (Keystore key,
  ESP staging-protocol migration, F3-mapped errors)
- iOS: KeyshareEnvelope.swift (CryptoKit + Keychain, F4-class writes)
- Tests: JVM 5/5, swiftc harness PASSED, instrumented x5 (run at 3a-5)
- cgo BbmtProjectMetadata/RedactedStructure + JNI/ObjC vertical
- loadKeyshareJSONFromRNES de-envelopes + self-heals (both)
- A6/A7/A8/A9/A10 both platforms; A3/A4 staged-shape guards
- Tripwire refuses share bytes at every new resolve; DKG log
  values replaced with lengths; F19 comments at GG18 methods
- NEW persistStagedKeyshare(handle): native finalize/envelope/
  verify, JS mirrors metadata only; legacy persist kept for GG18 (F19)
- verifyWalletKeysharePersisted/hasWallet: native presence, no blob
- saveKeyshareMetadata takes meta objects; backfill/enrich via A8
- getKeyshare removed; getKeyshareSecureMetadata added
- keyshareImport via A6 {handle,metadata,legacy}; B1 error-text
  mapping preserved incl. legacy wrong-password copy (Go now emits
  the flavor text); finalize persists staged handle
- Remove backupDecrypt chain: Go export, JNI wrapper, DklsNative,
  ObjC bridge, shim, both bridge methods
TssProvider.mpcTssSetup/nostrMpcTssSetup now return SetupResult:
DKLs resolves to {kind:staged, handle, metadata} parsed and
validated by parseStagedSetupResult (fail-closed on malformed or
legacy-shaped output); GG18 resolves to {kind:legacy-gg18, json}
on the explicit F19 plaintext branch.

Both setup screens branch on kind: staged persists via
persistStagedKeyshare (native finalize+envelope+verify, metadata
mirror in JS); legacy keeps the full-JSON persist path verbatim.
pendingKeyshareRef holds SetupResult; legacy-derivation flags now
take parsed field objects (projection covers all inputs).
Staged handles are wiped best-effort on abort, finished-abort,
and new-run reset via discardPendingKeyshares (never throws).

Tests: 7 dispatch + 4 invoke/discard; suites 40/40 green.
chaincodeReader now sources chaincode from native-projected
secure metadata (getKeyshareSecureMetadata, §3 superset with
chain_code_hex) instead of the raw RNES blob; exported names
kept stable so 12+ WalletService derivation sites are unchanged.
Migrated the last getKeyshare() callers (both contexts, Device,
PSBT), WalletHome's two direct blob reads, and wired the dev
tree view to getKeyshareStructureRedacted (A9: secret leaves
replaced natively, shape preserved). Zero raw keyshare-blob
reads remain in JS.

alertSecureStorageError dedupes repeats of the same message
(10s window, still returns handled) so derivation batches show
one alert per outage, not one per call.

Tests: 5 secure-reader + 2 dedupe; suites green.
Moved discardPendingKeyshares to light services/keyshareHandles
(re-exported from the orchestrator so setup screens and tests
are untouched) and wired it into the three remaining delete
paths: wallet reset (Settings), Showcase remove, and WalletHome
unfinished-keyshare clear. Together with the C4c abort wiring,
all five RNES delete sites now wipe Go-staged entries.

Tests: keyshareHandles suite (wipe + never-throws). 19/19 green.
Native ensureKeyshareEnveloped on both platforms (idempotent
§1.5 migration, resolves {migrated:bool}, [SECURE_STORAGE_*]
rejections). App bootstrap runs it before route resolution with
a blocking 'Securing your keyshare…' overlay; first migrated
launch shows the Q2 one-time backup prompt with an Open backup
action (Settings backup section via existing expandSection).
Failures are non-fatal (reads self-heal inline) but alert.

Also fixes iOS bridge registration the 3a Swift methods were
missing: adds 6 RCT_EXTERN_METHOD lines (import/persist/read/
redacted/destroy/migrate — without these the methods are
invisible to JS at runtime) and removes the 4 stale externs of
the 3a-2 deleted oracles. Swift↔extern parity now exact both
ways (61=61).

Tests: 6 migration-service cases. Android compile+unit GREEN,
iOS workspace build SUCCEEDED.
bbtc and others added 21 commits October 25, 2026 20:36
sha256 over canonical decoded PSBT bytes (paths/metadata included,
not just unsigned tx) — the value bound into the ceremony token.
PsbtConfirmation returns ParsePSBTDetails JSON + digest + network
atomically over the same bytes; the native confirmation sheet
renders only from this, never from JS-supplied values.

Tests: digest determinism (whitespace variants), full-artifact
binding (different outputs -> different digest), confirmation JSON
shape, garbage rejection.
requestPsbtSignCeremonyToken: Go parses the PSBT
(bbmtPsbtConfirmation), native AlertDialog sheet renders
outputs/fee/network from that JSON only, Confirm -> OS prompt ->
mint sign token with the Go digest bound (CeremonyTokens
boundDigest). Cancel/dismiss mints nothing.

All four PSBT sign methods (GG18 LAN/Nostr, DKLS LAN/Nostr)
recompute the digest over the actual PSBT (PsbtDigest, byte-
identical canonicalization to Go) and reject
CEREMONY_PSBT_MISMATCH + revoke the session on divergence.
Unbound (legacy) tokens unaffected.

JNI: BbmtPsbtConfirmation plumbed like BbmtPsbtIdentityHash;
native artifacts rebuilt (F28 stamp refreshed).

Tests: F20PsbtBindingTest 6/6 (Go-vector parity, spoof
confirm-A/sign-B revokes, unparseable fails closed, legacy
unbound ok); full JVM suite green; assembleDebug green.
requestPsbtSignCeremonyToken: Go parses the PSBT
(TssPsbtConfirmation via BbmtBridge), native UIAlertController
sheet renders outputs/fee/network from that JSON only, Confirm
-> LAContext auth -> mint sign token with the Go digest bound
(CeremonyTokens boundDigest). Cancel mints nothing.

All four PSBT sign methods (GG18 LAN/Nostr, DKLS LAN/Nostr)
recompute the digest over the actual PSBT (PsbtDigest,
canonicalization identical to Go/Kotlin) and reject
CEREMONY_PSBT_MISMATCH + revoke the session on divergence.
Unbound (legacy) tokens unaffected.

xcframework rebuilt via build-dkls.sh ios (BbmtPsbtConfirmation
export). xcodebuild build SUCCEEDED (build-for-testing blocked
only by the known hermes rsync sandbox denial).

Parity evidence: standalone swiftc run of the app
PsbtDigest.swift against the shared Go/Kotlin vector
(417228a4...) PASS; harness kept in f20-harness/.
PSBT sign wrappers (GG18/DKLS, LAN/Nostr) mint via
requestPsbtSignCeremonyToken(session, psbt); send wrappers via
requestSendSignCeremonyToken(session, payment). Both native
methods show the fused confirmation sheet and bind the token;
declining/unparsable never reaches a native sign call.
PSBTModal marked preview-only (native sheet is authoritative).

Tests updated to the new minters (same lifecycle assertions:
token last, complete on success, release on failure, owner
passthrough mints nothing); new F20/F20-1b negative tests:
invalid PSBT never signs, declined sheet never signs/sends.
requestSendSignCeremonyToken: native sheet (Confirm payment,
address-prefix network banner) -> OS auth -> sign token bound
to sha256 of canonical payment string v1|to|amount|fee|change
(SendBinding, identical Kotlin/Swift). All four send keysign
methods per platform (GG18/DKLS x LAN/Nostr) recompute over
their actual arguments; mismatch rejects CEREMONY_SEND_MISMATCH
and revokes the session. Unbound legacy tokens unaffected.

Tests: F20SendBindingTest (shared vector fb135146..., field
sensitivity, confirm-A/pay-B revokes, fee-inflation fails);
Swift parity via swiftc harness PASS; xcodebuild SUCCEEDED;
Android JVM suite green.
f4CeremonyAuth pins promptAndMint callers so new mint paths are
reviewed, not silent. The two F20 confirmation sheets (PSBT +
send) are the reviewed additions; count updated with the review
note. iOS single-mint-inside-prompt assertion unchanged and
still passing.
fetchDynamicAPIEndpoints/fetchDynamicNostrRelays now fetch from
a pinned commit (b94706d4...; reviewed content: 7 mempool
mirrors, 5 relays) instead of the moving main branch — a list-
repo compromise cannot redirect mempool traffic or MPC relays
until a pinned update ships in an app release.

Hardening alongside: 64KiB body cap, 32-entry cap, https +
mempool-hostname validation for APIs (hostname, not substring:
evil.com/?x=mempool fails), wss: + host validation for relays,
cached-relay re-validation. Hardcoded fallbacks unchanged.

Tests: f35DynamicLists 6/6 (pinned URL asserted, malicious
content dropped, oversize -> fallback, poisoned cache
re-validated).
RecommendedFees validates every tier value before return
(1..10000 sat/vB; absurd endpoint values are skipped, all-
absurd fails closed) — a malicious mempool mirror can no
longer inject an unbounded rate into tx building. Cap is ~5x
the worst observed mainnet spike, documented at the const.

checkFeeVsAmount backstops the spend paths: MpcSendBTC,
MpcSendBTCWithUTXOs, and the calculateFees estimator reject
a total fee exceeding the payment. F20-1b already displays
the fee natively; this is the native-layer backstop.

Tests: f31_fee_cap_test.go — pure bounds, httptest endpoint
skip/fallback/all-absurd, send-path rejection before any
network work.
LAN: relay gains POST/GET /groupkey/keygen/{session} (in-repo
relay, same storage pattern as completions); tss
exchangeGroupKeyConsensus posts own group-pubkey hex and
requires unanimity across parties before the share is used.
dkls/lan.go runs it after the completion barrier; mismatch
returns DKG_GROUP_KEY_MISMATCH and the share is freed, never
returned or persisted.

Nostr: SessionCoordinator.PublishGroupKey (keygen-groupkey
phase) + MessagePump.SetOnGroupKey collector; runNostrDKG
publishes, collects with a bounded wait, and fails closed on
divergence. Empty content never invokes the callback.

Compat (old-version relay/peers predate the check): missing
endpoint, timed-out peers, or failed publish warn and proceed
— the check guards honest divergence (lockout), and mixed-
version ceremonies must keep working. Mismatch always fails
closed.

Tests: 6 LAN (round-trip, garbage 400s, match, mismatch typed,
old-relay proceed, timeout proceed) + 4 nostr-compare/timeout
+ pump groupkey callback; LAN duo/trio + nostr duo DKG
loopbacks pass with the check live.
Verified public derivation on all three mirrors (Go, JS,
legacy fallback); impact assessment (metadata/DoS, share
theft needs a second protocol-level break); impossibility
argument for a compat-preserving fix; options A (SAS/ECDH),
B (ECIES-ify trio), C (documented residual, re-rate Medium).
F32 stays Open pending sign-off.
Single WebView in the app (verified by grep). originWhitelist
['*'] -> ['about:blank'] (the inline document's own origin; no
baseUrl set), file access props off. JS stays enabled with a
citing comment: the ticker is requestAnimationFrame-driven, not
CSS-only, so disabling JS would kill the animation (the
tracker's CSS-only suggestion was disproven against the
script block). Nav gate + onOpenWindow->external unchanged as
second layer.
…330/175)

The frameIntervalMs assertions (330/280/200) never matched the shipped
UR_AIRGAP_SPEEDS values; both shipped together in v4.0.6. Align the test
with production behavior; no source change.
…actions

Client-side signals only (no backend for attestation verdicts): native
getDeviceIntegritySignals on Android (test-keys/su-binary/root-manager
packages/emulator, always-resolves JSON, fail-open) and iOS (jailbreak
artifacts/sandbox-escape probe/suspicious dylibs incl. Frida/simulator).
JS confirmDeviceIntegrityForSensitiveAction gates backup, airgap export,
delete, dev-mode enable, and keyshare inspect with a Cancel/Continue
warning; unknown detection fails open. App unlock and signing ungated
(F20 native confirmation covers sign-time). Tests: deviceIntegrity 8/8;
neighbors ceremonyAuth+f4CeremonyAuth green.
… + F38 + F36)

Includes feature/hardenings ancestry: Wave 5 (F28/F25/F12/F13/F14-A/F24/F27/F26),
v4.1.1 toolchain (Go 1.26.10 rebuild, F41 ecies v2.0.8, F37 1MiB cap, axios
removal) and changelogs. Wave 5 branch tip fully contained, merged herein.

QA: jest focused suites green (deviceIntegrity 8/8, urBytesQr 13/13,
ceremonyAuth + f4CeremonyAuth); build-all.sh + verify-native-artifacts PASS;
compileDebugKotlin + iOS simulator build green. Device QA waived per owner
go-ahead (test ok).
A newly created wallet indexes only its first receive and change addresses. The home header shows Connected with the active provider count.

Co-authored-by: Cursor <cursoragent@cursor.com>
boldbitcoin and others added 2 commits October 28, 2026 00:23
govulncheck could not load BBMTLib because the sibling libtss replace and cgo toolchain were only set up on the test job.

Co-authored-by: Cursor <cursoragent@cursor.com>
Checkout cleanup ran git submodule foreach and failed because the libtss gitlink had no URL.

Co-authored-by: Cursor <cursoragent@cursor.com>

@kar1timmins kar1timmins left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@boldbitcoin
boldbitcoin merged commit 6bd1bee into main Oct 29, 2026
4 checks passed
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.

3 participants