Skip to content

v4.1.0 Feature/dice-rolls - #82

Merged
boldbitcoin merged 9 commits into
mainfrom
feature/dice-rolls
Sep 28, 2026
Merged

boldbitcoin merged 9 commits into
mainfrom
feature/dice-rolls

Conversation

@boldbitcoin

Copy link
Copy Markdown
Contributor

Summary

  • Add opt-in dice-roll entropy for the master chaincode on LAN and Nostr (duo/trio). Rolls stay on each phone; skip path keeps today’s session seed/nonce chaincode.
  • Enforce a short dice check without putting the master chaincode on the wire: LAN compares dice_ tags at Start/Join; Nostr binds the local check into the session id. Mismatch aborts or prevents peers from finishing together.
  • Harden derivation after metadata blanking: read chaincode from the encrypted keyshare for Receive, watch-wallet descriptors, and related paths. Polish pairing, finish-step, and Device export UI.

…r and clearer.

Paste or Scan skips Enter and lands on Confirm; D6/D20/Coin use a tap-only board with bounce, pips, and chips. Pairing shows the spoken check code on the dice row, drops the tall peer-code card, and puts Relays beside Device entropy with the backend in the Setup Wallet title.
Co-authored-by: kar1timmins <30006896+kar1timmins@users.noreply.github.com>
@kar1timmins

Copy link
Copy Markdown
Contributor

LGTM

@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 92362c6 into main Sep 28, 2026
2 checks passed
@boldbitcoin
boldbitcoin deleted the feature/dice-rolls branch September 28, 2026 14:32
boldbitcoin added a commit that referenced this pull request Oct 29, 2026
* docs(security): add B1 audit tracker and regression test plan

Wave 1 (B1 = F1+F2+F16) remediation tracker and 15-case regression test plan. T1-T3 cover this wave.

* fix(security): F1 versioned backup format (Argon2id + AES-GCM)

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).

* fix(security): F2 verify imported pub_key against share group key

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.

* fix(security): F16 gate backup export on full password policy

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).

* build: fail closed on release without signing config (F7-residual)

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.)

* fix(security): F1b cap backup KDF params pre-derive; legacy UX wording

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.

* build: version release scripts with env-only secrets (F7-residual)

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.

* test: tighten F1b assertion to prove pre-KDF rejection

<100ms bound proves the param cap runs before Argon2id; measures ~10ms.

* F15 (partial): SecureBytes export, C-string wipe, bridge theater removal

- 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

* F3/F4: patch-package RNES (fail-closed ESP + keyshare Keychain class)

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

* F3: fail-closed Android storage + JS surfacing

- 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)

* Wave 2: tracker + PR notes (F15 partial, F3/F4 done)

- B1 rows -> Done (merged c6d2613f); F15 Partial + new F15b row
- wave2-pr-notes.md: bridge audit, failure matrix, evidence, deviations

* Wave 2 review: F15b Critical, F4 biometry-deferred, F18 keysign asymmetry, 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

* Wave 3 Phase A: F15b+F18+F10 design for review (no impl)

* Wave 3 pre-Phase-B: split resolutions, F19/F20, token ≤10min, migration spec

* F10-clipboard: metadata-only copy, dice copy removed

- 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

* F10-clipboard: fix eslint-disable comment syntax

* F10 (3a): remove dead oracle bridge methods aesEncrypt/aesDecrypt/aesEncryptStoredKeyshare

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.

* F15b-1: Go staged-handle registry + import/persist orchestration

- 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

* F15b-1: JNI + ObjC plumbing for staged-handle cgo exports

- 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

* F15b-1: at-rest envelope primitives + migration protocol + tests

- 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)

* F15b-1: native A6-A10 + choke-point de-envelope + resolve tripwire

- 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

* F15b-1: staged persist + metadata core (C4/C10)

- 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

* F15b-1: staged backup import (C3) + backupDecrypt removal (A5)

- 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

* F15b-1: staged DKG dispatch + setup consumers (C1/C2)

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.

* F15b-1: migrate read consumers to secure metadata (C5/C6/A9)

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.

* F15b-1: staged-handle destroy on all delete flows (C8/A10)

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.

* F15b-1: envelope migration bootstrap + iOS extern parity (C7)

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.

* fix(android): provider-generated IV in AesGcmEnvelope.seal

