U App security posture
What protects the wallet, what we deliberately accept, and what we will not claim.
Nothing here is a promise of impossibility: no software is "unhackable." This is the
honest map as of v0.9.6 (internal security review 2026-08-29; prior passes 2026-08-26
and 2026-08-27 cited inline).
The custody core (strong by construction)
- The BIP39 seed exists on disk ONLY as an AndroidKeyStore (StrongBox/TEE where available)
AES-256-GCM envelope in app-private storage. Plaintext is never written to disk or logs.
- Signing keys never leave native memory. JS holds only opaque session tokens;
lock()
and process death wipe them. storeMnemonic refuses to overwrite an existing seed -
checking BOTH vault generations (a committed v2 vault deletes its v1 blob; a v1-only
guard would let a second phrase be sealed beside the live seed - 8/29).
- Every EVM signer is account-0-bound; registry wallets sign through their stored
derivation path, which is structurally paired to the address shown. Watch-only imports
can never be a from.
- Amounts are computed locally in BigInt from what the user typed. Spoofed balances or
prices cannot produce a wrong-amount send.
- Broadcasts are never retried (XRPL
submitincluded, audit 8/27); the SignSheet
one-decision guard prevents double-tap double-broadcasts.
- Every live sender (foreground send screens, the offline outbox pump, the eTransfer
executor, the dapp bridge) serializes its estimate -> nonce -> sign -> broadcast span
through one cross-sender lock, so no two senders can interleave a nonce read. The dapp
bridge re-verifies the nonce UNDER the lock before signing and consults the offline
queue's reserved-nonce ceilings, as do the online senders (8/29 - a dapp sheet sitting
open for minutes previously raced the outbox pump).
- Address validation is checksum-strict on every family: BTC/LTC/DOGE/BCH (bech32/base58/
cashaddr), XRP/XLM/XTZ/TON/XMR, DOT (SS58 blake2b-512 "SS58PRE" domain-separated),
ALGO (strict base32 + SHA-512/256 checksum), and EVM EIP-55 - a mixed-case address
whose checksum does not verify is rejected at scan, at compose, and at send; the app's
own EVM receive address is displayed and QR'd in checksummed form (audit 8/27).
- Biometric users get a hardware-bound vault: the v2 key requires STRONG biometric
(BiometricPrompt.CryptoObject) per unlock; enrollment changes invalidate it.
- No cleartext anywhere in release builds (network_security_config; the localhost/
emulator holes live only in debug overrides - 8/29). ALL first-party hosts the app
opens a frame on (pay, accounts, api, mobile, buy, send, swap, bridge, portal, una,
ai, agents, names, pos, verify, sell.u.cash) are CERTIFICATE-PINNED to their CA
chains (widened from 4 hosts - the shared SSO cookie makes every one of them an
authenticated surface - 8/29). allowBackup=false + full cloud-backup AND
device-transfer extraction exclusion (schema-correct wrappers - 8/29); FLAG_SECURE
in release.
- Hostile-endpoint bounds on everything the network tells the wallet before it signs
(8/29): fee quotes are taken from TWO independent public endpoints and the cheaper
is kept, with an absolute per-gas ceiling; UTXO sends refuse to sign when the fee
exceeds the amount; XRPL fee quotes are ceilinged; the offline chain-state cache
(nonces, UTXO sets, fee shapes) validates every network answer with the same
predicates persistence uses before caching it.
- The dapp signing bridge is origin-confined to a compiled-in
*.u.cashallowlist,
authenticated per message with a per-mount token only the injected provider can
know (iframes inside an allowed page, and documents that escape the navigation gate
via POST navigation or its 250ms fail-open, cannot forge bridge traffic - 8/29),
re-verified per message against the document actually loaded, and every sign goes
through the SignSheet with decoded calldata, origin, and network labels.
eth_sendTransaction fills nonce/gas/fees BEFORE the sheet (the user approves the
exact numbers that will be signed), surfaces page-supplied gas/fee fields verbatim,
applies the same 2x drift re-gate as the first-party send, and signs+broadcasts in
one lock span (audit 8/27 parity fix, hardened 8/29).
- The SignSheet's raw disclosure is FULL: every calldata byte that will be signed is
on-screen (selectable, with its length) - no truncation (audit 8/27). Calldata
decodes sourced from public signature registries are only trusted when
keccak256(signature) provably equals the call selector - a registry entry anyone
can submit can no longer dress a drainer up with friendly labels (8/29).
- Destroying the vault destroys what the vault produced (8/29): a vault reset (and a
boot with no vault) wipes every on-device store derived from the wallet - signed
outbox transactions, eTransfer claim records, the receive-address pool, address
dossiers, diagnostics - not just the sealed seed.
Native-enforced unlock (2026-08-26 pass, hardened 8/29)
Previously the native layer would mint a session token for unlock/openVault with NO
credential - the JS unlock screen was the only gate, which a compromised bundle defeats.
That structural hole is closed in the native layer itself:
- A session token cannot be minted unless the CONFIGURED lock was verified within the
last 60 seconds (the verification is recorded natively by the PIN/pattern check or the
biometric prompt). The window is single-shot: minting a token (or revealing the seed)
consumes EVERY verification window that could have admitted it - including the
non-crypto biometric step-up (8/29; one biometric prompt previously admitted
unlimited v1 mints plus a reveal).
- PIN/pattern locks: the soft verification the user already types satisfies the gate
(UX unchanged); the token mint requires it.
- Biometric vaults: the v2 seal IS the biometric -
unlockBound(CryptoObject decrypt)
is the only path, enforced natively. The mid-migration state (v2 sealed, v1 blob still
present) can no longer fall back to the zero-auth legacy decrypt.
- Method
noneis the user's documented opt-out: the vault opens for anyone holding
the device. Every other configured lock is enforced in native code.
- The recovery-phrase reveals (12-word and Monero) carry the same native gate and consume
the window. For method none the reveal stays available (a none bundle can already
sign-and-drain, so blocking it adds nothing while holding recovery hostage); the screen
nudges such users to set a lock.
- Lock CONFIGURATION is natively gated too (crown-jewels audit 8/27): changing the lock
method, replacing a PIN/pattern, or resetting the vault requires the same fresh
verification as a seed reveal, and the stored method is enum-validated - a compromised
bundle can no longer flip the method to none, store a lock state no verification can
ever satisfy, or disable erase-after-N (8/29).
- New-wallet creation is one native call (
generateAndStore): the phrase crosses the
bridge once, outward for the mandatory onboard reveal (audit 8/27); entropy is floored
at 128 bits natively. Failed phrase imports return a generic error (a wallet-library
exception can echo the offending word).
- Brute force: NEW PINs require 6+ digits (existing 4-digit PINs keep working and are
nudged to upgrade); wrong-entry lockout doubles from 30s to a 24h cap; an OPTIONAL
"erase wallet after 10 failed attempts" (default OFF) wipes the vault artifacts -
including every AndroidKeyStore seal key (uapp_seed_v1/_v2, uapp_pin_v1) and
the wallet-derived keychain stores - funds stay recoverable with the 12-word phrase.
- Proximity surfaces (BLE handoff, NFC tag emulation, the long-range mesh radio
bridges) are bounds-checked: the BLE holder role only accepts frames from the
session-handshake peer (an unauthenticated MTU request can no longer install itself
as the frame target or inject inbound frames - 8/29), frame buffers are capped
everywhere, and the NFC length encoding is spec-correct.
Offline / cold-wallet trust model (v0.9.5-0.9.6)
- An offline-signed payment is a BEARER packet: the raw bytes can only execute the exact
approved payment. Received packets are shape-validated (family allowlist, whole-byte
hex, chainId must be served, length caps) and deduped/routed by RAW bytes, never the
display txid. The app does not verify a received packet's signature - it broadcasts
as-is, which can only ever lose the sender's money.
- Chain-state caches (nonces, UTXO sets, sequences) are signing MATERIALIZATION, not
truth: a stale cache still signs (the chain verifies at broadcast; balances/UTXOs the
network rejects are parked with honest copy). Every persisted row is hydrated through
the same predicates as fresh entry, future-timestamped rows are rejected, and the
network writers validate what they cache (8/29).
- Handoff rails (QR, relay URL, NFC, BLE courier, BLE mesh, LoRa mesh radio) carry the
same envelope; the relay URL rides the URL fragment so no server ever sees the payment
from the sender's side.
The receive-address rotation (xpub push, v0.9.6)
Linking "fresh receiving address for every payment" exports the wallet's account-level
PUBLIC extended keys (7 families) to the user's pay.u.cash account. This is a permanent
privacy grant by design: whoever holds those keys can derive every receive address that
account will ever use, including after the link is turned off (unlink removes pay's
derivations, but the keys were seen). The app discloses this at the moment of sharing,
never exports private material, only the device's own locally-generated keys can be
pushed, and every address pay claims back is re-derived from the device seed before it
is shown - a lying server's claims render as a warning, never as addresses. The server
engine serializes link/unlink under a database lock so a concurrent sync cannot
resurrect a turned-off link (8/29).
Supply chain (2026-08-26 pass, widened 8/29)
- The vendored wallet-signing library is SHA-256-pinned in the build: any byte change
fails the build before it can ship.
- Continuous-integration jobs reference only GitHub-owned actions, pinned to immutable
commit SHAs (8/29); the CI signs nothing and holds no release material.
- Sensitive persisted services (wallet registry, unsigned send outbox, eTransfer claim
records, verified receive pool) assert Android Keystore-backed storage
(SECURE_HARDWARE) with a never-lose-the-write fallback; no service uses auth-bound
reads (they would prompt at every cold boot).
- Release deploys refuse mock flavors and debug-signed builds. Debug builds carry extra
test trust anchors for live devnet exercise - resource-level isolation keeps them out
of release (verified 8/29).
Accepted risks (deliberate, reviewed)
- Certificate pinning covers the app's own network stack. Android WebView does not
enforce pin-sets: traffic from embedded first-party pages relies on the system CA
store plus the bridge-layer origin and message-authentication laws above. All
first-party hosts are pinned on the native path; public chain-data endpoints (a
multi-party API system of public RPC and explorer providers) are NOT pinned - they
rotate across providers and the standard system-CA posture applies, with the
hostile-endpoint bounds above capping what a lying endpoint can achieve. Pin expiry
(2027-08-31) converts an abandoned install from bricked to unpinned; bump every
release.
- The v1 seed key is not auth-bound for PIN/pattern users. On a ROOTED device the
seed is recoverable from app-private storage regardless of the soft lock; only the
biometric v2 vault is hardware-bound. The lock-method screen says this in plain
language. This is the Android KeyStore trade-off (setUserAuthenticationRequired would
prompt per signing operation).
- The plaintext mnemonic crosses into JS during onboarding/import/reveal (it must be
shown). It lives in React state briefly, is cleared on background/leave, and clipboard
copies are swept after 45 seconds. The reveal is natively gated (above) but a fresh
verification window still exists for ~60 seconds after each unlock. Mnemonics are not
zeroized in native memory between operations (deferred).
- Trust-forward toward the u.cash fleet. Swap/bridge integrity ultimately rests on
swap.u.cash aggregated quotes. The SignSheet decode and the slippage floor bound what
those servers can do silently, but a compromised fleet host remains the major remaining
trust edge. (First-party hosts are pinned; the dapp allowlist is the *.u.cash
registry - the surface is as wide as the fleet.)
- Registry backup syncs wallet metadata (addresses, labels, paths - no secrets) to
the user's pay.u.cash account. By design, for reinstall restore. The receive-address
rotation above is the wider grant of the same kind.
- **The
*.u.cashSSO cookie stays live across wallet lock** (operator-directed): the
app signs out of pay only on explicit user sign-out. On a rooted device the cookie is
replayable while the wallet is locked; the vault itself stays sealed.
- Session tokens carry no TTL by design - the verification window gates minting
only, so a drained-background outbox can finish while unlocked. Auto-lock is a UX
bound, not a security boundary; the native gate is.
- Received bearer packets are not signature-verified (relay/mesh griefing - a
stranger can consume relay-queue slots; never funds).
- NFT images are fetched from URLs in airdropped token metadata (tracking/DoS only;
RN Image never executes content).
- Free-tier third-party API keys ship in the APK for public market and activity
data, part of the same multi-party API system (quota abuse only).
- The LoRa mesh channel key ships in the APK by design (a public community channel:
anyone with the app can read mesh traffic; the packets are bearer payment data, never
keys).
- Reviewed, kept as-is: stale chain-state rows remain signable (the network
arbitrates at broadcast); account-1+ keys are send-gated but not
balance-hidden-by-default; the JavaScript fallback engine flavor is a dynamic
dependency (dormant while the default engine is enabled); first-party broadcast
proxies are open, rate-limited relays by design; the iOS reference signer is a stub
(parity runs on the Android engine).
- No third-party audit. The reviews cited in this document are internal, and we say
"internally reviewed," never "audited." See the claims law below.
App Links
https://pay.u.cash/transfer/<token> is the single verified App Link (Android
autoVerify; the server's assetlinks.json lists exactly the release-signer fingerprint,
verified 2026-08-26). It routes only to the eTransfer claim screen and only while a
wallet is unlocked; it never signs anything.
What we claim - and what we do not
WE SAY: self-custodial by design (keys generated and sealed on device, StrongBox/TEE);
hardware-bound Class-3 biometric lock with NATIVE enforcement; PIN/pattern locks with
6-digit minimum and escalating lockout; optional erase-after-10; offline/cold signing
for every supported coin; certificate pinning across every first-party host on the
app's own network stack; supply-chain-pinned signing library; signer parity executed
on-device in continuous integration against independently derived goldens; internally
reviewed with the findings published here; responsible disclosure program.
WE DO NOT SAY (until separately earned): "audited" or "independently audited" (no
third-party engagement exists), "pentested," "bug bounty" (rewards are discretionary),
"open source" (the repository is private).
Reporting
Security issues: security@u.cash (the fleet's responsible-disclosure channel,
u.cash/legal/disclosure.html; safe harbor applies). Please do not open public issues for
vulnerabilities.