Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ jobs:
bash scripts/flow_mapping_assertions.sh
bash scripts/check_forbidden_symbols.sh
bash scripts/ci_scan.sh
python3 ci/bridge_rpc_names.py

- name: Production safety lints
run: bash ci/production_safety_checks.sh
Expand Down Expand Up @@ -490,6 +491,7 @@ jobs:
bash scripts/flow_assertions.sh
bash scripts/flow_mapping_assertions.sh
bash scripts/ci_scan.sh
python3 ci/bridge_rpc_names.py

- name: Lint
run: npm run lint
Expand Down Expand Up @@ -523,6 +525,12 @@ jobs:
steps:
- uses: actions/checkout@v7

# The bridge RPC names Kotlin handles must be the names the frontend sends;
# this job is what a Kotlin-only change selects.
- name: Bridge RPC name gate
working-directory: .
run: python3 ci/bridge_rpc_names.py

- name: Set up JDK 17
uses: actions/setup-java@v5
with:
Expand Down
145 changes: 145 additions & 0 deletions ci/bridge_rpc_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
# ci/bridge_rpc_names.py: every bridge RPC name the frontend sends is one Kotlin
# handles, and every name Kotlin handles is one the frontend sends.
#
# The bridge between the WebView and Kotlin is a method name on a
# BridgeRpcRequest. Nothing checked that the two sides agreed: `hasIdentityDirect`
# was sent for months and answered by the unknown-method arm, and Kotlin kept
# eight arms nothing sent (2026-09-26, #1009 and its follow-up). A name only one
# side knows is a fake by construction — the frontend wraps it in a default that
# downstream code reads as a measurement, or Kotlin keeps a path nothing
# exercises.
#
# Sent names are read from the frontend's production sources and from the bridge
# object `public/index.html` installs: a string literal passed to callBin,
# sendBridgeRequestBytes, buildBridgeRequest, callBoundaryMethod,
# callBridgeMethod or encodeBridgeRequest, and an upper-case identifier passed to
# callBin, resolved from a `const NAME = '…'` in the same file (unresolved: fail).
# Handled names are the string arms of the `when (method)` inside
# SinglePathWebViewBridge.handleBinaryRpcInternal.
#
# Exit 0 only when both sets are non-empty and equal. There is no allowlist: a
# name one side must stop using is removed from that side.

import os
import re
import sys

ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
FRONTEND_SRC = os.path.join(ROOT, "dsm_client", "frontend", "src")
INDEX_HTML = os.path.join(ROOT, "dsm_client", "frontend", "public", "index.html")
KOTLIN_BRIDGE = os.path.join(
ROOT, "dsm_client", "android", "app", "src", "main", "java", "com", "dsm", "wallet",
"bridge", "SinglePathWebViewBridge.kt",
)

CALL_RE = re.compile(
r"\b(?:callBin|sendBridgeRequestBytes|buildBridgeRequest|callBoundaryMethod)\(\s*[\"']([A-Za-z0-9_]+)[\"']"
)
CALL_IDENT_RE = re.compile(r"\bcallBin\(\s*([A-Z][A-Z0-9_]+)\b")
HTML_RE = re.compile(r"\b(?:callBridgeMethod|encodeBridgeRequest)\(\s*[\"']([A-Za-z0-9_]+)[\"']")
ARM_RE = re.compile(r"^\s*\"([A-Za-z0-9_]+)\"\s*->", re.M)


def fail(msg):
print(f"[bridge-rpc-names] FAIL: {msg}")


def frontend_sources():
for dirpath, dirnames, filenames in os.walk(FRONTEND_SRC):
if "__tests__" in dirpath or os.sep + "proto" in dirpath[len(FRONTEND_SRC):]:
continue
for name in filenames:
if not name.endswith((".ts", ".tsx")):
continue
if ".test." in name or name == "setupTests.ts" or name.endswith(".d.ts"):
continue
yield os.path.join(dirpath, name)