AndroidKeyStore with randomized-encryption-required rejects
caller-provided IVs on encrypt ('Caller-provided IV not
permitted'), breaking on-device envelope seal. Init without
GCMParameterSpec and read back cipher.iv instead — SunJCE and
Keystore both generate a random 12-byte IV in this mode, with a
fail-closed length check. Caught by EnvelopeInstrumentedTest;
JVM 12/12 + instrumented 5/5 re-verified.

* fix(go): literal redaction markers in RedactedStructure output

json.Marshal HTML-escapes < and > by default, so the wire bytes
carried \\u003c-redacted...\\u003e instead of the documented
literal markers. Encode with SetEscapeHTML(false); consumer is
JSON.parse, not HTML injection. keyhandles 5/5 green.

* 3a-5: L1 bridge-spy suite + evidence close-out; add F21

New __tests__/wave3a-bridgeSpy.test.ts (11/11): per-flow bridge
recording over every closed 3a flow with a known share fixture —
setup LAN+Nostr, orchestrator pass-through, import, persist
(nsec-arg exception documented), read/metadata, dev-view
redacted + source guardrail, delete, migration. GG18 skipped
with explicit kind-tag assertion (F19 cite).

keyshareMetadata.test.ts: stub ESM-only op-sqlite so the suite
collects (19/19; failed identically at Wave 2 base).

Tracker: F15b-1 + F10-clipboard green with 3a-5 evidence; new
F21 (pairing nsec retained in JS state after finalize, Med,
non-blocking); staged-path evidence class noted as grep, not
disk-write instrumentation.

* F21: drop pairing nsec JS references after finalize succeeds

localNsec state (set once at keypair generation, never cleared)
and pendingKeyshareRef.partyNsec (nulled only on new-run/abort)
kept the full pairing nsec in JS for the screen's lifetime,
past its last use. ensureNostrKeyshareStored — the choke point
both success paths share — now clears both after the persisted
flag is set. Safe: DKG + finalize already consumed it, signing
takes no nsec. Honesty: dereference (GC), not heap wipe — JS
strings are immutable. Guardrail suite 3/3; tracker Done.

* 3b-0: reconcile design doc conflicts at kickoff

- §6.2: 10-min token expiry wins ('30-min' was a doc bug).
- Table B: B19 cancel REVOKES session token natively; new
  B20-B21 retain + token-gate keypair generators (keygen
  class, same ceremony/session as DKG).
- Table C: note removals landed in 3a ('(3b)' stale).
- Table D: strike ungated-cancel bullet (superseded by B19);
  keypair bullet revised to retain + gate.
- Tracker: F10-bridge acceptance append; Wave 3b section.

* 3b-1: ceremony-token core (mint/verify/release natively)

CeremonyTokens.kt + CeremonyTokens.swift (pure, mirrored):
state machine minted->active->used|expired|revoked, native
class+session binding (no reason oracle), <=10min TTL,
single-live-session mint (new ceremony supersedes),
cancel->revokeSession. Bridge: requestCeremonyToken (OS
prompt first — BiometricPrompt/LAContext biometric-or-
credential; no-lock refuse; mint only on success),
complete/release, DEBUG-only Test mint (Swift #if DEBUG
stripped, proven by Release build; Kotlin BuildConfig gate,
disclosed). JS ceremonyAuth wrapper + typed errors.

Evidence: JVM 12/12, ceremony-harness PASSED, jest 8/8
incl. malicious-mint simulation + Test-hook guardrail,
extern parity exact, Debug+Release iOS + Android compile
green. Backgrounding: NO revoke on background (foreground-
service keep-alive is legitimate; expiry bounds).

* 3b-1b: per-ceremony mint replaces generic request; drop transport class

A generic 'request token for class X' bridge method is the same
vulnerability one level up (JS chooses class + prompt text).
Replaced with five per-ceremony entries (sign/keygen/export/
import/config), each hardcoding its op class and prompt copy;
shared worker is private, not JS-reachable. Transport needs no
mint: fetchData/publishData verify against the parent ceremony
token (sign|keygen) + session — one token per ceremony, no
confusion mode; TRANSPORT removed from both enums.

Tests: jest 12/12 (incl. no-generic-mint native scan +
per-op forwarding), JVM 24/24, harness PASSED, extern parity
exact, Android + iOS Debug green. Design §6.2/B13-B14 updated.

* 3b-1c: TTL bounds ceremony start, not duration + tracker notes

verify() enforces expiry only for MINTED tokens; ACTIVE
ceremonies never die mid-sign from token expiry (a
state-consistency bug). Class/session binding still enforced
when active; duration bounded by complete/release/cancel,
supersession, process death. JVM 13/13 (new
TtlBoundsStartNotDuration), harness mirrored.

Tracker: TTL scope note, mint-supersession bounded-DoS note,
biometric-upgrade note, timing-flat-verify note.

* 3b-1d: compile-time elimination for Android test mint

Move the prompt-bypassing mint out of the main module into a
debug-source-set module (CeremonyTestModule/Package),
registered reflectively so release compiles without it.
Release classes verified: CeremonyTokens present, no
CeremonyTest*. Matches Swift #if DEBUG (Release build
green). Removes the BuildConfig runtime gate + its JVM test;
jest guardrails now pin location (#if DEBUG regions, debug
source set only) and forbid shipped-JS references (14/14).

* 3b-2a: token-gate the 4 DKLs sign wrappers (B5-B8)

JS: withSignToken scope in TssProvider — mint-or-receive a sign
token (OS prompt on mint), complete on success, release on
throw; owner-provided tokens pass through untouched. Calls
without an MPC session (sends, nostr) get a fresh single-call
ceremony ID. GG18 branch stays untokenized (F19 gap, tested).

Native: gate FIRST in all 4 dkls sign methods both platforms
(Kotlin CeremonyTokens.verify + verifySingleCall for
session-less calls, shared CEREMONY_AUTH_REQUIRED reject;
Swift guard + .m ceremonyToken params). verifySingleCall
enforces opClass + TTL-start + revocation where session
equality is inapplicable.

Also: complete/release evict the JS session map in finally,
so a native failure cannot leave a stale reusable entry.

Evidence: tssSignTokenGating 8/8, JVM CeremonyTokensTest
15/15, ceremony-harness PASSED, iOS Debug + Android
release compile green, tsc 32 pre-existing, eslint clean.
Full jest: only pre-existing failures (2 suites, identical
at HEAD; 4 libtss-submodule environmental).

* 3b-2b: token-gate DKLs setup + keypair generators (B9-B12, B20-B21)

JS: setup wrappers mint-or-receive keygen tokens (strict
session); new TssProvider.nostrKeypair/eciesKeypair wrappers
(single-call keygen ceremony each — pairing precedes DKG,
so no token sharing; each mint has its own OS prompt). Both
screen call sites migrated; zero direct native callers
remain. withSignToken generalized to withCeremonyToken with
the mint fn pinned per call site (still no generic minter).

