Repository navigation
V4.1.1 enhancements & hardenings - #83
Merged
Merged
Conversation
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.
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.