def sent_names():
"""{name: [where, ...]}; exits 2 on an identifier that resolves to nothing."""
sent = {}
unresolved = []
for path in frontend_sources():
text = open(path, encoding="utf-8").read()
rel = os.path.relpath(path, ROOT)
for m in CALL_RE.finditer(text):
sent.setdefault(m.group(1), []).append(f"{rel}:{text.count(chr(10), 0, m.start()) + 1}")
for m in CALL_IDENT_RE.finditer(text):
const = re.search(r"\bconst\s+" + m.group(1) + r"\s*=\s*[\"']([A-Za-z0-9_]+)[\"']", text)
where = f"{rel}:{text.count(chr(10), 0, m.start()) + 1}"
if const:
sent.setdefault(const.group(1), []).append(where)
else:
unresolved.append(f"{where} ({m.group(1)})")
html = open(INDEX_HTML, encoding="utf-8").read()
for m in HTML_RE.finditer(html):
sent.setdefault(m.group(1), []).append(
f"{os.path.relpath(INDEX_HTML, ROOT)}:{html.count(chr(10), 0, m.start()) + 1}"
)
if unresolved:
fail("callBin called with an identifier no `const NAME = '…'` in its file names:")
for u in unresolved:
print(f" {u}")
sys.exit(2)
return sent


def handled_names():
text = open(KOTLIN_BRIDGE, encoding="utf-8").read()
fn = text.find("fun handleBinaryRpcInternal(")
if fn < 0:
fail(f"{os.path.relpath(KOTLIN_BRIDGE, ROOT)}: handleBinaryRpcInternal not found")
sys.exit(2)
when = text.find("when (method) {", fn)
if when < 0:
fail("handleBinaryRpcInternal has no `when (method) {` block")
sys.exit(2)
i = text.index("{", when)
depth = 0
j = i
while j < len(text):
c = text[j]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
j += 1
block = text[i:j + 1]
handled = {}
for m in ARM_RE.finditer(block):
handled.setdefault(m.group(1), []).append(
f"{os.path.relpath(KOTLIN_BRIDGE, ROOT)}:{text.count(chr(10), 0, i + m.start()) + 1}"
)
return handled


def main():
sent = sent_names()
handled = handled_names()
if not sent or not handled:
fail(f"a scan that finds nothing is not a scan (sent={len(sent)}, handled={len(handled)})")
return 2
status = 0
unhandled = sorted(set(sent) - set(handled))
if unhandled:
fail("the frontend sends bridge RPC names Kotlin does not handle:")
for name in unhandled:
print(f" {name}: " + ", ".join(sent[name]))
status = 1
unsent = sorted(set(handled) - set(sent))
if unsent:
fail("Kotlin handles bridge RPC names the frontend never sends (dead arms):")
for name in unsent:
print(f" {name}: " + ", ".join(handled[name]))
status = 1
if status == 0:
print(f"[bridge-rpc-names] OK: {len(sent)} names sent, {len(handled)} handled, the same set")
return status