Native: keygen gates in dklsMpcTssSetup/dklsNostrMpcTssSetup
(strict) + nostrKeypair/eciesKeypair (verifySingleCall),
both platforms + .m params. Gate FIRST everywhere.

Evidence: tssKeygenTokenGating 8/8, JVM 16/16, harness
PASSED, iOS Debug + Android release green, tsc 32
pre-existing, eslint 0 errors. Spy/dispatch mocks extended
for the keygen mint.

* 3b-2c: gate LAN handshake transport on a handshake ceremony token (B13-B14)

Design correction: the only JS transport callers are
pre-ceremony LAN pairing signaling; in-ceremony MPC
transport runs inside Go, so no parent token exists at these
call sites. The handshake IS the ceremony: initSession mints
keygen (setup) or sign (send/sign) after validation, owns it
across publish + fetch polls, completes before each return,
releases on throw; cancel/no-lock pass through actionable.

Native verifyTransport (sign|keygen + strict session, no
transport class) both platforms + .m params; gate rejects
fail-fast. TssProvider wrappers take REQUIRED token+session
(transport never mints).

Evidence: tssTransportGating 4/4, JVM 18/18, harness PASSED
(66 checks), iOS Debug + Android release green, tsc 32
pre-existing, eslint 0 errors.

* 3b-2d: allowlist-or-token gate for setAPI/setFeeAPIs (B15-B16)

Native CeremonyApiAllowlist both platforms: built-in seeds,
private sole-writer store, pure decide() (allowlisted
applies silently; unknown needs a config token, then
persists best-effort). setAPI/setFeeAPIs gate FIRST + .m
params; pbxproj registers the new Swift file.

JS: TssProvider wrappers (optional token) + apiConfigApply
ensures (silent try; in-app URL confirm + config mint for
unknown hosts; direct mint for settings saves; denial
memory; inflight dedup). All 30 call sites migrated; zero
direct native callers remain. No QR-provided URL reaches
setAPI (network only); legacy custom URLs re-confirm once.

Evidence: apiConfigApply 12/12, JVM allowlist 9/9 + tokens
18/18, harness PASSED (80 checks), iOS Debug + Android
release green, tsc 32 pre-existing, eslint 0 errors.

* 3b-2e: token-gate backup stage + persist on an import ceremony (B17-B18)

Table B COMPLETE. Strict import verify on
importBackupToStagedHandle/persistStoredKeyshare, both
platforms + .m. Import flow shares ONE owner ceremony
across adjacent stage+persist; setup-commit (decoupled by
the backup-check UI) mints its own save ceremony — one
prompt per explicit user action. Wrappers are owner-or-mint
with a both-or-neither half-pair guard; withCeremonyToken
fn also receives the ceremony session for strict verify.

Evidence: tssImportTokenGating 8/8, keyshareImport owner
3/3, JVM 19/19, harness PASSED (84 checks), iOS Debug +
Android release green, tsc 32 pre-existing, eslint 0 errors.

* 3b-3: remove nostrJoinKeysign both platforms (F18) + gate backup export (A12)

F18: bridge methods + .m export + DklsNative wrapper/decl +
JNI impl (Android) + TssShim + BbmtBridge mm/h (iOS) removed
after zero-caller re-verification (static, WebView, eval,
computed dispatch, reflection). Go core stays native-only.
Already-dead dkls-side JNI decl left untouched (out of scope).

A12: backupEncryptStoredKeyshare takes a single-call export
token both platforms; both export modals route via
TssProvider (enforced ceremony auth; no JS biometric gate
existed to replace).

Evidence: f18Removal 6/6, tssExportTokenGating 4/4, JVM
20/20, harness PASSED (86 checks), assembleDebug (NDK) +
iOS Debug green, JNI 65 symbols, tsc 32 pre-existing.

* 3b-4: token-gate cancel x4 with atomic revoke (B19) + F4-auth pins

B19: all 4 cancels take a ceremony token (same op+session);
native cancelConsume authorizes + atomically revokes the
token's session under one lock (no TOCTOU; revoke precedes
the Go broadcast). Sessioned cancels check equality; nostr
global cancels scope revocation to the token's session. JS
prefers the live token (no prompt on abort) with fresh
same-session mint fallback (stuck-cancel; covers GG18);
stuck-mint class threaded from both abort sites.

F4-auth: prompts verified biometric-or-device-credential
with no-lock refuse, mint-on-success-only; pinned by
f4CeremonyAuth source-text suite (single release mint, 5
entries via promptAndMint, DEBUG elimination intact).
isCeremonyAuthRejection hoisted to ceremonyAuth.

Evidence: tssCancelTokenGating 6/6, f4CeremonyAuth 8/8,
JVM 23/23, harness PASSED (98 checks), iOS Debug +
Android release green, tsc 32 pre-existing.

* 3b-5: L5 malicious-JS suite + round-trips + green report

l5MaliciousJs 11/11 over a strict-native simulator: unsigned
and forged direct calls fail closed on every gated method;
cross-class and cross-session replay fail; config evil-URL
and wrong-class fail while seeds pass; no generic minter;
all 19 gated methods carry + verify ceremonyToken on both
platforms; setup/persist/read/sign round-trip leaves zero
live tokens; no background revocation wiring; tokens
memory-only (kill-mid-session recovers nothing).

Tracker: F15b-2/F18/F10-bridge Done (unmerged), Wave 3b
GREEN, manual QA checklist for real-device pre-merge.

Regression: JVM 44/44, instrumented 6/6 on emulator,
harness 98 PASSED, iOS Debug + Release green, NDK
assembleDebug green, tsc 32 pre-existing, full jest 728
passed (same 6 pre-existing failures).

* 3b-review: GG18 live verdict, F10-bridge Partial + F10-GG18 Critical, status wording

GG18 is LIVE (production Welcome backend selector + legacy
keyshares infer gg18, no forced migration): no removal.
F10-bridge Partial pending F19; new F10-GG18 Critical open
row names the 6 untokenized GG18 sign/setup branches (GG18
cancels are gated). F15b-2/F18 reworded to Implemented
pending QA/merge; QA recorded as merge gate with merge
sequence. Allowlist seeds spot-checked read-only; tokens
ephemeral/kill=re-auth; dead JNI decl deferred Low.

* 3b-6: tokenize GG18 sign/setup x6 (F10-GG18/F19 Done), legacy e2e, QA checklist

Gate mpcSignPSBT, mpcSendBTCWithUTXOs, nostrMpcSendBTC, nostrMpcSignPSBT (sign; 2 strict-session + 2 single-call) and mpcTssSetup, nostrMpcTssSetup (keygen strict-session) with class-binding ceremony tokens on both platforms + TssProvider wrappers; existing operation-specific OS prompts. Rewrite pin tests as authorization assertions; add tssGg18LegacyE2E (no-tss_backend fixture) proving backward compat; L5 census 19->25. Tracker: F10-GG18/F10-bridge/F19 Done; 12-item QA gate with exact steps.

* 3b-6b: Go GG18 loopback keysign test + L5 session-index pins

TestF19_LegacyGG18LoopbackKeysign: in-process 2-party DKG + threshold sign over loopback messenger; asserts stored keyshare has no tss_backend and routes GG18; both signatures independently verified with crypto/ecdsa. L5 pins GG18 setup session at args[4] / sign at args[3] by assertion. Tracker evidence updated.

* 3b-6c: log-trace close-out (verified non-secret, no F22)

* 3b-6d: fix v2 file backup write/read (F23 Critical pre-merge blocker)

B1 wrote the ASCII BW2$ envelope with RNFS.writeFile 'base64': $ is not base64, so iOS silently wrote 0-byte files (nil data resolves success) and Android threw. Every v2 file backup since B1 affected; legacy untouched. Fix: write utf8 + fail-closed stat guard (never share empty file); import sniffs utf8 BW2$ passthrough else base64 legacy fallback (readBackupBlobFromUri rename). Opacity encoding pin corrected; 6 new tests. QA item 4 now requires export->verify-nonempty->delete->restore round-trip.

* chore: bump to 4.1.1 (72)

* 3b-6e: silent legacy import + persistent backup nudge (home card + settings)

Legacy import no longer blocks on an alert; sets STALE_BACKUP_NUDGE shown as a Wallet home card (navigates to Settings backup section) + a Settings Security line. Post-migration prompt also sets it so Later persists. Fresh v2 export clears (fail-safe helpers, inside modal for all 3 users). Airgap export does not clear (no completion signal) - noted follow-up.

* 3b-6f: remove backup nudge UX, keep silent legacy import

Reverts 3b-6e persistent nag per product (annoying/meaningless: internal auto-realign + always-newest-format exports cover old keys). Removes flag module, home card, settings line, repo key, nudge tests. Keeps: silent legacy import (no alert) pinned by a no-alert test. One-time post-migration prompt kept.

* testnet4: Go core params + network keys + CLI (1/5)

Local TestNet4Params (testnet3-derived, true Name/Net/Port; btcd v0.24.2 predates upstream TestNet4Params) + normalizeNetwork aliases. _btc_net default testnet4, SetNetwork/UseAPI canonicalize, PubTo* accept testnet/testnet3/testnet4, all chaincfg selections switched, mempool default URL testnet4, scripts/CLI updated, pin tests, TssShim param renames (internal-only).

* testnet4: JS network keys + validator centralization (2/5)

Canonical testnet4 with testnet/testnet3 aliases: hub helpers, validator accepts all spellings (3 call-site mappings deleted), pairing ternaries retargeted (undefined-passthrough preserved), toggle + label, cache migration reads legacy keys/writes canonical rows + normalizes persisted prefs, doc comments.

* testnet4: providers, URL matchers, stored-key normalization, v2 purge (3/5)

testnet4-aware URL matchers (isTestnetApi, failover guard, price strips, header pill+display); getStoredNetworkKey() normalized pref read at 5 state-init sites; LocalCacheMigration v2 (v1 extracted verbatim, legacy keys read/mapped canonical, retired testnet3 row purge across 11 tables). NOTE: NetworkContext refresh now eagerly defaults fresh installs to mainnet (matches every other default).

* testnet4: ceremony allowlist seeds + L5/harness/JVM/API pins (4/5)

* testnet4: provider fixtures, settings defaults, docs + dev scripts (5/5)

* fix(ios): btcAddress/derivePubkey reject on error (Android parity)

iOS resolve() fulfilled the promise with the native error text, so a
failed derivation returned e.g. 'invalid network, options: ...' as the
address/pubkey and JS used it verbatim (mempool URL with an error
string in the address slot). Reject with BTC_ADDRESS_ERROR /
DERIVE_PUBKEY_ERROR instead, matching BBMTLibNativeModule.kt.

* fix(build): skip legacy tss.aar/Tss.xcframework binds by default