if __name__ == "__main__":
sys.exit(main())
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,6 @@ class SinglePathWebViewBridge(private val context: Context) {



private fun readPersistedBytesOrEmpty(p: SharedPreferences, key: String): ByteArray {
val s = p.getString(key, null)
if (s.isNullOrBlank()) return ByteArray(0)
return try {
BridgeEncoding.base32CrockfordDecode(s)
} catch (_: Throwable) {
ByteArray(0)
}
}



// Enhanced error handling with specific error codes
private const val ERROR_BRIDGE_NOT_INITIALIZED = 1
private const val ERROR_INVALID_PAYLOAD = 2
Expand Down Expand Up @@ -266,73 +254,10 @@ class SinglePathWebViewBridge(private val context: Context) {
// --- Native QR scanner (Android ML Kit / camera activity) ---
// JS expects a 1-byte boolean response for availability.
// Launch result is delivered via CustomEvent("dsm-event") topic "qr_scan_result".
"hasNativeQrScanner" -> {
try {
// If the activity exists, we treat native scanning as available.
// (Camera permission flow is handled by the activity itself.)
val pm = inst.context.packageManager
val intent = android.content.Intent(inst.context, com.dsm.wallet.ui.QrScannerActivity::class.java)
val resolved = intent.resolveActivity(pm) != null
byteArrayOf(if (resolved) 1 else 0)
} catch (e: Throwable) {
Log.w(TAG, "hasNativeQrScanner: failed to resolve activity", e)
byteArrayOf(0)
}
}

"startNativeQrScanner" -> {
try {
// Prefer launching through the active MainActivity so the result callback can
// dispatch back into the WebView as a dsm-event.
val act = com.dsm.wallet.ui.MainActivity.getActiveInstance()
if (act != null) {
act.runOnUiThread {
try {
act.launchNativeQrScanner { qrText: String? ->
// Dispatch via JS evaluation (topic: qr_scan_result)
act.dispatchQrScanResult(qrText)
}
} catch (e: Throwable) {
Log.w(TAG, "startNativeQrScanner: inner exception", e)
act.dispatchQrScanResult(null)
}
}
}
} catch (e: Throwable) {
Log.w(TAG, "startNativeQrScanner: failed to launch scanner", e)
}
// Empty response is fine; result comes via event.
ByteArray(0)
}

// device_id bytes via JNI → Rust (Invariant #7: spine path, not prefs).
"getDeviceIdBin" -> {
try {
Unified.getDeviceIdBin()
} catch (_: Throwable) {
ByteArray(0)
}
}

// genesis_hash bytes via JNI → Rust (Invariant #7: spine path, not prefs).
"getGenesisHashBin" -> {
try {
Unified.getGenesisHashBin()
} catch (_: Throwable) {
ByteArray(0)
}
}

// signing public key bytes (JNI). Returns empty if not available.
"getSigningPublicKeyBin" -> {
try {
Unified.getSigningPublicKeyBin()
} catch (_: Throwable) {
ByteArray(0)
}
}

// Canonical mnemonic-rooted Genesis v2 (whitepaper §2.5): generate a mnemonic for
// device_id bytes via JNI → Rust (Invariant #7: spine path, not prefs).
// genesis_hash bytes via JNI → Rust (Invariant #7: spine path, not prefs).
// signing public key bytes (JNI). Returns empty if not available.
// Canonical mnemonic-rooted Genesis v2 (whitepaper §2.5): generate a mnemonic for
// backup, then create the wallet from it. No silicon enrollment, no random entropy.
"generateMnemonic" -> {
inst.generateMnemonic()
Expand All @@ -354,23 +279,9 @@ class SinglePathWebViewBridge(private val context: Context) {
}

// strict wallet history (JNI). Returns FramedEnvelopeV3 bytes or empty on error.
"getWalletHistoryStrict" -> {
try {
Unified.getWalletHistoryStrict()
} catch (t: Throwable) {
Log.w(TAG, "getWalletHistoryStrict failed", t)
ByteArray(0)
}
}

// genesis_envelope bytes (prefs-only). Used for cold-start rehydration.
// genesis_envelope bytes (prefs-only). Used for cold-start rehydration.
// Returns empty if not present.
"getPersistedGenesisEnvelope" -> {
val p = inst.prefs()
readPersistedBytesOrEmpty(p, KEY_GENESIS_ENVELOPE)
}

// Resolve BLE address from native mapping (bytes-only).
// Resolve BLE address from native mapping (bytes-only).
// Payload: 32-byte device_id. Response: UTF-8 address bytes or empty.
"resolveBleAddressForDeviceId" -> {
if (payload.size != 32) return ByteArray(0)
Expand Down Expand Up @@ -584,20 +495,7 @@ class SinglePathWebViewBridge(private val context: Context) {
}

// Generic Envelope v3 processing (online transfers, DBRW export, etc.)
"processEnvelopeV3" -> {
try {
val result = Unified.processEnvelopeV3(payload)
// State may have mutated — refresh NFC capsule if backup enabled.
// Rust decides whether to actually create one (no-op if disabled).
try { UnifiedNativeApi.maybeRefreshNfcCapsule() } catch (_: Throwable) {}
result
} catch (t: Throwable) {
Log.w(TAG, "processEnvelopeV3 failed", t)
ByteArray(0)
}
}

else -> throw IllegalArgumentException("Unknown binary RPC method: $method")
else -> throw IllegalArgumentException("Unknown binary RPC method: $method")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,6 @@ object Unified {
@Keep @JvmStatic fun getAllBalancesStrict(): ByteArray =
UnifiedNativeApi.getAllBalancesStrict()

/**
* Fetch wallet history (strict, protobuf-encoded).
* Returns: ByteArray (protobuf-encoded WalletHistoryResponse)
*/
@Keep @JvmStatic fun getWalletHistoryStrict(): ByteArray =
UnifiedNativeApi.getWalletHistoryStrict()

// BLE bilateral operations

Expand Down Expand Up @@ -490,13 +484,6 @@ object Unified {
@Keep @JvmStatic fun onAppBackgrounded(): Boolean =
try { UnifiedNativeApi.onAppBackgrounded() } catch (_: Throwable) { false }
@Keep @JvmStatic fun getGenesisHashBin(): ByteArray = UnifiedNativeApi.getGenesisHashBin()
/**
* Get the local signing public key (64 bytes for SPHINCS+ SPX256s).
* Used for bilateral transaction verification and QR code generation.
* @return 64-byte signing public key or empty array if not initialized
*/
@Keep @JvmStatic fun getSigningPublicKeyBin(): ByteArray =
UnifiedNativeApi.getSigningPublicKeyBin()
/**
* Get the current BLE MAC address for a device_id by searching identity cache.
* @param deviceId Raw 32-byte device ID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ internal object UnifiedNativeApi {
@Keep @JvmStatic external fun processEnvelopeV3(envelope: ByteArray): ByteArray
@Keep @JvmStatic external fun processEnvelopeV3WithAddress(envelope: ByteArray, deviceAddress: String): ByteArray
@Keep @JvmStatic external fun getAllBalancesStrict(): ByteArray
@Keep @JvmStatic external fun getWalletHistoryStrict(): ByteArray
@Keep @JvmStatic external fun ensureAppRouterInstalled(): Boolean
@Keep @JvmStatic external fun getAppRouterStatus(): Int
@Keep @JvmStatic external fun computeB0xAddress(genesis: ByteArray, deviceId: ByteArray, tip: ByteArray): String
Expand Down Expand Up @@ -111,7 +110,6 @@ internal object UnifiedNativeApi {
*/
@Keep @JvmStatic external fun onAppBackgrounded(): Boolean
@Keep @JvmStatic external fun getGenesisHashBin(): ByteArray
@Keep @JvmStatic external fun getSigningPublicKeyBin(): ByteArray
@Keep @JvmStatic external fun resolveBleAddressForDeviceIdBin(deviceId: ByteArray): ByteArray
@Keep @JvmStatic external fun resolvePeerIdentityForBleAddressBin(address: String): ByteArray
@Keep @JvmStatic external fun isRejectEnvelope(envelopeBytes: ByteArray): ByteArray
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ class SinglePathWebViewBridgeFuzzTest {
@Test
fun testFuzzBridgePayloads() {
val methods = listOf(
"hasNativeQrScanner",
"getDeviceIdBin",
"getSigningPublicKeyBin",
"getTransportHeadersV3Bin",
"getArchitectureInfo",
"getDiagnosticsLog",
"getPersistedDeviceId",
"getPersistedGenesisHash",
"getBluetoothStatus",
Expand All @@ -37,7 +37,7 @@ class SinglePathWebViewBridgeFuzzTest {
"resolveBleAddressForDeviceId",
"initiateBleContactPairing",
"getTransportHeadersV3Bin",
"processEnvelopeV3",
"acceptBilateralByCommitment",
"unknownMethod" // Test unknown methods too
)

Expand Down Expand Up @@ -289,7 +289,7 @@ class SinglePathWebViewBridgeFuzzTest {
@Test
fun testValidPayloadsStillWork() {
// Test methods that should work with empty payloads
val emptyMethods = listOf("hasNativeQrScanner", "getDeviceIdBin")
val emptyMethods = listOf("getTransportHeadersV3Bin", "getPreference")

emptyMethods.forEach { method ->
val result = SinglePathWebViewBridge.handleBinaryRpc(method, ByteArray(0))
Expand Down
Loading
Loading