Neither app links the gomobile Tss artifacts (single libbbmtmobile
runtime; enforced by androidSingleRuntime test + Xcode link set), and
the iOS bind fails on current Xcode (gobind emits ObjC 'byte' for
uint8 fields). Gate both binds behind BUILD_LEGACY_TSS=1 /
--legacy-tss so build-all.sh is green again.

* 3a-6: record Wave 3a QA RESULT passed (merge gate)

* 3b-7: record Wave 3b QA RESULT passed (merge gate)

* wave4: log hygiene warm-up — relay.go metadata-only message log

Trim %+v of full Message (carried Body ciphertext) to from/seq/hash/
body_len. F22 closed NA (redaction over-match); this is the noted
hygiene follow-up, not a finding.

* wave4 F6: close LAN padding oracle via uniform reject outcome + DELETE nil-safety

Decrypt-fail kept the message (deleted one poll later as already
applied) while tag-mismatch deleted immediately — a relay-observable
keep/delete oracle. All reject classes now DELETE on the same poll;
dummy MAC work keeps timing equivalent. No wire change: the h1 tag
covers plaintext (mpc.go Send), so true verify-before-decrypt /
Encrypt-then-MAC would break cross-version LAN ceremonies.
DELETE path: return on NewRequest error (no Do(nil)), nil-guard
resp/Body before Close. Tests: TestF6_* (same-poll DELETE,
nil-safety, timing-flat), full tss suite green, -race clean.

* wave4 F6 follow-up: track F24/F25/F26 residuals (F23 already taken)

F24 empty-hash fail-open (Med-High), F25 HTTP no-timeout DoS (Med),
F26 timing residual doc (Low-Med). Review-proposed numbers shifted
by one: F23 is the 3b-6d v2-file finding. F6 row amended with
hang-check-only note + DELETE-uniformity/dummy-HMAC verifications.

* wave4 F11-1: Go export seam validation + SecureRandom bounds + panic recovery

New bbmtmobile/validate.go: mustGoStr (nil/8MiB-cap per arg),
mustNonNegative int bounds, recoverBridge (all panics -> typed
error:... instead of aborting the host). Applied to all 66
*C.char-returning exports (scripted); tss.SecureRandom bounded
[1,4096] + RecoverAsError; amounts/[0,21M BTC], isEdDSA 0/1,
pre-params timeout bounded. Tests: f11_seam_test.go via tag-gated
hook (go test -tags bbmt_test ./bbmtmobile/), 6/6 green.

* wave4 F11-2: JNI null + oversize guards on all jstring entry points

New bbmt_jni_check_strings (null or >16MiB -> typed error return)
inserted in all 53 JNI functions taking jstrings (scripted); 12
skipped (no jstring params; hook jobject already null-checked).
Null from Kotlin is no longer JNI-fatal; unbounded copies rejected
before GetStringUTFChars. check-jni-exports: 65 symbols, no dupes.

* wave4 F11-3: Kotlin null-safety + fail-closed transport + setAPI URL shape

@ReactMethod String params nullable + INVALID_ARGS reject (58
methods scripted; JS null previously crashed via intrinsic check).
Fail-closed: publishData/fetchData/listenForPeers/discoverPeers
reject on error (getLanIp keeps empty: legitimate no-LAN state with
handling caller). gateApiUrls validates URL shape first (https any
host; http/ws/wss LAN-literal-only, no DNS; garbage never prompts
nor persists) + error-prefix reject on setAPI/setFeeAPIs results.
JVM: 12/12 incl. 3 new F11 shape tests.

* wave4 F11-4: Swift null-safety + fail-closed transport + setAPI URL shape

@objc String params optional + INVALID_ARGS guard (59 methods
scripted; JS nil previously trapped the bridge). Fail-closed:
publishData/fetchData/listenForPeers/discoverPeers reject on
error (iOS getLanIp keeps legitimate empty). gateApiUrls validates
URL shape first (mirror of Kotlin, case-for-case) + error-to-reject
on setAPI/setFeeAPIs results. dupC propagates nil so Go mustGoStr
rejects instead of empty-coercing. Harness: +47 shape checks green.

* wave4 F11-5a: JS INVALID_URL contract tests + Android instrumented validation tests

apiConfigApply: INVALID_URL rejects fail closed (no prompt, no mint)
for setAPI/setFeeAPIs. Instrumented: JNI null/oversize typed errors
via reflection, @ReactMethod null -> INVALID_ARGS, unreachable
fetchData -> FETCH_DATA_ERROR (fail-closed, no empty resolve).

* wave4 F11-3b: Kotlin fetchData/publishData convert Go errors to rejects

bbmt* wrappers return raw strings (no unwrap); without a prefix
check Go failures resolve as values. Legit payloads (hex/base64/JSON
handshake data, observed URL queries) never start with error:, so
the check has no false positives — and the reject direction is the
safe one regardless. listen/discover intentionally left unprefixed:
peer display names are user-controlled content.

* wave4 F11-5b: L5 window widening + tracker close-out

L5 source-text window 600->1300 (measured max 1162; null guards
pushed verify calls out; property unchanged). F11 tracker -> Done
with evidence matrix + documented residuals.

* wave4 pre-merge: F27/F28 tracking + F24 disposition + close-out notes

F27 error-text-as-value (High, measured 25+34 residual) and F28
build integrity (High) tracked; F24 dispositioned as distinct from
F6 (Done stands). Close-out notes: review-to-tracker number map,
tss flake (monitored), L5 window measured ceiling, emulator arm64
note, QA pending human call.

* wave4 pre-merge: F27 scope amendment (delta-first, F27-GG18 sub-row)

Wave 5 order: (1) iOS/Android delta audit first (9-instance gap,
review said 15 — corrected: 34 vs 25), mirror iOS-fixed
postTx/btcAddress to Android; (2) new F27-GG18 sub-row first (GG18
raw-resolve both platforms); (3) remaining misc.

* docs: fix signed-release.sh working directory in BUILD-SECRETS.md

The script uses relative paths without cd; running it from the repo
root as documented would miss the keystore. generate-signed-bundle.sh
cds internally and stays root-safe.

* wave4: record device QA RESULT passed (merge gate)

* wave5 F28: native source-hash stamp + gradle fail-fast gate (build integrity)

Option (a): build-dkls.sh writes android/app/native-srcstamp.txt after
successful Android builds; gradle verifyDklsNativeStamp (preBuild) fails
fast when cpp/Go sources drift without a rebuild. Covers the Wave 4
stale-.so incident class. CI runs stamp selftest (mechanism only).

* wave5 F25: bounded relay HTTP clients (5s poll / 10s unary)

Shared relayPollClient/relayRequestClient in tss, reused by dkls
lan_pump. 10 call sites (8 GG18 + 2 DKLs) off http.DefaultClient and
bare http.Get/Post. Hanging-relay tests assert exact timeout behavior.

* wave5 F12: exact-match cancel, explicit derived enumeration

Remove prefix matching + 15-min poison window. Multi-input derived
sessions (base+N) register via deriveUtxoSession at 6 call sites;
CancelMpcSession enumerates registered children exactly. Late-derived
and ctx loops observe cancel-before-start via parent link. Token
revocation path untouched (native cancelConsume); JVM + L5 cancel
suites green.

* docs(tracker): F25 + F12 Done (Wave 5)

* docs(tracker): F14 Part B closed as not-a-finding (Part A only)

nsec is a facet of the keyshare secret bundle; backup protection is uniform by design.

* wave5 F13: lifecycle safety proven by test (freshness-by-construction)

No persisted epoch: census proves every session mint is fresh-random
per ceremony (newCeremonySessionID, secureRandomHex-seeded pairing
data, per-attempt SecureRandom(64) + subscribe-only nostr); a
monotonic counter buys nothing over 128+ bits per-mint entropy and
would need a new Go file-access seam. Replay guards are per-call
locals (fresh by construction); ephemerals memory-only (no persist).
Tests: JS mint uniqueness, DKLs stale-replay rejected (commitment
binding), duplicate absorbed, consecutive signs differ, GG18
loopback duplicate delivery completes.

* docs(tracker): F13 Done (Wave 5)

* wave5 F14-A: remove inert SessionKeyHex (option b)

Delete Config field + required-check + 9 literal sites + dead
computations (pre-agreement Sha256, attempt-config param/args, tss
CLI flags). Bridge entry sessionKey params kept (signature
stability; live for LAN in dual-transport funcs). Tripwires +
SecretKeys denylist keep the label as legacy defense. Field was
never read and never persisted: no backup migration needed.

* docs(tracker): F14 Done (Wave 5, Part A only)

* wave5 F24: close empty-hash fail-open; verify DKLs pump (F24b)

Census: zero legitimate empty-hash producers (sender always tags
d1/h1; relay stores verbatim; nostr rejects unconditionally).
mpc.go: empty hash -> uniform same-poll DELETE (F6 outcome
preserved). dkls lan_pump verified NOTHING: add h1/d1 gate via new
tss.VerifyLANPayloadTag/Digest (exact GG18 parity); failures keep
for retry like decrypt-fail (no relay-observable split).

* docs(tracker): F24 Done (Wave 5)

* wave5 F27-1: delta mirror (Android postTx/btcAddress) + F27-GG18 iOS (6)

Android: unwrap bbmtPostTx + 4 pub-to-address wrappers (mirrors iOS
fixes; existing try/catch converts throw to typed reject).
iOS GG18: mpcTssSetup/nostrMpcTssSetup/sign/send x4 reject Go
errors with Android-matching codes instead of resolving error text
as keyshares/signatures. Android GG18 verified already-clean
(all 6 wrappers unwrap + methods reject): F27-GG18 was iOS-only.

* wave5 F27-2: iOS remainder (resolveOrReject gate + 9 ternaries)

Private resolve() helper renamed to resolveOrReject with rejecter
+ code: Go errors now reject instead of resolving error text (15
call sites; compiler-enforced). 9 inline ternaries converted to
if/else reject (Android-matching codes). iOS F27 done: 25/25
(10 helper + 9 ternary + 6 GG18); all other sites audited
fixed-or-N/A (error checks, BbmtBridge prefix checks, staged
guards, static/local values).

* wave5 F27-3: Android remainder (unwrap 23 wrappers)

All value-returning raw wrappers now unwrap (throw on error:
prefix), each verified called inside try/catch (script-audited 23/23
+ delta 5/5). Throws convert to the methods' existing typed reject
codes. Remaining raw wrappers are all method-layer-fixed
(inline prefix-check) or dead (aes x2, helloDkg) / void
(listeners). Android F27 ground truth: 28 fixed this wave + 14
method-layer + 6 GG18 pre-fixed; census-34 delta explained
(GG18 was already clean).

* wave5 F27-4: catch-resolve fixes + tests

Android: nostrKeypair/hexToNpub catches rejected (were resolving
e.message); stopRelay/getLanIp resolve constants (not error text,
out of scope, noted). Tests: Go error:-prefix contract (3),
Android instrumented forced-error sample (5), jest source
tripwire (4: no ternary, no old helper, wrappers allowlisted,
no resolve(e.message)).

* docs(tracker): F27 + F27-GG18 Done (Wave 5)

* docs(tracker): F26 documented-residual (Wave 5)

* v4.1.1 F29: Go 1.26.10 minimum (CVE-2026-111943)

go.mod toolchain go1.26.10 (eMudhra intermediates distrusted in
crypto/x509). Dockerfiles (main + fips): 1.26.10 + sha256-verified
download (amd64 ccfe7ae8) + GOTOOLCHAIN=local. libtss/Dockerfile
needs the same in the libtss repo (submodule, separate commit).
Rebuilt: android 3/3 ABIs report go1.26.10 via go version -m;
iOS xcframework both slices 786x go1.26.10 strings, 0x go1.26.0.

* v4.1.1 F30: Node 22.23.4 (Node 20 EOL)

Dockerfile: node-v22.23.4 + sha256 b8492284. engines >=22.23.4.
.nvmrc 22.23.4. No setup-node in CI (no JS CI exists) — nothing to
pin. Verified: npm ci green + jest 752/1 (== main baseline, the 1
failure pre-existing) on v22.23.4.

* v4.1.1 F28-exp: verify-script Go gate + CI pin + RELEASE.md

verify-native-artifacts.sh: MIN_GO=1.26.10 floor on every shipped
libbbmtmobile.so via go version -m (>=, ver_ge), all-ABIs-must-agree
check; iOS .a archives via strings (every go1.x.y found must be >=
floor). Gate proven: exit 1 on forged 9.99.99 floor, PASS after revert;
ver_ge correct on 6 edge cases (1.26.0/1.26.9/1.26.10/1.26.11/1.27.0/1.24.2).

CI: setup-go pinned 1.26 -> 1.26.10 in both workflows + GOTOOLCHAIN=local
(no auto-download drift). No artifact-upload workflow exists on this
track, so the script is a release-machine gate (documented).

RELEASE.md: toolchain minimums table + pre-release gate commands.
F34 remains skipped (manual GitHub release upload).

* docs(tracker): v4.1.1 track close-out + Track B verdicts (F31/F32 NEW)

- v4.1.1 section: F29/F30/F28-exp done, F34 skipped, version check
  (4.1.1/72 already set), full suite results, op-sqlite androidTest
  non-blocker note
- Track B: F31 CONFIRMED Medium (no fee cap, btc.go:233/1392/1191),
  F32 CONFIRMED High (public-derived trio key, lan_crypto.go:9-13),
  F33 confirmed-on-main but fixed by Wave 5 F24b (no action),
  F5 NOT DONE (already tracked as F8 Open)
- New table rows: F31, F32 (Wave 6+)

* docs(tracker): fix F5/F8 numbering collision in v4.1.1 note

Track B prompt's 'F5' (cleartext verification) collided with tracker F5
(nsec finding). Cleartext is tracker F8 Open. Corrected the note and
recorded the disposition: known residual for v4.1.1 release notes —
cleartext http://LAN-IP is load-bearing for LAN pairing and IP literals
can't be domain-config scoped, so the fix needs Wave 6/5.5 work.

* docs(tracker): reconciliation gap table F35-F41 + npm/govulncheck

All six review-named gaps confirmed in source, all untracked -> numbered
F35 (GitHub-raw hosts, High), F36 (device integrity, Med), F37 (intent
hardening, Med), F38 (WebView, Low), F39 (post-DKG cross-check, Med),
F40 (DKLs refresh unavailable, Med). Scans: npm 18 vulns, none
runtime-reachable (axios unused -> hygiene removal); govulncheck 1
reachable -> F41 (ecies GO-2023-2380, High, v4.1.1-candidate).
Notes source-report absence as scope limit.

* fix(F41): bump ecies/go to v2.0.8 + explicit go-ethereum v1.17.0 (clears GO-2023-2380 + transitive p2p CVEs)

Option 1 failed (v2.0.9/v2.0.10/v2.0.11 all still transitive-flagged
via go-ethereum 1.13.5/1.14.12/1.15.8). Option 2: ecies v2.0.8 fixes
GO-2023-2380 (invalid-curve key recovery); explicit go-ethereum v1.17.0
require overrides the transitive 1.13.5 and clears GO-2026-4508,
GO-2026-4507, GO-2026-4315, GO-2026-4314, GO-2024-2819.

Gates: go build ./... OK; govulncheck ./... = 0 affected (20 not-called);
go test -race (tss 65s, dkls full 235s, nostrtransport, bbmtmobile
tagged) green. Wire format unchanged: ecies.go v2.0.3->v2.0.8 diff is
only the curve-provider swap (fomichev/secp256k1 -> getCurve());
Encrypt/Decrypt/HKDF identical, so duo interop across app versions
holds.

* fix(F37): cap shared-file peek + PSBT read at 1 MiB, typed reject

SEND/VIEW can deliver any file from any app; the peek copied and read
it whole into JS memory (large-file OOM). copySharedFileWithCap stats
the sandbox copy and throws IncomingFileTooLargeError above 1 MiB
before any bytes are read; classifyIncomingFile rethrows it (no
extension-hint fallback for oversized files), and
showKeyshareImportError surfaces a File Too Large alert. The PSBT
shared-uri read shares the cap. Tests: 5 new TestF37 cases (reject,
no-reclassify, at-cap boundary, PSBT path, Android string size); suite
12/12.

* v4.1.1 hygiene: remove unused axios dependency

axios@1.13.6 was a direct dependency with zero importers in
first-party source (verified: no require/import outside the stale
untracked bundle.js). 35 advisories against it were unreachable.
npm audit (prod) drops 18 -> 15, high 9 -> 6; the remainder are
metro/CLI/devtools build chains (do not ship) and the
@keystonehq/uuid flags already accepted in the reconciliation.

* docs(tracker): close-out dispositions per Step 0 answers

- Q2 F5 -> Partial (F21 mitigates heap; F14-B closes backup half;
  remainder deferred v4.1.2+); Q3 F9 -> Done (subsumed by F15b/F27);
  Q4 stale cells -> F15b-1/F15b-2/F18/F10-clipboard/F10-bridge Done
  (merged + QA passed 2026-10-24); Q5 F16b -> Accepted residual.
- F37 -> Done (f6168bfc), F41 -> Done (0195d75c); NEW F41b (transitive
  go-ethereum pin watch). Fix-waves line + reconciliation follow-ups
  updated to reality.
- build-notes.md: pipe-masked exit-code lesson + xcodebuild sandbox /
  gradle offline / emulator notes.

* docs(tracker): make F8/F20 deferrals explicit in-row

* docs(changelog): prepend v4.1.1 entry

Keep-a-Changelog format matched (## [x.y.z] - YYYY-MM-DD, ### Security
added as the canonical category). [Unreleased] folded into [4.1.1]:
the testnet4 bullet ships in this release, preserved verbatim under
Changed. Security content per tracker with three corrections: F34
manual (not restored), F13 freshness-by-construction (no persisted
epoch), F4 per-ceremony prompt (not per-read biometry).

* docs(security): v4.1.1 reference report

Standalone consolidation report: exec summary (52 items, disposition
tally), findings by category with commit refs, attacker-class deltas,
honest residual list (incl. F41b), per-wave verification evidence with
the xcodebuild/env caveats, audit trail with §8 source-report gap
disclosure verbatim, Appendix A (three independent collisions, not a
swap), Appendix B (NA evidence).

* docs(changelog): add missing v4.1.0 section

v4.1.0 (2026-09-28, tag on 92362c6b) never got a changelog entry.
Reconstructed from the #82 squash commit and docs/DICE_USER_GUIDE.md:
dice-rolls setup entropy (D6/D20/coin), commitment + checksum-tag
exchange with mismatch abort, dice chaincode derivation, pairing
screen rework, log redaction helper.

* docs(changelog): add missing v4.0.7 section

v4.0.7 (2026-08-28, b7120e6d) also never got an entry. Reconstructed
from the #81 commit: LAN keysign party-ID fix (KeyShareN -> DKG id N,
canonical committee order) + QR transport fail-soft in pairing.

* wave6 Phase 0: cross-version compat fixtures + tests (v4.1.0 <-> v4.1.1)

Fixtures generated by the v4.1.0 tree itself (tag 92362c6b): DKLs
shares, trio AES chunk + h1 tag, duo ECIES chunk, legacy CBC backup.
Forward: old shares import under F2 binding and sign verifiably;
old chunks decrypt; real legacy backup imports (Legacy=true).
Reverse (verified, temp tests): current chunks decrypt under v4.1.0
code — incl. ecies v2.0.8 ciphertext under v2.0.3 (F41 format proof).
-race clean. Live mixed-version device ceremony remains Phase 0b
(human devices).

* docs(tracker): gitleaks sweep result + F42 (test keyshares in history)

* docs(wave6): F20 native PSBT confirmation design (for sign-off)

* feat(F20): PsbtConfirmation + full-artifact digest (Go + JNI export)

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.

* feat(F20): Android fused PSBT confirmation + digest binding

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.

* feat(F20): iOS fused PSBT confirmation + digest binding (mirror)

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/.

* feat(F20): JS wiring — confirmation minters for PSBT sign + send

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.

* feat(F20-1b): native send-payment confirmation (Android + iOS)

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.

* test(F20): reviewed mint-caller guardrail 6 -> 8 (F20 sheets)

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.

* docs(tracker): F20 Implemented pending QA/merge (Wave 6 Phase 1 evidence)

* docs(F20): design status implemented + deviations noted

* feat(F35): pin GitHub-raw lists to reviewed commit + strict validation

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).

* feat(F31): fee-rate sanity cap + fee-vs-amount bound (Go)

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.

* docs(tracker): F35 + F31 Implemented pending QA/merge (Wave 6 Phase 2)

* feat(F39): post-DKG group-key cross-check (LAN + nostr, DKLS)

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.

* docs(F32): trio LAN key analysis + decision options (no code change)

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.

* docs(tracker): F39 Implemented pending QA/merge; F32 Open decision-required

* feat(F38): lock quotes-marquee WebView to about:blank, file access 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.

* test(urqr): align airgap interval literals with shipped presets (500/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.

* docs(tracker): F38 Implemented pending QA/merge (Wave 6 Phase 4)

* feat(F36): device-integrity signals + warn-and-confirm on extractive 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.

* docs(tracker): F36 Implemented pending QA/merge (Wave 6 Phase 4)

* feat(v4.1.1): audit remediations and wallet UX enhancements

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>

* fix(ci): check out libtss before the Go vulnerability scan

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>

* fix(ci): register libtss in .gitmodules

Checkout cleanup ran git submodule foreach and failed because the libtss gitlink had no URL.

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

---------

Co-authored-by: bbtc <bbtc@github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: boldbitcoin <boldbitcoin@github.com>
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.

4 participants