diff --git a/Cargo.lock b/Cargo.lock index 42e47af82..74a539a18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,24 +400,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn", -] - [[package]] name = "bip39" version = "2.2.2" @@ -595,16 +577,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "cbindgen" version = "0.29.4" @@ -642,15 +614,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -709,17 +672,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.6.1" @@ -1163,7 +1115,6 @@ dependencies = [ "argon2", "arrayref", "async-trait", - "axum", "base32", "bincode", "bitcoin", @@ -1182,7 +1133,6 @@ dependencies = [ "futures", "getrandom 0.4.3", "hmac 0.13.0", - "jni", "lazy_static", "log", "lru", @@ -1203,7 +1153,6 @@ dependencies = [ "quickcheck", "rand 0.9.4", "rand_chacha 0.9.0", - "rocksdb", "rstest", "serde", "serial_test", @@ -1213,8 +1162,6 @@ dependencies = [ "test-log", "thiserror 2.0.18", "tokio", - "tokio-stream", - "tower", "tracing", "tracing-subscriber", "uuid", @@ -2404,16 +2351,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libredox" version = "0.1.16" @@ -2423,21 +2360,6 @@ dependencies = [ "libc", ] -[[package]] -name = "librocksdb-sys" -version = "0.17.3+10.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" -dependencies = [ - "bindgen", - "bzip2-sys", - "cc", - "libc", - "libz-sys", - "lz4-sys", - "zstd-sys", -] - [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -2449,17 +2371,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-sys" -version = "1.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "link-section" version = "0.19.0" @@ -2515,16 +2426,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3630,16 +3531,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rocksdb" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" -dependencies = [ - "libc", - "librocksdb-sys", -] - [[package]] name = "ron" version = "0.12.1" @@ -5410,13 +5301,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt index 5a1a19c15..40e2c0f12 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt @@ -2,14 +2,25 @@ package com.dsm.wallet.bridge -import java.io.ByteArrayOutputStream - +import com.google.protobuf.ByteString +import com.google.protobuf.InvalidProtocolBufferException +import dsm.types.proto.AppRouterPayload +import dsm.types.proto.BilateralPayload +import dsm.types.proto.BridgeRpcRequest +import dsm.types.proto.BridgeRpcResponse +import dsm.types.proto.ErrorResponse +import dsm.types.proto.PreferencePayload +import dsm.types.proto.SuccessResponse + +/** + * The bridge's wire shapes, decoded and encoded by the generated protobuf classes + * of `proto/dsm_app.proto` (`dsm.types.proto`). Kotlin implements no wire decoder + * of its own: every field number and wire type is the generated parser's. + */ internal object BridgeEnvelopeCodec { data class BridgeRequest(val method: String, val payload: ByteArray) - data class DsmErrorInfo(val sourceTag: Int, val message: String) - data class BridgeRpcError(val errorCode: Int, val message: String, val debugB32: String?) data class AppRouterRequest(val methodName: String, val args: ByteArray) @@ -18,551 +29,131 @@ internal object BridgeEnvelopeCodec { data class BilateralRequest(val commitment: ByteArray, val reason: String?) - fun parseBridgeRequest(requestBytes: ByteArray): BridgeRequest { - var offset = 0 - var method = "" - var payload = ByteArray(0) - var payloadFound = false + private const val METHOD_MAX_BYTES = 128 - while (offset < requestBytes.size) { - val (key, keyOff) = readVarint(requestBytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - when (fieldNumber) { - 1 -> { - if (wireType != 2) throw IllegalArgumentException("BridgeRpcRequest.method wrong wire type") - val (bytes, off) = readLengthDelimited(requestBytes, offset) - if (bytes.size > 128) { - throw IllegalArgumentException("BridgeRpcRequest.method too long: ${bytes.size} bytes (max 128)") - } - offset = off - method = bytes.toString(Charsets.UTF_8) - // Additional validation: method should be a valid identifier - if (method.isEmpty() || !method.all { it.isLetterOrDigit() || it in "_.-" }) { - throw IllegalArgumentException("BridgeRpcRequest.method invalid characters: '$method'") - } - } - 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 -> { - if (wireType != 2) throw IllegalArgumentException("BridgeRpcRequest.payload wrong wire type") - if (payloadFound) throw IllegalArgumentException("BridgeRpcRequest has multiple payloads") - payloadFound = true - val (bytes, off) = readLengthDelimited(requestBytes, offset) - offset = off - payload = decodePayload(fieldNumber, bytes) - } - else -> { - offset = skipField(wireType, requestBytes, offset) - } - } + /** + * Decodes a `BridgeRpcRequest`. The method must be present, at most 128 bytes + * and an identifier (letters, digits, `_`, `.`, `-`). The payload is the oneof + * member's content: the inner bytes of the bytes, string and BLE payloads, and + * the message's own bytes for the typed payloads the dispatcher decodes itself. + */ + fun parseBridgeRequest(requestBytes: ByteArray): BridgeRequest { + val req = try { + BridgeRpcRequest.parseFrom(requestBytes) + } catch (e: InvalidProtocolBufferException) { + throw IllegalArgumentException("BridgeRpcRequest does not decode: ${e.message}", e) + } + val method = req.method + if (method.isEmpty()) { + throw IllegalArgumentException("BridgeRpcRequest.method missing") + } + val methodBytes = method.toByteArray(Charsets.UTF_8).size + if (methodBytes > METHOD_MAX_BYTES) { + throw IllegalArgumentException("BridgeRpcRequest.method too long: $methodBytes bytes (max $METHOD_MAX_BYTES)") + } + if (!method.all { it.isLetterOrDigit() || it in "_.-" }) { + throw IllegalArgumentException("BridgeRpcRequest.method invalid characters: '$method'") + } + val payload = when (req.payloadCase) { + BridgeRpcRequest.PayloadCase.EMPTY, + BridgeRpcRequest.PayloadCase.PAYLOAD_NOT_SET -> ByteArray(0) + BridgeRpcRequest.PayloadCase.BYTES -> req.bytes.data.toByteArray() + BridgeRpcRequest.PayloadCase.STRING -> req.string.value.toByteArray(Charsets.UTF_8) + BridgeRpcRequest.PayloadCase.PREFERENCE -> req.preference.toByteArray() + BridgeRpcRequest.PayloadCase.APP_ROUTER -> req.appRouter.toByteArray() + BridgeRpcRequest.PayloadCase.BLE_CONTACT -> req.bleContact.pairingData.toByteArray() + BridgeRpcRequest.PayloadCase.BLE_ADDRESS -> req.bleAddress.deviceId.toByteArray() + BridgeRpcRequest.PayloadCase.BILATERAL -> req.bilateral.toByteArray() } - return BridgeRequest(method, payload) } - fun extractDeterministicSafetyMessageFromEnvelope(envelopeBytes: ByteArray): String? { - val err = extractErrorInfoFromEnvelope(envelopeBytes) ?: return null - return if (err.sourceTag == 11) err.message else null - } - + /** (isSuccess, payload): the success data, or the `ErrorResponse` bytes. */ fun parseEnvelopeResponse(responseBytes: ByteArray): Pair { - return parseBridgeRpcResponse(responseBytes) + val resp = try { + BridgeRpcResponse.parseFrom(responseBytes) + } catch (e: InvalidProtocolBufferException) { + throw IllegalArgumentException("BridgeRpcResponse does not decode: ${e.message}", e) + } + return when (resp.resultCase) { + BridgeRpcResponse.ResultCase.SUCCESS -> Pair(true, resp.success.data.toByteArray()) + BridgeRpcResponse.ResultCase.ERROR -> Pair(false, resp.error.toByteArray()) + BridgeRpcResponse.ResultCase.RESULT_NOT_SET -> + throw IllegalArgumentException("BridgeRpcResponse missing result") + } } fun decodeBridgeRpcError(errorBytes: ByteArray): BridgeRpcError? { - var offset = 0 - var errorCode = 0 - var message = "" - var debugB32: String? = null - - while (offset < errorBytes.size) { - val (key, keyOff) = readVarint(errorBytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - when (fieldNumber) { - 1 -> { - if (wireType != 0) return null - val (value, off) = readVarint(errorBytes, offset) - offset = off - errorCode = value.toInt() - } - 2 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(errorBytes, offset) - offset = off - message = bytes.toString(Charsets.UTF_8) - } - 3 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(errorBytes, offset) - offset = off - debugB32 = bytes.toString(Charsets.UTF_8) - } - else -> { - offset = skipField(wireType, errorBytes, offset) - } - } - } - - return if (errorCode != 0 || message.isNotEmpty() || !debugB32.isNullOrEmpty()) { - BridgeRpcError(errorCode, message, debugB32) - } else { - null + val err = try { + ErrorResponse.parseFrom(errorBytes) + } catch (_: InvalidProtocolBufferException) { + return null } + if (err.errorCode == 0 && err.message.isEmpty() && err.debugB32.isEmpty()) return null + return BridgeRpcError(err.errorCode, err.message, err.debugB32.ifEmpty { null }) } - fun encodeAppRouterPayload(methodName: String, args: ByteArray): ByteArray { - val methodBytes = methodName.toByteArray(Charsets.UTF_8) - - val out = ByteArrayOutputStream() - // field 1 (method_name), wire type 2 - out.write(0x0A) - out.write(encodeVarint32(methodBytes.size)) - out.write(methodBytes) - - // field 2 (args), wire type 2 - out.write(0x12) - out.write(encodeVarint32(args.size)) - out.write(args) - return out.toByteArray() - } + fun encodeAppRouterPayload(methodName: String, args: ByteArray): ByteArray = + AppRouterPayload.newBuilder() + .setMethodName(methodName) + .setArgs(ByteString.copyFrom(args)) + .build() + .toByteArray() fun decodeAppRouterPayload(payloadBytes: ByteArray): AppRouterRequest? { - var offset = 0 - var methodName = "" - var args = ByteArray(0) - while (offset < payloadBytes.size) { - val (key, keyOff) = readVarint(payloadBytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - when (fieldNumber) { - 1 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - methodName = bytes.toString(Charsets.UTF_8) - } - 2 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - args = bytes - } - else -> { - offset = skipField(wireType, payloadBytes, offset) - } - } + val p = try { + AppRouterPayload.parseFrom(payloadBytes) + } catch (_: InvalidProtocolBufferException) { + return null } - if (methodName.isBlank()) return null - return AppRouterRequest(methodName, args) + if (p.methodName.isBlank()) return null + return AppRouterRequest(p.methodName, p.args.toByteArray()) } fun decodePreferencePayload(payloadBytes: ByteArray): PreferenceRequest? { - var offset = 0 - var key = "" - var value: String? = null - while (offset < payloadBytes.size) { - val (tag, keyOff) = readVarint(payloadBytes, offset) - offset = keyOff - val fieldNumber = (tag ushr 3).toInt() - val wireType = (tag and 0x07).toInt() - when (fieldNumber) { - 1 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - key = bytes.toString(Charsets.UTF_8) - } - 2 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - value = bytes.toString(Charsets.UTF_8) - } - else -> { - offset = skipField(wireType, payloadBytes, offset) - } - } + val p = try { + PreferencePayload.parseFrom(payloadBytes) + } catch (_: InvalidProtocolBufferException) { + return null } - if (key.isBlank()) return null - return PreferenceRequest(key, value) + if (p.key.isBlank()) return null + return PreferenceRequest(p.key, if (p.hasValue()) p.value else null) } fun decodeBilateralPayload(payloadBytes: ByteArray): BilateralRequest? { - var offset = 0 - var commitment = ByteArray(0) - var reason: String? = null - while (offset < payloadBytes.size) { - val (tag, keyOff) = readVarint(payloadBytes, offset) - offset = keyOff - val fieldNumber = (tag ushr 3).toInt() - val wireType = (tag and 0x07).toInt() - when (fieldNumber) { - 1 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - commitment = bytes - } - 2 -> { - if (wireType != 2) return null - val (bytes, off) = readLengthDelimited(payloadBytes, offset) - offset = off - reason = bytes.toString(Charsets.UTF_8) - } - else -> { - offset = skipField(wireType, payloadBytes, offset) - } - } + val p = try { + BilateralPayload.parseFrom(payloadBytes) + } catch (_: InvalidProtocolBufferException) { + return null } + val commitment = p.commitment.toByteArray() if (commitment.size != 32) return null - return BilateralRequest(commitment, reason) + return BilateralRequest(commitment, if (p.hasReason()) p.reason else null) } - fun createSuccessResponse(data: ByteArray): ByteArray { - // SuccessResponse { bytes data = 1 } - val successStream = ByteArrayOutputStream() - successStream.write(0x0A) // field 1, wire type 2 - val dataLenVarint = encodeVarint32(data.size) - successStream.write(dataLenVarint) - successStream.write(data) - val successBytes = successStream.toByteArray() - - // BridgeRpcResponse: field 1 = success - val bridgeStream = ByteArrayOutputStream() - bridgeStream.write(0x0A) - bridgeStream.write(encodeVarint32(successBytes.size)) - bridgeStream.write(successBytes) - return bridgeStream.toByteArray() - } + fun createSuccessResponse(data: ByteArray): ByteArray = + BridgeRpcResponse.newBuilder() + .setSuccess(SuccessResponse.newBuilder().setData(ByteString.copyFrom(data))) + .build() + .toByteArray() + /** The debug string encodes the error without itself; an encoder that fails leaves it empty. */ fun createErrorResponse( errorCode: Int, message: String, debugEncoder: (ByteArray) -> String ): ByteArray { - val msgBytes = message.toByteArray(Charsets.UTF_8) - - val preimageStream = ByteArrayOutputStream() - preimageStream.write(0x08) // field 1, wire type 0 - preimageStream.write(encodeVarint32(errorCode)) - preimageStream.write(0x12) // field 2, wire type 2 - preimageStream.write(encodeVarint32(msgBytes.size)) - preimageStream.write(msgBytes) - preimageStream.write(0x1A) // field 3, wire type 2 (empty debug) - preimageStream.write(encodeVarint32(0)) - - val errorPreimageBytes = preimageStream.toByteArray() - val debugStr = try { debugEncoder(errorPreimageBytes) } catch (_: Throwable) { "" } - val debugBytes = debugStr.toByteArray(Charsets.UTF_8) - - val errStream = ByteArrayOutputStream() - errStream.write(0x08) // field 1, wire type 0 - errStream.write(encodeVarint32(errorCode)) - errStream.write(0x12) // field 2, wire type 2 - errStream.write(encodeVarint32(msgBytes.size)) - errStream.write(msgBytes) - errStream.write(0x1A) // field 3, wire type 2 - errStream.write(encodeVarint32(debugBytes.size)) - errStream.write(debugBytes) - - val errorBytes = errStream.toByteArray() - - // BridgeRpcResponse: field 2 = error - val bridgeStream = ByteArrayOutputStream() - bridgeStream.write(0x12) - bridgeStream.write(encodeVarint32(errorBytes.size)) - bridgeStream.write(errorBytes) - return bridgeStream.toByteArray() - } - - private fun parseBridgeRpcResponse(bytes: ByteArray): Pair { - var offset = 0 - var isSuccess: Boolean? = null - var payload = ByteArray(0) - - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - when (fieldNumber) { - 1 -> { - if (wireType != 2) throw IllegalArgumentException("BridgeRpcResponse.success wrong wire type") - if (isSuccess != null) throw IllegalArgumentException("BridgeRpcResponse has multiple results") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - payload = parseSuccessPayload(msgBytes) - isSuccess = true - } - 2 -> { - if (wireType != 2) throw IllegalArgumentException("BridgeRpcResponse.error wrong wire type") - if (isSuccess != null) throw IllegalArgumentException("BridgeRpcResponse has multiple results") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - payload = msgBytes - isSuccess = false - } - else -> { - offset = skipField(wireType, bytes, offset) - } - } - } - - if (isSuccess == null) throw IllegalArgumentException("BridgeRpcResponse missing result") - return Pair(isSuccess == true, payload) - } - - private fun extractErrorInfoFromEnvelope(bytes: ByteArray): DsmErrorInfo? { - var offset = 0 - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - when (fieldNumber) { - 99 -> { - if (wireType != 2) return null - val (errBytes, _) = readLengthDelimited(bytes, offset) - return parseErrorInfo(errBytes) - } - 11 -> { - if (wireType != 2) { - offset = skipField(wireType, bytes, offset) - } else { - val (rxBytes, off) = readLengthDelimited(bytes, offset) - offset = off - val err = parseUniversalRxForError(rxBytes) - if (err != null) return err - } - } - else -> { - offset = skipField(wireType, bytes, offset) - } - } - } - return null - } - - private fun parseUniversalRxForError(bytes: ByteArray): DsmErrorInfo? { - var offset = 0 - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - - if (fieldNumber == 1 && wireType == 2) { - val (opBytes, off) = readLengthDelimited(bytes, offset) - offset = off - val err = parseOpResultForError(opBytes) - if (err != null) return err - } else { - offset = skipField(wireType, bytes, offset) - } - } - return null - } - - private fun parseOpResultForError(bytes: ByteArray): DsmErrorInfo? { - var offset = 0 - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - if (fieldNumber == 5 && wireType == 2) { - val (errBytes, _) = readLengthDelimited(bytes, offset) - return parseErrorInfo(errBytes) - } - offset = skipField(wireType, bytes, offset) - } - return null - } - - private fun parseErrorInfo(bytes: ByteArray): DsmErrorInfo? { - var offset = 0 - var message = "" - var sourceTag = 0 - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - when (fieldNumber) { - 2 -> { - if (wireType != 2) return null - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - message = msgBytes.toString(Charsets.UTF_8) - } - 4 -> { - if (wireType != 0) return null - val (tag, off) = readVarint(bytes, offset) - offset = off - sourceTag = tag.toInt() - } - else -> { - offset = skipField(wireType, bytes, offset) - } - } - } - return if (sourceTag != 0 || message.isNotEmpty()) DsmErrorInfo(sourceTag, message) else null - } - - private fun parseSuccessPayload(bytes: ByteArray): ByteArray { - var offset = 0 - var data = ByteArray(0) - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - if (fieldNumber == 1) { - if (wireType != 2) throw IllegalArgumentException("SuccessResponse.data wrong wire type") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - data = msgBytes - } else { - offset = skipField(wireType, bytes, offset) - } - } - return data - } - - private fun decodePayload(fieldNumber: Int, bytes: ByteArray): ByteArray { - return when (fieldNumber) { - 2 -> ByteArray(0) - 3 -> parseBytesPayload(bytes) - 4 -> parseStringPayload(bytes) - 5 -> parsePreferencePayload(bytes) - 6 -> parseAppRouterPayload(bytes) - 8 -> parseSingleBytesPayload(bytes) - 9 -> parseSingleBytesPayload(bytes) - 11 -> parseBilateralPayload(bytes) - else -> ByteArray(0) - } - } - - private fun parseBytesPayload(bytes: ByteArray): ByteArray { - var offset = 0 - var out = ByteArray(0) - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - if (fieldNumber == 1) { - if (wireType != 2) throw IllegalArgumentException("BytesPayload.data wrong wire type") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - out = msgBytes - } else { - offset = skipField(wireType, bytes, offset) - } - } - return out - } - - private fun parseStringPayload(bytes: ByteArray): ByteArray { - var offset = 0 - var out = ByteArray(0) - while (offset < bytes.size) { - val (key, keyOff) = readVarint(bytes, offset) - offset = keyOff - val fieldNumber = (key ushr 3).toInt() - val wireType = (key and 0x07).toInt() - if (fieldNumber == 1) { - if (wireType != 2) throw IllegalArgumentException("StringPayload.value wrong wire type") - val (msgBytes, off) = readLengthDelimited(bytes, offset) - offset = off - out = msgBytes - } else { - offset = skipField(wireType, bytes, offset) - } - } - return out - } - - private fun parsePreferencePayload(bytes: ByteArray): ByteArray { - // Keep canonical protobuf bytes for downstream typed decoders. - return bytes - } - - private fun parseAppRouterPayload(bytes: ByteArray): ByteArray { - // Keep canonical protobuf bytes for downstream typed decoders. - return bytes - } - - private fun parseSingleBytesPayload(bytes: ByteArray): ByteArray { - return parseBytesPayload(bytes) - } - - private fun parseBilateralPayload(bytes: ByteArray): ByteArray { - // Keep canonical protobuf bytes for downstream typed decoders. - return bytes - } - - private fun readVarint(bytes: ByteArray, start: Int): Pair { - var shift = 0 - var result = 0L - var offset = start - while (offset < bytes.size) { - val b = bytes[offset].toInt() and 0xFF - result = result or ((b and 0x7F).toLong() shl shift) - offset += 1 - if (b and 0x80 == 0) break - shift += 7 - if (shift > 63) throw IllegalArgumentException("varint too long") - if (offset >= bytes.size && (b and 0x80) != 0) throw IllegalArgumentException("truncated varint") - } - return Pair(result, offset) - } - - private fun readLengthDelimited(bytes: ByteArray, start: Int): Pair { - val (len, off) = readVarint(bytes, start) - val l = len.toInt() - if (l < 0 || off + l > bytes.size) throw IllegalArgumentException("invalid length-delimited size") - val out = bytes.copyOfRange(off, off + l) - return Pair(out, off + l) - } - - private fun skipField(wireType: Int, bytes: ByteArray, start: Int): Int { - return when (wireType) { - 0 -> readVarint(bytes, start).second - 1 -> { - val off = start + 8 - if (off > bytes.size) throw IllegalArgumentException("truncated fixed64") - off - } - 2 -> readLengthDelimited(bytes, start).second - 5 -> { - val off = start + 4 - if (off > bytes.size) throw IllegalArgumentException("truncated fixed32") - off - } - else -> throw IllegalArgumentException("unsupported wire type: $wireType") - } - } - - private fun encodeVarint32(valueIn: Int): ByteArray { - var v = valueIn - val baos = ByteArrayOutputStream() - while (true) { - if (v and 0x7F.inv() == 0) { - baos.write(v) - break - } else { - baos.write((v and 0x7F) or 0x80) - v = v ushr 7 - } - } - return baos.toByteArray() + val preimage = ErrorResponse.newBuilder() + .setErrorCode(errorCode) + .setMessage(message) + .build() + .toByteArray() + val debug = try { debugEncoder(preimage) } catch (_: Throwable) { "" } + val error = ErrorResponse.newBuilder() + .setErrorCode(errorCode) + .setMessage(message) + .setDebugB32(debug) + .build() + return BridgeRpcResponse.newBuilder().setError(error).build().toByteArray() } } diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt index 164abe75e..f66dbde0a 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt @@ -68,13 +68,10 @@ import dsm.types.proto.NativeHostEvent import dsm.types.proto.NativeHostEventKind import dsm.types.proto.QrScanResultPayload import dsm.types.proto.SessionHardwareFactsProto -import java.io.ByteArrayInputStream import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.io.InputStream import java.lang.ref.WeakReference -import java.net.HttpURLConnection import java.net.URL import java.util.Locale @@ -878,57 +875,6 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { - // The WebView external-host allowlist lives at file scope below so that - // `WebViewAllowlistTest` (src/test) can read the set literals and the - // `isAllowlistedExternalHost` predicate directly. The lock is the test - // — any change to the allowlist requires a matching test diff that is - // visible in PR review. - - @VisibleForTesting - internal fun proxyWithCorsForTest(request: WebResourceRequest): WebResourceResponse? { - return proxyWithCorsInternal(request) - } - - private fun proxyWithCorsInternal(request: WebResourceRequest): WebResourceResponse? { - val url = request.url?.toString() ?: return null - val host = request.url?.host ?: return null - if (!isAllowlistedExternalHost(host)) return null - return try { - val conn = (URL(url).openConnection() as HttpURLConnection).apply { - connectTimeout = 10_000 - readTimeout = 15_000 - requestMethod = request.method - for ((k, v) in request.requestHeaders) { - if (k.isNullOrBlank()) continue - setRequestProperty(k, v) - } - } - val code = conn.responseCode - val rawContentType = conn.contentType ?: "application/octet-stream" - val parts = rawContentType.split(';').map { it.trim() } - val mime = parts.firstOrNull()?.ifBlank { "application/octet-stream" } ?: "application/octet-stream" - val charset = parts.firstOrNull { it.startsWith("charset=", ignoreCase = true) } - ?.substringAfter('=') - ?.ifBlank { null } - ?: "utf-8" - - val stream: InputStream = try { - conn.inputStream - } catch (_: Throwable) { - conn.errorStream ?: ByteArrayInputStream(ByteArray(0)) - } - - val headers = mutableMapOf() - headers["Access-Control-Allow-Origin"] = "https://appassets.androidplatform.net" - headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" - headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" - - WebResourceResponse(mime, charset, code, conn.responseMessage ?: "OK", headers, stream) - } catch (_: Throwable) { - null - } - } - private fun installDsmBinaryBridge(wv: WebView) { if (!WebViewFeature.isFeatureSupported(WebViewFeature.CREATE_WEB_MESSAGE_CHANNEL)) { Log.e(tag, "WebViewFeature.CREATE_WEB_MESSAGE_CHANNEL not supported") @@ -1058,18 +1004,6 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } Log.i(tag, "DSM bridge: method '$method' response size: ${respBytes.size} bytes") - // Optional: native-side deterministic safety routing (Error.source_tag == 11) - try { - val (ok, data) = com.dsm.wallet.bridge.BridgeEnvelopeCodec.parseEnvelopeResponse(respBytes) - if (ok) { - com.dsm.wallet.bridge.BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope(data)?.let { - dispatchDsmEventOnUi("dsm.deterministicSafety", it.toByteArray(Charsets.UTF_8)) - } - } - } catch (_: Throwable) { - // ignore parse errors (response may not be an Envelope) - } - // Prepend message ID to response (8 bytes u64) val responseWithId = ByteArray(8 + respBytes.size) java.nio.ByteBuffer.wrap(responseWithId, 0, 8).order(java.nio.ByteOrder.BIG_ENDIAN).putLong(messageId) @@ -1964,11 +1898,9 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { ?: return super.shouldInterceptRequest(view, request as WebResourceRequest?) val uri = req.url ?: return super.shouldInterceptRequest(view, request as WebResourceRequest?) - // APK assets are served by WebViewAssetLoader. - // Allowlisted external hosts fall through to the CORS proxy. - // Everything else returns null so WebView handles it normally. + // APK assets are served by WebViewAssetLoader; everything else + // returns null so WebView handles it normally. return assetLoader.shouldInterceptRequest(uri) - ?: proxyWithCorsInternal(req) } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { @@ -2008,37 +1940,3 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } } -// ============================================================================= -// SECURITY: WebView external-host allowlist (CI-locked). -// -// These declarations are deliberately at file scope (not inside MainActivity) -// so that `dsm_client/android/app/src/test/.../WebViewAllowlistTest.kt` can -// read the set literals and call `isAllowlistedExternalHost` directly. The -// lock is the test: any change to either set requires a matching test diff -// that is visible in PR review. -// -// `proxyWithCorsInternal` consults `isAllowlistedExternalHost` BEFORE -// performing any external fetch or injecting CORS response headers. Adding -// a new external host therefore requires both: -// 1. updating the set(s) below, AND -// 2. updating WebViewAllowlistTest.kt to match. -// Both edits land in the same PR diff and trigger explicit review. -// ============================================================================= - -internal val WEBVIEW_ALLOWED_EXACT_HOSTS: Set = setOf( - "tile.openstreetmap.org", - "localhost", - "127.0.0.1", -) - -internal val WEBVIEW_ALLOWED_HOST_SUFFIXES: Set = setOf( - ".tile.openstreetmap.org", -) - -internal fun isAllowlistedExternalHost(host: String): Boolean { - if (host in WEBVIEW_ALLOWED_EXACT_HOSTS) return true - for (suffix in WEBVIEW_ALLOWED_HOST_SUFFIXES) { - if (host.endsWith(suffix)) return true - } - return false -} diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt index bc497b708..55fb37c0c 100644 --- a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/BridgeEnvelopeCodecTest.kt @@ -96,11 +96,9 @@ class BridgeEnvelopeCodecTest { assertEquals(method128, req.method) } - @Test - fun parseBridgeRequest_emptyInput_defaultValues() { - val req = BridgeEnvelopeCodec.parseBridgeRequest(ByteArray(0)) - assertEquals("", req.method) - assertEquals(0, req.payload.size) + @Test(expected = IllegalArgumentException::class) + fun parseBridgeRequest_noMethod_refused() { + BridgeEnvelopeCodec.parseBridgeRequest(ByteArray(0)) } @Test @@ -213,18 +211,14 @@ class BridgeEnvelopeCodecTest { BridgeEnvelopeCodec.parseBridgeRequest(encodeVarintField(1, 42)) } - @Test(expected = IllegalArgumentException::class) - fun parseBridgeRequest_payloadWrongWireType() { + @Test + fun parseBridgeRequest_wrongWireTypeIsNotThePayload() { + // A varint where the preference message would be is an unknown field to + // the protobuf parser: it is skipped, and the request carries no payload. val bytes = encodeLenField(1, "test".toByteArray()) + encodeVarintField(5, 42) - BridgeEnvelopeCodec.parseBridgeRequest(bytes) - } - - @Test(expected = IllegalArgumentException::class) - fun parseBridgeRequest_duplicatePayloads() { - val methodField = encodeLenField(1, "test".toByteArray()) - val payload1 = encodeLenField(3, encodeLenField(1, byteArrayOf(1))) - val payload2 = encodeLenField(4, encodeLenField(1, byteArrayOf(2))) - BridgeEnvelopeCodec.parseBridgeRequest(methodField + payload1 + payload2) + val req = BridgeEnvelopeCodec.parseBridgeRequest(bytes) + assertEquals("test", req.method) + assertEquals(0, req.payload.size) } @Test(expected = IllegalArgumentException::class) @@ -303,9 +297,13 @@ class BridgeEnvelopeCodecTest { } @Test - fun decodeBridgeRpcError_wrongWireTypeForMessage_returnsNull() { + fun decodeBridgeRpcError_wrongWireTypeForMessage_leavesTheMessageEmpty() { + // A varint where the message string would be is skipped as unknown; the + // code stands alone. val bytes = encodeVarintField(1, 1) + encodeVarintField(2, 42) - assertNull(BridgeEnvelopeCodec.decodeBridgeRpcError(bytes)) + val err = BridgeEnvelopeCodec.decodeBridgeRpcError(bytes)!! + assertEquals(1, err.errorCode) + assertEquals("", err.message) } @Test @@ -367,14 +365,6 @@ class BridgeEnvelopeCodecTest { BridgeEnvelopeCodec.parseEnvelopeResponse(ByteArray(0)) } - @Test(expected = IllegalArgumentException::class) - fun parseEnvelopeResponse_multipleResults() { - val successInner = encodeLenField(1, byteArrayOf(0x01)) - val success = encodeLenField(1, successInner) - val error = encodeLenField(2, byteArrayOf(0x08, 0x01)) - BridgeEnvelopeCodec.parseEnvelopeResponse(success + error) - } - @Test(expected = IllegalArgumentException::class) fun parseEnvelopeResponse_successWrongWireType() { BridgeEnvelopeCodec.parseEnvelopeResponse(encodeVarintField(1, 42)) @@ -414,14 +404,14 @@ class BridgeEnvelopeCodecTest { } @Test - fun createErrorResponse_debugEncoderThrows_emptyDebug() { + fun createErrorResponse_debugEncoderThrows_noDebug() { val response = BridgeEnvelopeCodec.createErrorResponse(1, "test") { throw RuntimeException("encoder broke") } val (isSuccess, errorPayload) = BridgeEnvelopeCodec.parseEnvelopeResponse(response) assertFalse(isSuccess) val err = BridgeEnvelopeCodec.decodeBridgeRpcError(errorPayload)!! - assertEquals("", err.debugB32) + assertNull(err.debugB32) } // ── encodeAppRouterPayload / decodeAppRouterPayload ────────────────── @@ -531,57 +521,4 @@ class BridgeEnvelopeCodecTest { fun decodeBilateralPayload_emptyInput_returnsNull() { assertNull(BridgeEnvelopeCodec.decodeBilateralPayload(ByteArray(0))) } - - // ── extractDeterministicSafetyMessageFromEnvelope ───────────────────── - - @Test - fun extractSafetyMessage_emptyEnvelope_returnsNull() { - assertNull( - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope(ByteArray(0)) - ) - } - - @Test - fun extractSafetyMessage_noErrorInfo_returnsNull() { - assertNull( - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope( - encodeVarintField(1, 42) - ) - ) - } - - @Test - fun extractSafetyMessage_field99_sourceTag11_returnsMessage() { - val errorInfo = encodeLenField(2, "safety violation".toByteArray()) + - encodeVarintField(4, 11) - val envelope = encodeLenField(99, errorInfo) - assertEquals( - "safety violation", - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope(envelope) - ) - } - - @Test - fun extractSafetyMessage_field99_differentSourceTag_returnsNull() { - val errorInfo = encodeLenField(2, "other error".toByteArray()) + - encodeVarintField(4, 5) - assertNull( - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope( - encodeLenField(99, errorInfo) - ) - ) - } - - @Test - fun extractSafetyMessage_field11_nestedPath_returnsMessage() { - val errorInfo = encodeLenField(2, "deep error".toByteArray()) + - encodeVarintField(4, 11) - val opResult = encodeLenField(5, errorInfo) - val universalRx = encodeLenField(1, opResult) - val envelope = encodeLenField(11, universalRx) - assertEquals( - "deep error", - BridgeEnvelopeCodec.extractDeterministicSafetyMessageFromEnvelope(envelope) - ) - } } diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebViewAllowlistTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebViewAllowlistTest.kt deleted file mode 100644 index 3cfc182c3..000000000 --- a/dsm_client/android/app/src/test/java/com/dsm/wallet/ui/WebViewAllowlistTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.dsm.wallet.ui - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * SECURITY: WebView external-host allowlist lock. - * - * The first two tests freeze the allowlist contents byte-for-byte. Any - * change to `WEBVIEW_ALLOWED_EXACT_HOSTS` or `WEBVIEW_ALLOWED_HOST_SUFFIXES` - * in MainActivity.kt MUST be reflected in this file. That makes allowlist - * expansion visible in PR review — the reviewer is forced to consciously - * approve a new external host the WebView is allowed to fetch from. - * - * The third test asserts the predicate still correctly maps the sets to - * accept/reject decisions for representative hosts. - * - * If you are editing this file because the allowlist legitimately grew, - * please confirm: - * 1. The new host is necessary for product behaviour (not dev convenience). - * 2. The host operator's TLS/CORS posture has been reviewed. - * 3. The PR description names the new host and the reason. - */ -class WebViewAllowlistTest { - - @Test - fun allowlist_exact_hosts_frozen() { - assertEquals( - setOf( - "tile.openstreetmap.org", - "localhost", - "127.0.0.1", - ), - WEBVIEW_ALLOWED_EXACT_HOSTS, - ) - } - - @Test - fun allowlist_host_suffixes_frozen() { - assertEquals( - setOf( - ".tile.openstreetmap.org", - ), - WEBVIEW_ALLOWED_HOST_SUFFIXES, - ) - } - - @Test - fun isAllowlistedExternalHost_respects_sets() { - // Exact-match accept. - assertTrue(isAllowlistedExternalHost("tile.openstreetmap.org")) - assertTrue(isAllowlistedExternalHost("localhost")) - assertTrue(isAllowlistedExternalHost("127.0.0.1")) - - // Suffix-match accept (subdomains of openstreetmap tile servers). - assertTrue(isAllowlistedExternalHost("a.tile.openstreetmap.org")) - assertTrue(isAllowlistedExternalHost("b.tile.openstreetmap.org")) - - // Reject — not in either set. - assertFalse(isAllowlistedExternalHost("evil.example.com")) - assertFalse(isAllowlistedExternalHost("openstreetmap.org")) - assertFalse(isAllowlistedExternalHost("tile.openstreetmap.org.attacker.com")) - assertFalse(isAllowlistedExternalHost("")) - assertFalse(isAllowlistedExternalHost("127.0.0.2")) - } -} diff --git a/dsm_client/deterministic_state_machine/Cargo.lock b/dsm_client/deterministic_state_machine/Cargo.lock index a02f7b1b0..10e34a51a 100644 --- a/dsm_client/deterministic_state_machine/Cargo.lock +++ b/dsm_client/deterministic_state_machine/Cargo.lock @@ -401,24 +401,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 2.0.117", -] - [[package]] name = "bip39" version = "2.2.2" @@ -582,16 +564,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "cbindgen" version = "0.29.3" @@ -629,15 +601,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -696,17 +659,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.6.1" @@ -1154,7 +1106,6 @@ dependencies = [ "argon2", "arrayref", "async-trait", - "axum", "base32", "bincode", "bitcoin", @@ -1173,7 +1124,6 @@ dependencies = [ "futures", "getrandom 0.4.2", "hmac 0.13.0", - "jni", "lazy_static", "log", "lru", @@ -1194,7 +1144,6 @@ dependencies = [ "quickcheck", "rand 0.9.4", "rand_chacha 0.9.0", - "rocksdb", "rstest", "serde", "serial_test", @@ -1204,8 +1153,6 @@ dependencies = [ "test-log", "thiserror 2.0.18", "tokio", - "tokio-stream", - "tower", "tracing", "tracing-subscriber", "uuid", @@ -2237,15 +2184,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -2374,16 +2312,6 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libredox" version = "0.1.12" @@ -2394,21 +2322,6 @@ dependencies = [ "libc", ] -[[package]] -name = "librocksdb-sys" -version = "0.17.3+10.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" -dependencies = [ - "bindgen", - "bzip2-sys", - "cc", - "libc", - "libz-sys", - "lz4-sys", - "zstd-sys", -] - [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -2420,17 +2333,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "link-section" version = "0.16.1" @@ -2486,16 +2388,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3138,7 +3030,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.14.0", + "itertools", "log", "multimap", "once_cell", @@ -3158,7 +3050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.14.0", + "itertools", "log", "multimap", "petgraph 0.8.3", @@ -3177,7 +3069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", "syn 2.0.117", @@ -3190,7 +3082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", "syn 2.0.117", @@ -3591,16 +3483,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rocksdb" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" -dependencies = [ - "libc", - "librocksdb-sys", -] - [[package]] name = "ron" version = "0.12.2" @@ -5517,13 +5399,3 @@ name = "zmij" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/dsm_client/deterministic_state_machine/dsm/Cargo.toml b/dsm_client/deterministic_state_machine/dsm/Cargo.toml index ddef8fe7a..202cfc470 100644 --- a/dsm_client/deterministic_state_machine/dsm/Cargo.toml +++ b/dsm_client/deterministic_state_machine/dsm/Cargo.toml @@ -17,13 +17,7 @@ unused_must_use = "deny" [features] default = [] # NO JNI, demos, or HTTP features in core - pure business logic -bluetooth = ["dep:tokio-stream"] -storage = ["rocksdb"] -threadsafe = [] -jni = ["dep:jni"] # JNI feature for Android integration -web-stack = ["dep:axum", "dep:tower"] sphincs-trace = [] # Enable verbose SPHINCS+ tracing (debug only) - formal = [] # Issue #181 Finding 2: NON_PAPER_MODE testnet/signet bypass is OFF by default. # Without this feature, all Bitcoin networks (including Testnet/Signet) require # checkpoint-rooted header chains and entry-anchor verification — i.e. the @@ -100,13 +94,6 @@ ml-kem = { version = "0.2.0", features = ["deterministic"] } # Pure Rust FIPS 2 # pqcrypto-mlkem = "0.1.0" # REMOVED: Hardware optimized, caused SIGILL on Android # pqcrypto-sphincsplus has been replaced with a pure Rust implementation -jni = { version = "0.21", optional = true } - -# Bluetooth support (optional) -tokio-stream = { version = "0.1.14", optional = true, features = ["sync"] } - -# Storage - Optional for server-side components only (mobile apps use HTTP API) -rocksdb = { version = "0.24.0", features = ["multi-threaded-cf"], optional = true } once_cell = "1.19" # Removed dsm-storage-node dependency to avoid circular dependency @@ -142,10 +129,6 @@ env_logger = "0.11.10" # Type safety and validation derive_more = { version = "2.1.1", features = ["from", "display", "error", "debug", "into"] } -# Web framework and middleware (optional; core must be network-agnostic by default; no JSON feature) -axum = { version = "0.8", features = ["tracing"], optional = true } -tower = { version = "0.5.3", features = ["util", "timeout", "load-shed", "limit"], optional = true } - [dev-dependencies] mockall = "0.15.0" tempfile = "3.10.0" diff --git a/dsm_client/deterministic_state_machine/dsm/build.rs b/dsm_client/deterministic_state_machine/dsm/build.rs index 916eb238c..3590f3918 100644 --- a/dsm_client/deterministic_state_machine/dsm/build.rs +++ b/dsm_client/deterministic_state_machine/dsm/build.rs @@ -6,29 +6,10 @@ use std::env; use std::path::PathBuf; -use std::process::Command; - -fn rustc_version() -> Result> { - let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); - let output = Command::new(rustc).arg("--version").output()?; - if !output.status.success() { - return Err("failed to query rustc version".into()); - } - - Ok(String::from_utf8(output.stdout)?.trim().to_string()) -} fn main() -> Result<(), Box> { let out_dir = PathBuf::from(env::var("OUT_DIR")?); let vendored_include = protoc_bin_vendored::include_path()?; - let target = env::var("TARGET")?; - let rustc_version = rustc_version()?; - - println!("cargo:rustc-env=DSM_BUILD_TARGET={target}"); - println!("cargo:rustc-env=DSM_RUSTC_VERSION={rustc_version}"); - println!("cargo:rerun-if-env-changed=RUSTC"); - println!("cargo:rerun-if-env-changed=TARGET"); - // Canonical schema location is the repository root at `proto/`. // Allow override via DSM_PROTO_ROOT, but default to the repo-root canonical path. let proto_root = env::var("DSM_PROTO_ROOT") diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/deterministic_id.rs b/dsm_client/deterministic_state_machine/dsm/src/common/deterministic_id.rs deleted file mode 100644 index cb4b86dfa..000000000 --- a/dsm_client/deterministic_state_machine/dsm/src/common/deterministic_id.rs +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Deterministic ID Generation (No UUID, No Wall-Clock) -//! -//! This module provides deterministic, reproducible ID generation for all DSM components. -//! All IDs are derived from cryptographic hashes, never random UUIDs. -//! -//! Constraints: -//! - No UUID::new_v4() or UUID::now_v7() (non-deterministic) -//! - No wall-clock unix_tss -//! - All IDs are reproducible from explicit inputs - -use crate::crypto::blake3::dsm_domain_hasher; - -/// Generate a deterministic ID from domain-separated hash of inputs -/// -/// # Arguments -/// * `domain` - Domain separator (e.g., "DSM/tx-id", "DSM/msg-id") -/// * `inputs` - Variable number of byte slices to hash -/// -/// # Returns -/// Hex string of first 16 bytes of BLAKE3 hash (UUID-compatible format) -pub fn derive_id_from_hash(domain: &str, inputs: &[&[u8]]) -> String { - let mut hasher = dsm_domain_hasher(crate::common::domain_tags::TAG_DSM_DETERMINISTIC_ID); - hasher.update(domain.as_bytes()); - - for input in inputs { - hasher.update(input); - } - - let hash = hasher.finalize(); - let bytes = hash.as_bytes(); - - // Take first 16 bytes and format as UUID-compatible string - // Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - format!( - "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], - bytes[6], bytes[7], - bytes[8], bytes[9], - bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] - ) -} - -/// Generate a deterministic session ID from participants and unix_ts-free context -pub fn generate_session_id(context: &[u8]) -> String { - derive_id_from_hash("DSM/session-id", &[context]) -} diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/mod.rs index 37da67504..3b76a45eb 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/mod.rs @@ -12,7 +12,6 @@ /// Centralized canonical encoding for cryptographic commitments pub mod canonical_encoding; /// Deterministic ID generation (no UUID, no wall-clock) -pub mod deterministic_id; /// Additional-device admission (§16.3 — existing device admits a new device into the tree) pub mod device_admission; pub mod device_tree; diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/random_walk.rs b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/random_walk.rs index 5a26aae6a..c3afaaf8c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/random_walk.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/random_walk.rs @@ -256,7 +256,6 @@ pub mod algorithms { mod tests { use super::*; use crate::crypto::blake3::dsm_domain_hasher; - use crate::core::state_machine::utils; struct TestCsprng { current: [u8; 32], @@ -409,20 +408,5 @@ pub mod algorithms { .unwrap(); assert!(!result2); } - - #[test] - fn test_calculate_next_entropy() { - let current_entropy = b"current_entropy"; - let operation = b"operation"; - - // Same inputs → same entropy (deterministic) - let entropy1 = utils::calculate_next_entropy(current_entropy, operation, &[0u8; 32]); - let entropy2 = utils::calculate_next_entropy(current_entropy, operation, &[0u8; 32]); - assert_eq!(entropy1, entropy2); - - // Different parent hash → different entropy - let entropy3 = utils::calculate_next_entropy(current_entropy, operation, &[0x01; 32]); - assert_ne!(entropy1, entropy3); - } } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/utils.rs b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/utils.rs index 2874fbad2..68b35a150 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/utils.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/utils.rs @@ -5,9 +5,6 @@ //! This module contains common utility functions used across the state machine //! implementation, ensuring consistent behavior and reducing duplication. -use blake3; -use crate::common::domain_tags::TAG_STATE_HASH; - /// Perform constant-time equality comparison to prevent timing attacks /// /// This function implements constant-time comparison for cryptographic values, @@ -25,38 +22,6 @@ pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { result == 0 } -/// Domain-separated BLAKE3 hash for general state machine operations. -/// -/// Uses the `"DSM/state-hash"` domain tag per the whitepaper mandate that all -/// production hashing must be domain-separated: `BLAKE3("DSM/\0" || data)`. -pub fn hash_blake3(data: &[u8]) -> blake3::Hash { - crate::crypto::blake3::domain_hash(TAG_STATE_HASH, data) -} - -// verify_state_hash(&State) deleted: only caller was relationship.rs::validate_transition -// (also dead). HashChain has its own verify_state_hash impl for chain-internal use. - -/// Calculate the next entropy based on current entropy, operation, and state number -/// -/// Implements the deterministic entropy evolution from whitepaper §11 eq. 14: -/// `e_{n+1} = H("DSM/next-entropy" || e_n || op || H(S_n))`. Per §4.3 no -/// counter participates — adjacency comes from the parent hash. -pub fn calculate_next_entropy( - current_entropy: &[u8], - operation_bytes: &[u8], - parent_hash: &[u8; 32], -) -> [u8; 32] { - let mut hasher = - crate::crypto::blake3::dsm_domain_hasher(crate::common::domain_tags::TAG_DSM_NEXT_ENTROPY); - hasher.update(current_entropy); - hasher.update(operation_bytes); - hasher.update(parent_hash); - - *hasher.finalize().as_bytes() -} - -// create_test_transition() deleted: zero callers (no other tests imported it). - #[cfg(test)] mod tests { use super::*; @@ -71,14 +36,4 @@ mod tests { assert!(!constant_time_eq(&a, &c)); assert!(!constant_time_eq(&a, &[1, 2, 3])); } - - #[test] - fn test_hash_blake3() { - let data = b"test data"; - let hash = hash_blake3(data); - - // hash_blake3 uses the TAG_STATE_HASH domain internally - let expected = crate::crypto::blake3::domain_hash(TAG_STATE_HASH, data); - assert_eq!(hash.as_bytes(), expected.as_bytes()); - } } diff --git a/dsm_client/deterministic_state_machine/dsm/src/crypto/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/crypto/mod.rs index c9a51e0d5..b07547043 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/crypto/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/crypto/mod.rs @@ -142,19 +142,6 @@ pub fn hash_multiple(parts: &[&[u8]]) -> Vec { hasher.finalize().as_bytes().to_vec() } -// ===== Initialization ===== - -/// Initialize crypto subsystems used by DSM. -pub fn init_crypto() -> Result<(), DsmError> { - // Kyber KEM/AES - kyber::init_kyber()?; - - // SPHINCS+ (ensures self-tests run at startup) - sphincs::init_sphincs()?; - - Ok(()) -} - // ===== Nonce generation ===== // Notes: // - AES-GCM requires a 96-bit (12-byte) nonce. Use `generate_gcm_nonce`. diff --git a/dsm_client/deterministic_state_machine/dsm/src/crypto/sphincs.rs b/dsm_client/deterministic_state_machine/dsm/src/crypto/sphincs.rs index 0778f5e33..52c9ec609 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/crypto/sphincs.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/crypto/sphincs.rs @@ -59,7 +59,6 @@ use rand::rngs::OsRng; use rand::{RngCore, SeedableRng, TryRngCore}; use rand_chacha::ChaCha20Rng; use subtle::ConstantTimeEq; -use tracing::{debug, error, info}; use zeroize::{Zeroize, ZeroizeOnDrop}; #[cfg(feature = "sphincs-trace")] @@ -1109,27 +1108,6 @@ pub fn signature_bytes(v: SphincsVariant) -> usize { param_set(v).sig_bytes } -// ================================ Init ====================================== - -pub fn init_sphincs() -> Result<(), DsmError> { - // self-test a small variant for sanity and log supported variants - info!("Initializing SPHINCS+ (BLAKE3-only) with 6 parameter sets"); - let v = SphincsVariant::SPX128s; - let kp = generate_keypair(v)?; - let msg = b"SPHINCS+ self-test message"; - let sig = sign(v, &kp.secret_key, msg)?; - let ok = verify(v, &kp.public_key, msg, &sig)?; - if !ok { - error!("SPHINCS+ self-test failed"); - return Err(DsmError::crypto( - "SPHINCS+ self-test failure".to_string(), - None::, - )); - } - debug!("SPHINCS+ self-test passed for {:?}", v); - Ok(()) -} - // ===================== Default Variant Wrappers ========================== /// Generate SPHINCS+ keypair using default variant (SPX256f). diff --git a/dsm_client/deterministic_state_machine/dsm/src/lib.rs b/dsm_client/deterministic_state_machine/dsm/src/lib.rs index 5b76bf81d..783aae283 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/lib.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/lib.rs @@ -101,8 +101,6 @@ pub mod verification; use crate::types::error::DsmError; const VERSION: &str = env!("CARGO_PKG_VERSION"); -const RUST_VERSION: &str = env!("DSM_RUSTC_VERSION"); -const TARGET: &str = env!("DSM_BUILD_TARGET"); /// Returns the version of the SDK /// @@ -114,57 +112,3 @@ const TARGET: &str = env!("DSM_BUILD_TARGET"); pub fn version() -> String { VERSION.to_string() } - -/// Build information for debugging and support -pub fn build_info() -> BuildInfo { - BuildInfo { - version: VERSION.to_string(), - rust_version: RUST_VERSION.to_string(), - target: TARGET.to_string(), - features: get_enabled_features(), - } -} - -/// Build information structure -#[derive(Debug, Clone)] -pub struct BuildInfo { - /// SDK version - pub version: String, - /// Rust compiler version - pub rust_version: String, - /// Target architecture - pub target: String, - /// Enabled features - pub features: Vec, -} - -#[allow(unused_mut)] -#[allow(clippy::vec_init_then_push)] -fn get_enabled_features() -> Vec { - let mut features = vec![]; - // JNI moved to dsm_sdk - #[cfg(feature = "bluetooth")] - features.push("bluetooth".to_string()); - #[cfg(feature = "storage")] - features.push("storage".to_string()); - #[cfg(feature = "threadsafe")] - features.push("threadsafe".to_string()); - features -} - -#[cfg(test)] -mod tests { - use super::{build_info, version, VERSION}; - - #[test] - fn build_info_is_compile_time_stamped() { - let info = build_info(); - - assert_eq!(version(), VERSION); - assert_eq!(info.version, VERSION); - assert_ne!(info.rust_version, "unknown"); - assert!(!info.rust_version.is_empty()); - assert_ne!(info.target, "unknown"); - assert!(!info.target.is_empty()); - } -} diff --git a/dsm_client/deterministic_state_machine/dsm/src/pbi.rs b/dsm_client/deterministic_state_machine/dsm/src/pbi.rs index dc3b4b24c..9f64b6ada 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/pbi.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/pbi.rs @@ -1,54 +1,36 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Platform Boundary Interface (PBI) +//! Platform boundary: the identity pair the platform hands the SDK. //! -//! This module implements the hard platform boundary described in the public -//! protocol and security documentation. -//! It acts as the sole entry point for ingesting raw, non-deterministic platform inputs (IO, JNI, Entropy) -//! and transforming them into canonical, immutable, cryptographic types *before* they touch the Core State Machine. -//! -//! # Architecture -//! -//! 1. **Ingestion**: Raw bytes from JNI/Platform are accepted. -//! 2. **Canonization**: Inputs are immediately hashed/validated into domain-separated types. -//! 3. **Context Creation**: A `PlatformContext` is built. This is the ONLY object the Core trusts. -//! -//! # Invariants -//! -//! - No raw `Vec` or `String` inputs allowed deep in the core. -//! - All inputs must be length-checked and domain-separated immediately. +//! The platform (JNI/Kotlin) hands over the persisted device id and genesis hash +//! as raw bytes. This module turns them into fixed-size arrays and nothing else: +//! each must be exactly 32 bytes. It does not hash, derive or verify them; whether +//! they name a real identity is established by the SDK's identity restore and by +//! the signing authority that derives from them. use crate::types::error::DsmError; use zeroize::{Zeroize, ZeroizeOnDrop}; -/// Canonical, immutable platform context. -/// This is the "Safe" object that the Core consumes. +/// The identity pair as two 32-byte arrays. #[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)] pub struct PlatformContext { - /// Canonical Device ID (32 bytes) + /// Device ID (32 bytes) pub device_id: [u8; 32], - /// Canonical Genesis Hash (32 bytes) + /// Genesis hash (32 bytes) pub genesis_hash: [u8; 32], } -/// Raw inputs from the platform (JNI/Kotlin/Swift). -/// These are "unsafe" and must be processed immediately. +/// The pair as the platform hands it over: raw bytes of any length. pub struct RawPlatformInputs { pub device_id_raw: Vec, pub genesis_hash_raw: Vec, } impl PlatformContext { - /// The Single Point of Entry for bootstrapping the Core. - /// - /// This function consumes raw inputs and returns a sanitized Context or an Error. - /// It enforces the "Zero Tolerance" policy at the perimeter. + /// Fixes both identifiers to 32 bytes, or refuses the pair. pub fn bootstrap(inputs: RawPlatformInputs) -> Result { - // 1. Canonize Device ID - let device_id = Self::canonize_identifier(&inputs.device_id_raw, "DSM/devid\0")?; - - // 2. Canonize Genesis Hash - let genesis_hash = Self::canonize_identifier(&inputs.genesis_hash_raw, "DSM/genesis\0")?; + let device_id = Self::exactly_32_bytes(&inputs.device_id_raw)?; + let genesis_hash = Self::exactly_32_bytes(&inputs.genesis_hash_raw)?; Ok(Self { device_id, @@ -56,10 +38,7 @@ impl PlatformContext { }) } - /// Helper to validate and canonize 32-byte identifiers. - /// STRICTNESS: We expect the platform to pass the *pre-calculated* digests for IDs, - /// but we verify lengths strictly. - fn canonize_identifier(input: &[u8], _domain_tag: &str) -> Result<[u8; 32], DsmError> { + fn exactly_32_bytes(input: &[u8]) -> Result<[u8; 32], DsmError> { if input.len() != 32 { return Err(DsmError::Validation { context: format!( @@ -87,17 +66,17 @@ mod tests { } #[test] - fn canonize_identifier_exact_32_bytes() { + fn exactly_32_bytes_exact_32_bytes() { let input = vec![0x42u8; 32]; - let result = PlatformContext::canonize_identifier(&input, "DSM/test\0"); + let result = PlatformContext::exactly_32_bytes(&input); assert!(result.is_ok()); assert_eq!(result.unwrap(), [0x42u8; 32]); } #[test] - fn canonize_identifier_too_short() { + fn exactly_32_bytes_too_short() { let input = vec![0x01u8; 16]; - let result = PlatformContext::canonize_identifier(&input, "DSM/test\0"); + let result = PlatformContext::exactly_32_bytes(&input); assert!(result.is_err()); match result.unwrap_err() { DsmError::Validation { context, .. } => { @@ -109,9 +88,9 @@ mod tests { } #[test] - fn canonize_identifier_too_long() { + fn exactly_32_bytes_too_long() { let input = vec![0x01u8; 64]; - let result = PlatformContext::canonize_identifier(&input, "DSM/test\0"); + let result = PlatformContext::exactly_32_bytes(&input); assert!(result.is_err()); match result.unwrap_err() { DsmError::Validation { context, .. } => { @@ -123,15 +102,15 @@ mod tests { } #[test] - fn canonize_identifier_empty() { - let result = PlatformContext::canonize_identifier(&[], "DSM/test\0"); + fn exactly_32_bytes_empty() { + let result = PlatformContext::exactly_32_bytes(&[]); assert!(result.is_err()); } #[test] - fn canonize_identifier_preserves_bytes() { + fn exactly_32_bytes_preserves_bytes() { let input: Vec = (0..32).collect(); - let arr = PlatformContext::canonize_identifier(&input, "DSM/devid\0").unwrap(); + let arr = PlatformContext::exactly_32_bytes(&input).unwrap(); assert_eq!(&arr[..], &input[..]); } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/storage_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/storage_routes.rs index b55ca3453..38fc389ef 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/storage_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/storage_routes.rs @@ -1124,6 +1124,19 @@ async fn deliver_pending_acceptance_replies( } }; + // The post-admission RELEASE (3.5b PR4): the sweep only sees promoted + // rows, so a row carries its release by construction. A row without one + // is a local defect — the sender refuses a bare delta — and stays + // unmarked and visible rather than going out with an empty release. + let Some(release_bytes) = reply.release_bytes.as_deref() else { + log::error!( + "[storage.sync] §16.6 reply for commitment {}.. has no post-admission release — \ + local defect, left unmarked", + crate::util::text_id::encode_base32_crockford(&reply.commitment[..4]), + ); + continue; + }; + // NOTE: the envelope is built from `dsm::types::proto`, which is a SEPARATE // prost generation from `crate::generated` — same schema, distinct Rust types. let mut b0x = match crate::sdk::b0x_sdk::B0xSDK::new( @@ -1149,11 +1162,7 @@ async fn deliver_pending_acceptance_replies( &reply.commitment, &reply.receipt_bytes, (reply.applied_parent_tip_b, reply.applied_child_tip_b), - // The post-admission RELEASE (3.5b PR4): the sweep only sees - // promoted rows, so a Some here is admission-terminal by - // construction. Rows from before the release existed carry - // none and the sender refuses them — beta clean cut. - reply.release_bytes.as_deref().unwrap_or_default(), + release_bytes, ) .await { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs index 7bbc71dbd..04ca2688e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs @@ -1016,13 +1016,21 @@ impl AppRouterImpl { } } - let resp = generated::BilateralPrepareResponse { - commitment_hash: Some(generated::Hash32 { + // This device's own answer: the prepare went out and the proposal + // is identified by its commitment. The peer's prepare response, + // when it arrives over BLE, is a different message. + let resp = generated::BilateralTransferResponse { + success: true, + transaction_hash: Some(generated::Hash32 { v: commitment_hash.to_vec(), }), - ..Default::default() + message: "prepare sent over BLE; the transfer completes when the peer's \ + response arrives" + .to_string(), }; - pack_envelope_ok(generated::envelope::Payload::BilateralPrepareResponse(resp)) + pack_envelope_ok(generated::envelope::Payload::BilateralTransferResponse( + resp, + )) } #[cfg(not(all(target_os = "android", feature = "bluetooth", feature = "jni")))] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs index fe0e33cf5..62deb8160 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/ingress.rs @@ -69,309 +69,15 @@ fn push_canonical_envelope_event(payload: pb::envelope::Payload) -> Result<(), p } /// Push a genesis lifecycle event to the WebView (EventBridge maps kinds to the -/// `genesis.securing-device*` topics the frontend renders). `pub(crate)` so the canonical -/// Genesis v2 route (`system.createGenesisV2` in `handlers::system_routes`) drives the SAME -/// securing-screen rail as this legacy bootstrap path. +/// `genesis.securing-device*` topics the frontend renders). `pub(crate)` for the +/// canonical Genesis v2 route (`system.createGenesisV2` in `handlers::system_routes`), +/// which drives the securing-screen rail. pub(crate) fn push_genesis_lifecycle_event(kind: i32, progress: u32) -> Result<(), pb::Error> { push_canonical_envelope_event(pb::envelope::Payload::GenesisLifecycle( pb::GenesisLifecycleEvent { kind, progress }, )) } -fn bootstrap_finalize_envelope( - result: i32, - device_id: Vec, - genesis_hash: Vec, - message: impl Into, -) -> Envelope { - crate::envelope::local_answer(pb::envelope::Payload::BootstrapFinalizeResponse( - pb::BootstrapFinalizeResponse { - result, - device_id, - genesis_hash, - message: message.into(), - }, - )) -} - -fn startup_initialize_identity_context( - device_id: Vec, - genesis_hash: Vec, -) -> Result<(), pb::Error> { - match dispatch_startup(StartupRequest { - operation: Some(startup_request::Operation::InitializeIdentityContext( - pb::InitializeIdentityContextOp { - device_id, - genesis_hash, - }, - )), - }) - .result - { - Some(startup_response::Result::OkBytes(_)) => Ok(()), - Some(startup_response::Result::Error(error)) => Err(error), - None => Err(ingress_error( - ERROR_CODE_PROCESSING_FAILED, - "startup: empty initialize identity response", - )), - } -} - -fn finalize_bootstrap_core(report: pb::BootstrapMeasurementReport) -> Result { - log::info!( - "FINALIZE_BOOTSTRAP: ENTRY phase={} trust={}", - report.phase, - report.trust_level - ); - // Scope guard: keep BOOTSTRAP_SECURING=true until this function exits, then clear it - // unconditionally. This preserves phase=securing_device throughout the whole finalize - // (including startup_initialize_identity_context which writes the identity), so any - // concurrent session state read observes securing_device → wallet_ready atomically - // instead of the prior race where the flag was cleared BEFORE has_identity became true, - // exposing a transient phase=needs_genesis flash in the UI. - struct ClearBootstrapSecuringOnDrop; - impl Drop for ClearBootstrapSecuringOnDrop { - fn drop(&mut self) { - log::info!("FINALIZE_BOOTSTRAP: SCOPE_GUARD_DROP clearing BOOTSTRAP_SECURING=false"); - crate::sdk::session_manager::BOOTSTRAP_SECURING - .store(false, std::sync::atomic::Ordering::SeqCst); - log::info!( - "FINALIZE_BOOTSTRAP: POST_DROP BOOTSTRAP_SECURING={} SDK_READY={} has_id={}", - crate::sdk::session_manager::BOOTSTRAP_SECURING - .load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::session_manager::SDK_READY.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::app_state::AppState::get_has_identity() - ); - } - } - let _clear_on_exit = ClearBootstrapSecuringOnDrop; - - if report.device_id.len() != 32 { - return Err(ingress_error( - ERROR_CODE_INVALID_INPUT, - format!( - "bootstrap_finalize: device_id must be 32 bytes, got {}", - report.device_id.len() - ), - )); - } - if report.genesis_hash.len() != 32 { - return Err(ingress_error( - ERROR_CODE_INVALID_INPUT, - format!( - "bootstrap_finalize: genesis_hash must be 32 bytes, got {}", - report.genesis_hash.len() - ), - )); - } - - let device_id = report.device_id.clone(); - let genesis_hash = report.genesis_hash.clone(); - - // Strict enforcement: no feature gate and no default-allow path. - // A ReadOnly trust level from the bootstrap measurement means the device - // failed the C-DBRW entropy health test and MUST NOT be allowed to proceed - // through genesis creation. The caller surface returns a BootstrapResultReadOnly - // envelope and the genesis lifecycle emits an error event for telemetry. - match report.trust_level { - x if x - == pb::bootstrap_measurement_report::TrustLevel::BootstrapTrustLevelReadOnly as i32 => - { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - return Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultReadOnly as i32, - device_id, - genesis_hash, - "bootstrap rejected by Rust: read-only trust state", - )); - } - x if x - == pb::bootstrap_measurement_report::TrustLevel::BootstrapTrustLevelBlocked as i32 => - { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - return Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultBlocked as i32, - device_id, - genesis_hash, - "bootstrap rejected by Rust: blocked trust state", - )); - } - x if x - == pb::bootstrap_measurement_report::TrustLevel::BootstrapTrustLevelUnspecified - as i32 => - { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - return Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultRejected as i32, - device_id, - genesis_hash, - "bootstrap rejected by Rust: missing trust level", - )); - } - _ => {} - } - - let context = PlatformContext::bootstrap(RawPlatformInputs { - device_id_raw: device_id.clone(), - genesis_hash_raw: genesis_hash.clone(), - }) - .map_err(|e| { - ingress_error( - ERROR_CODE_PROCESSING_FAILED, - format!("bootstrap_finalize: PlatformContext::bootstrap failed: {e}"), - ) - })?; - - if let Err(error) = startup_initialize_identity_context( - context.device_id.to_vec(), - context.genesis_hash.to_vec(), - ) { - log::error!( - "FLASH_DEBUG: FINALIZE_BOOTSTRAP: startup_initialize_identity_context FAILED err={} BOOTSTRAP_SECURING={} SDK_READY={} has_id={}", - error.message, - crate::sdk::session_manager::BOOTSTRAP_SECURING.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::session_manager::SDK_READY.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::app_state::AppState::get_has_identity() - ); - let message = match push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - ) { - Ok(()) => error.message, - Err(push_error) => format!( - "{}; the error lifecycle event was not delivered: {}", - error.message, push_error.message - ), - }; - return Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultError as i32, - device_id, - genesis_hash, - message, - )); - } - - // CRITICAL EVIDENCE POINT: at this moment, startup_initialize_identity_context has - // returned successfully, which means prime_identity_app_state has already stored - // has_identity=true AND initialize_sdk_core has stored SDK_READY=true. The scope - // guard is still holding BOOTSTRAP_SECURING=true. If compute_phase runs at this - // exact instant it should return `securing_device` (not `wallet_ready` yet). The - // scope guard drops only after we return from finalize_bootstrap_core below. - log::info!( - "FLASH_DEBUG: FINALIZE_BOOTSTRAP: IDENTITY_INSTALLED BOOTSTRAP_SECURING={} SDK_READY={} has_id={}", - crate::sdk::session_manager::BOOTSTRAP_SECURING.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::session_manager::SDK_READY.load(std::sync::atomic::Ordering::SeqCst), - crate::sdk::app_state::AppState::get_has_identity() - ); - - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindSecuringComplete as i32, - 0, - )?; - push_genesis_lifecycle_event(pb::genesis_lifecycle_event::Kind::GenesisKindOk as i32, 0)?; - - let ready_message = if report.trust_level - == pb::bootstrap_measurement_report::TrustLevel::BootstrapTrustLevelPinRequired as i32 - { - "bootstrap ready with degraded trust: PIN required" - } else { - "bootstrap ready" - }; - - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultReady as i32, - context.device_id.to_vec(), - context.genesis_hash.to_vec(), - ready_message, - )) -} - -fn handle_bootstrap_measurement_report_core( - report: pb::BootstrapMeasurementReport, -) -> Result { - match report.phase { - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseStarted as i32 => { - // Mark that C-DBRW securing is in progress — session manager returns - // "securing_device" phase until finalization completes. - crate::sdk::session_manager::BOOTSTRAP_SECURING - .store(true, std::sync::atomic::Ordering::SeqCst); - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindStarted as i32, - 0, - )?; - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindSecuringDevice as i32, - 0, - )?; - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultUnspecified as i32, - report.device_id, - report.genesis_hash, - "bootstrap measurement started", - )) - } - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseProgress as i32 => { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindSecuringProgress as i32, - report.progress_percent, - )?; - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultUnspecified as i32, - report.device_id, - report.genesis_hash, - "bootstrap progress", - )) - } - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseFinalize as i32 - || x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseResumeFinalize - as i32 => - { - finalize_bootstrap_core(report) - } - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseAborted as i32 => { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindSecuringAborted as i32, - 0, - )?; - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultAborted as i32, - report.device_id, - report.genesis_hash, - report.error_message, - )) - } - x if x == pb::bootstrap_measurement_report::Phase::BootstrapPhaseError as i32 => { - push_genesis_lifecycle_event( - pb::genesis_lifecycle_event::Kind::GenesisKindError as i32, - 0, - )?; - Ok(bootstrap_finalize_envelope( - pb::bootstrap_finalize_response::Result::BootstrapResultError as i32, - report.device_id, - report.genesis_hash, - report.error_message, - )) - } - _ => Err(ingress_error( - ERROR_CODE_INVALID_INPUT, - format!("bootstrap measurement: unsupported phase {}", report.phase), - )), - } -} - fn process_envelope_core(envelope_in: Envelope) -> Result { crate::envelope::validate_envelope_v3(&envelope_in).map_err(|e| { ingress_error( @@ -380,12 +86,6 @@ fn process_envelope_core(envelope_in: Envelope) -> Result { ) })?; - if let Some(pb::envelope::Payload::BootstrapMeasurementReport(report)) = - envelope_in.payload.clone() - { - return handle_bootstrap_measurement_report_core(report); - } - let mut raw = Vec::new(); match envelope_in.encode(&mut raw) { Ok(()) => {} @@ -1279,7 +979,7 @@ mod tests { #[test] #[serial] - fn startup_initialize_identity_context_sets_identity_and_router() { + fn initialize_identity_context_sets_identity_and_router() { let identity = restarted_device(0x11); install_identity_context_core(identity.device_id.to_vec(), identity.genesis.to_vec()) .expect("identity context install should succeed"); @@ -1302,7 +1002,7 @@ mod tests { #[test] #[serial] - fn startup_initialize_identity_context_via_dispatch_succeeds() { + fn initialize_identity_context_via_dispatch_succeeds() { let identity = restarted_device(0x13); let fleet = fleet(); let response = dispatch_startup(StartupRequest { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/b0x_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/b0x_sdk.rs index 129c2d647..9a82ef18b 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/b0x_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/b0x_sdk.rs @@ -1846,55 +1846,26 @@ impl B0xSDK { // signature already lives in OnlineTransferRequest.signature / OnlineMessageRequest.signature. // Do NOT duplicate that signature into EvidenceOracle.signature for b0x transport, // or envelopes can exceed storage-node body limits (HTTP 413). - // Keep only oracle_key in evidence so receivers can still verify without extra lookups. // - // Source of truth: signing_authority derives the pk deterministically from - // (genesis_hash, device_id, C-DBRW binding key) — the SAME derivation that - // produces the secret key used by `wallet.sign_operation_bytes`. Embedding - // this pk guarantees the receiver's sphincs_verify uses the same pk that - // produced the signature; using `state.device_info.public_key` or - // `AppState::get_public_key()` can drift (stale genesis pk, fallback - // 32-byte placeholder, etc.) and silently poison the inbox. - // DEADLOCK: the fallback here used to be `self.core_sdk.get_current_state()`, - // which takes the `state_machine` lock (core_sdk.rs:420). This builder runs - // inside `pre_write`, where that NON-REENTRANT parking_lot mutex is ALREADY - // held (core_sdk.rs:1116). Re-locking it hangs silently — no panic, no error. - // - // The caller has already resolved this key fail-closed, so prefer the value - // it handed us over re-deriving one. That removes the re-entry AND removes a - // live source of public-key drift: the param was previously length-validated - // and then ignored. - let sender_signing_public_key = match crate::sdk::signing_authority::current_public_key() { - Ok(pk) => pk, - Err(e) if !params.sender_signing_public_key.is_empty() => { - log::warn!( - "submit_to_b0x: signing_authority pk unavailable ({e}); using caller-supplied \ - sender_signing_public_key" - ); - params.sender_signing_public_key.clone() - } - Err(e) => { - log::warn!( - "submit_to_b0x: signing_authority pk unavailable ({e}) and caller supplied \ - none; falling back to persisted app-state pk" - ); - crate::sdk::app_state::AppState::get_public_key().unwrap_or_default() - } - }; - - let evidence = if !sender_signing_public_key.is_empty() { + // The evidence carries only the sender's signing public key, exactly as the + // caller resolved it from the signing authority (the routes fail closed when + // it is unavailable; `validate_submission_params` requires 64 bytes). The + // receiver roots verification in its STORED contact and treats a disagreeing + // wire key as a signal, so nothing here substitutes another key for it: no + // re-derivation under the state lock, no persisted app-state key, no + // default. + let evidence = if params.sender_signing_public_key.is_empty() { + None + } else { Some(dsm::types::proto::Evidence { kind: Some(dsm::types::proto::evidence::Kind::Oracle( dsm::types::proto::EvidenceOracle { payload: vec![], signature: vec![], - // Carry sender signing public key so receivers can verify without contact lookups. - oracle_key: sender_signing_public_key.clone(), + oracle_key: params.sender_signing_public_key.clone(), }, )), }) - } else { - None }; let invoke = dsm::types::proto::Invoke { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/recipient_staging.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/recipient_staging.rs index fa0b43102..1342185dd 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/recipient_staging.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/recipient_staging.rs @@ -412,20 +412,25 @@ pub fn counterparty_has_unconverged_inbound(counterparty_device_id: &[u8]) -> Re }, )? .collect::>>()?; + // A frozen half the store holds but cannot read is an error, never "nothing + // in flight": the barrier must not let a send cross a transfer it cannot see. for (transfer, evidence) in rows { if let Some(t) = transfer { - if let Ok(req) = dsm::types::proto::OnlineTransferRequest::decode(t.as_slice()) { - if req.from_device_id.as_slice() == counterparty_device_id { - return Ok(true); - } + let req = + dsm::types::proto::OnlineTransferRequest::decode(t.as_slice()).map_err(|e| { + anyhow!("recipient_staging: a staged transfer half does not decode: {e}") + })?; + if req.from_device_id.as_slice() == counterparty_device_id { + return Ok(true); } } if let Some(e) = evidence { - if let Ok(r) = dsm::types::receipt_types::StitchedReceiptV2::from_canonical_protobuf(&e) - { - if r.devid_a.as_slice() == counterparty_device_id { - return Ok(true); - } + let r = dsm::types::receipt_types::StitchedReceiptV2::from_canonical_protobuf(&e) + .map_err(|e| { + anyhow!("recipient_staging: a staged evidence half does not decode: {e}") + })?; + if r.devid_a.as_slice() == counterparty_device_id { + return Ok(true); } } } @@ -545,6 +550,44 @@ mod tests { ) } + /// The finality barrier reads the counterparty from the frozen half itself. + #[test] + #[serial] + fn an_unconverged_inbound_half_names_its_sender() { + use prost::Message; + fresh_db(); + let peer = [0x5Au8; 32]; + let half = dsm::types::proto::OnlineTransferRequest { + from_device_id: peer.to_vec(), + ..Default::default() + } + .encode_to_vec(); + stage_transfer_half("XFER-PEER", &half, &digest_of(&evidence(0xC1)), "TESTROUTE") + .expect("stage transfer"); + assert!(counterparty_has_unconverged_inbound(&peer).expect("readable")); + assert!(!counterparty_has_unconverged_inbound(&[0x5Bu8; 32]).expect("readable")); + } + + /// A staged half the store holds but cannot decode is an error, never + /// "nothing in flight": the barrier must not let a send cross a transfer it + /// cannot read. + #[test] + #[serial] + fn a_staged_half_that_does_not_decode_is_an_error_not_absence() { + fresh_db(); + // A lone field tag with no value: prost refuses it. + stage_transfer_half( + "XFER-BAD", + b"\x08", + &digest_of(&evidence(0xC3)), + "TESTROUTE", + ) + .expect("stage transfer"); + let err = counterparty_has_unconverged_inbound(&[0x5Au8; 32]) + .expect_err("an unreadable half is an error"); + assert!(err.to_string().contains("does not decode"), "{err}"); + } + /// Transfer first, then a restart, then evidence. The staged half must /// survive the restart and the pair must become ready. #[test] diff --git a/dsm_client/frontend/public/index.html b/dsm_client/frontend/public/index.html index c14477649..cf843c0c0 100644 --- a/dsm_client/frontend/public/index.html +++ b/dsm_client/frontend/public/index.html @@ -16,8 +16,8 @@ script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; - img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://demotiles.maplibre.org; - connect-src 'self' https://*.dsm-wallet.io https://demotiles.maplibre.org https://*.tile.openstreetmap.org https://tile.openstreetmap.org <% if (htmlWebpackPlugin && htmlWebpackPlugin.options && htmlWebpackPlugin.options.templateParameters && htmlWebpackPlugin.options.templateParameters.buildTarget === 'android') { %> http://localhost:8080 http://localhost:8081 http://localhost:8082 http://localhost:8083 http://localhost:8084 http://127.0.0.1:8080 http://127.0.0.1:8081 http://127.0.0.1:8082 http://127.0.0.1:8083 http://127.0.0.1:8084 <% } %>; + img-src 'self' data: blob:; + connect-src 'self'; worker-src 'self' blob:; object-src 'none';" /> @@ -1886,6 +1886,8 @@ // Single shared MessagePort onmessage handler (ID-based routing) function installPortHandler(p) { p.onmessage = (e) => { + // Declared outside the try so the catch can release the pending entry. + let respId; try { let bytes; const data = e.data; @@ -1928,7 +1930,7 @@ const idView = new DataView(bytes.buffer, bytes.byteOffset, 8); const hi = idView.getUint32(0, false); // big-endian high 32 bits const lo = idView.getUint32(4, false); // big-endian low 32 bits - const respId = (BigInt(hi) << 32n) | BigInt(lo); + respId = (BigInt(hi) << 32n) | BigInt(lo); // Lookup pending request by ID const state = pending.get(respId); @@ -2014,14 +2016,23 @@ try { console.log('[DSM] Bridge interface installed, waiting for port...'); } catch (_) {} - // Listen for MessagePort from Android + // The bridge port: Kotlin posts it once, as an empty-string message + // carrying the port. It is taken once; any later port-bearing message, + // or one without that handshake, is not the bridge and is ignored. window.addEventListener('message', (ev) => { - if (ev.ports && ev.ports[0]) { - port = ev.ports[0]; - installPortHandler(port); - resolvePortWaiters(port); - window.dispatchEvent(new Event('dsm-bridge-ready')); + if (!(ev.ports && ev.ports[0])) return; + if (port) { + console.warn('[DSM] Ignoring a second MessagePort delivery'); + return; } + if (ev.data !== '') { + console.warn('[DSM] Ignoring a MessagePort without the bridge handshake'); + return; + } + port = ev.ports[0]; + installPortHandler(port); + resolvePortWaiters(port); + window.dispatchEvent(new Event('dsm-bridge-ready')); }); })(); diff --git a/dsm_client/frontend/public/service-worker.js b/dsm_client/frontend/public/service-worker.js index dff089068..e5e3b199b 100644 --- a/dsm_client/frontend/public/service-worker.js +++ b/dsm_client/frontend/public/service-worker.js @@ -1,4 +1,4 @@ -/* DSM Service Worker - offline-first tiles + app shell */ +/* DSM Service Worker - offline-first app shell */ const VERSION = 'v1'; const APP_SHELL = [ '/', @@ -7,9 +7,7 @@ const APP_SHELL = [ // Add other critical assets if needed ]; -// Separate caches to keep tiles bounded const APP_CACHE = `dsm-app-${VERSION}`; -const TILE_CACHE = `dsm-tiles-${VERSION}`; self.addEventListener('install', (event) => { event.waitUntil( @@ -22,26 +20,13 @@ self.addEventListener('activate', (event) => { caches.keys().then((keys) => Promise.all( keys - .filter((k) => ![APP_CACHE, TILE_CACHE].includes(k)) + .filter((k) => k !== APP_CACHE) .map((k) => caches.delete(k)) ) ).then(() => self.clients.claim()) ); }); -// Helper: is tile request -function isTileRequest(url) { - try { - const u = new URL(url); - return ( - u.hostname.endsWith('tile.openstreetmap.org') || - u.hostname.endsWith('demotiles.maplibre.org') - ); - } catch (_) { - return false; - } -} - self.addEventListener('fetch', (event) => { const { request } = event; const url = request.url; @@ -49,28 +34,6 @@ self.addEventListener('fetch', (event) => { // Only handle GET if (request.method !== 'GET') return; - // Cache-first for map tiles with expiration - if (isTileRequest(url)) { - event.respondWith( - caches.open(TILE_CACHE).then(async (cache) => { - const cached = await cache.match(request); - if (cached) return cached; - try { - const resp = await fetch(request, { mode: 'cors' }); - // Only cache successful, opaque or basic responses - if (resp && (resp.status === 200 || resp.type === 'opaque')) { - cache.put(request, resp.clone()); - } - return resp; - } catch (err) { - // If offline and no cache, fall through (will fail) - return cached || Response.error(); - } - }) - ); - return; - } - // Network-first for app/json requests event.respondWith( (async () => { diff --git a/dsm_client/frontend/src/bridge/bridgeEvents.ts b/dsm_client/frontend/src/bridge/bridgeEvents.ts index 10b39998e..e57990712 100644 --- a/dsm_client/frontend/src/bridge/bridgeEvents.ts +++ b/dsm_client/frontend/src/bridge/bridgeEvents.ts @@ -8,7 +8,7 @@ export type BridgeEventMap = { 'session.state': NativeSessionSnapshot; 'identity.ready': void; 'wallet.refresh': { source: string; [k: string]: any }; - 'wallet.bilateralCommitted': { commitmentHash?: Uint8Array; counterpartyDeviceId?: Uint8Array; accepted?: boolean; committed?: boolean; rejected?: boolean }; + 'wallet.bilateralAccepted': { commitmentHash: Uint8Array; counterpartyDeviceId: Uint8Array }; 'wallet.creditReceived': { source: string; tokenId?: string; amount?: bigint | string | number; nextBalance?: bigint | string | number; creditCount?: number }; 'dsm.deterministicSafety': { classification: string; message?: string }; 'contact.bleMapped': { address: string; deviceId?: string; genesisHash?: string }; diff --git a/dsm_client/frontend/src/contexts/WalletContext.tsx b/dsm_client/frontend/src/contexts/WalletContext.tsx index 8df16e645..4557cdf04 100644 --- a/dsm_client/frontend/src/contexts/WalletContext.tsx +++ b/dsm_client/frontend/src/contexts/WalletContext.tsx @@ -63,7 +63,7 @@ export const WalletContext = createContext(defaultValue); export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { notifyToast } = useUX(); const state = useWalletStore(); - const bilateralSignal = useEventSignal('wallet.bilateralCommitted'); + const bilateralSignal = useEventSignal('wallet.bilateralAccepted'); const refreshWalletProjection = React.useCallback(async () => { try { diff --git a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralAccepted.test.tsx similarity index 94% rename from dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx rename to dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralAccepted.test.tsx index a52717a4c..1ac2ff774 100644 --- a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralCommitted.test.tsx +++ b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralAccepted.test.tsx @@ -40,7 +40,7 @@ describe('WalletContext bilateral committed event', () => { }); // An accepted transfer reaches the provider as two events from the accept - // path: `wallet.bilateralCommitted` (the signal) and `wallet.refresh` (the + // path: `wallet.bilateralAccepted` (the signal) and `wallet.refresh` (the // reload). The provider reloads once, on the second; it used to reload on // both. it('reloads once for an accepted transfer, on the accept path’s wallet.refresh', async () => { @@ -71,7 +71,7 @@ describe('WalletContext bilateral committed event', () => { // The signal alone reloads nothing. await act(async () => { - bridgeEvents.emit('wallet.bilateralCommitted', {} as any); + bridgeEvents.emit('wallet.bilateralAccepted', {} as any); await Promise.resolve(); await Promise.resolve(); }); @@ -106,7 +106,7 @@ describe('WalletContext bilateral committed event', () => { await renderWalletProvider(); await act(async () => { - bridgeEvents.emit('wallet.bilateralCommitted', { accepted: true } as any); + bridgeEvents.emit('wallet.bilateralAccepted', { accepted: true } as any); await Promise.resolve(); }); diff --git a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx index cbff88737..7d469de5a 100644 --- a/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx +++ b/dsm_client/frontend/src/contexts/__tests__/WalletContext.bilateralThrottle.test.tsx @@ -6,7 +6,12 @@ import { UXProvider } from '../UXContext'; import { WalletProvider } from '../WalletContext'; import GlobalToast from '../../components/GlobalToast'; import { dsmClient } from '@/dsm/index'; -import { emitBilateralCommitted } from '@/dsm/events'; +import { emitBilateralAccepted } from '@/dsm/events'; + +const accepted = { + commitmentHash: new Uint8Array(32).fill(0x11), + counterpartyDeviceId: new Uint8Array(32).fill(0x22), +}; describe('WalletContext bilateral event throttle & toast', () => { afterEach(() => { @@ -52,9 +57,9 @@ describe('WalletContext bilateral event throttle & toast', () => { // Rapidly dispatch 3 events at t=0 act(() => { - emitBilateralCommitted(); - emitBilateralCommitted(); - emitBilateralCommitted(); + emitBilateralAccepted(accepted); + emitBilateralAccepted(accepted); + emitBilateralAccepted(accepted); }); // Deterministic coalescing uses a microtask gate. Flush microtasks to allow it to run. @@ -68,8 +73,8 @@ describe('WalletContext bilateral event throttle & toast', () => { // A second burst: one more toast, still no reload from the signal. act(() => { - emitBilateralCommitted(); - emitBilateralCommitted(); + emitBilateralAccepted(accepted); + emitBilateralAccepted(accepted); }); await act(async () => { diff --git a/dsm_client/frontend/src/dsm/EventBridge.ts b/dsm_client/frontend/src/dsm/EventBridge.ts index bb3ac590f..a5c50b491 100644 --- a/dsm_client/frontend/src/dsm/EventBridge.ts +++ b/dsm_client/frontend/src/dsm/EventBridge.ts @@ -12,7 +12,6 @@ import { decodeNativeHostEventToLegacyTopic } from './NativeHostBridge'; import { dispatchNativeQrScannerActive } from './qrScannerState'; import { bytesToBase32CrockfordPrefix, encodeBase32Crockford } from '../utils/textId'; import { bridgeEvents } from '../bridge/bridgeEvents'; -import { emitDeterministicSafetyIfPresent } from '../utils/deterministicSafety'; import logger from '../utils/logger'; import type { NativeSessionSnapshot } from '../runtime/nativeSessionTypes'; @@ -121,33 +120,45 @@ function decodeSessionState(bytes: Uint8Array): NativeSessionSnapshot { throw new Error(`decodeSessionState: unexpected payload case '${payload?.case}'`); } const session = payload.value as pb.AppSessionStateProto; + // Rust fills every nested status on every snapshot (session_manager's + // compute_snapshot). A snapshot missing one is malformed, not a status of + // false, and is refused rather than filled in here. + const lock = session.lockStatus; + const hardware = session.hardwareStatus; + const ble = hardware?.ble; + const qr = hardware?.qr; + if (!lock || !hardware || !ble || !qr) { + throw new Error('decodeSessionState: the snapshot lacks its lock or hardware status'); + } return { received: true, phase: session.phase as NativeSessionSnapshot['phase'], identity_status: session.identityStatus as NativeSessionSnapshot['identity_status'], env_config_status: session.envConfigStatus as NativeSessionSnapshot['env_config_status'], lock_status: { - enabled: session.lockStatus?.enabled ?? false, - locked: session.lockStatus?.locked ?? false, - method: (session.lockStatus?.method || 'none') as NativeSessionSnapshot['lock_status']['method'], - lock_on_pause: session.lockStatus?.lockOnPause ?? true, + enabled: lock.enabled, + locked: lock.locked, + // Rust spells the method itself ("none" when there is no lock). + method: lock.method as NativeSessionSnapshot['lock_status']['method'], + lock_on_pause: lock.lockOnPause, }, hardware_status: { - app_foreground: session.hardwareStatus?.appForeground ?? true, + app_foreground: hardware.appForeground, ble: { - enabled: session.hardwareStatus?.ble?.enabled ?? false, - permissions_granted: session.hardwareStatus?.ble?.permissionsGranted ?? false, - scanning: session.hardwareStatus?.ble?.scanning ?? false, - advertising: session.hardwareStatus?.ble?.advertising ?? false, + enabled: ble.enabled, + permissions_granted: ble.permissionsGranted, + scanning: ble.scanning, + advertising: ble.advertising, }, qr: { - available: session.hardwareStatus?.qr?.available ?? true, - active: session.hardwareStatus?.qr?.active ?? false, - camera_permission: session.hardwareStatus?.qr?.cameraPermission ?? false, + available: qr.available, + active: qr.active, + camera_permission: qr.cameraPermission, }, }, + // Rust sends an empty string for no error. fatal_error: session.fatalError || null, - wallet_refresh_hint: Number(session.walletRefreshHint ?? 0), + wallet_refresh_hint: Number(session.walletRefreshHint), }; } @@ -203,16 +214,6 @@ export function initializeEventBridge(): void { return; } - if (topic === 'dsm.deterministicSafety') { - try { - const msg = new TextDecoder().decode(bytes); - emitDeterministicSafetyIfPresent(msg); - } catch { - // ignore - } - return; - } - // --- Lifecycle events from Kotlin (previously evaluateJavascript, now binary) --- // Re-dispatch as DOM CustomEvents so existing hooks work. diff --git a/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts b/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts index 5d0bca9bf..020d59651 100644 --- a/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts +++ b/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts @@ -5,6 +5,7 @@ import { getBridgeInstance } from '../bridge/BridgeRegistry'; import { bridgeEvents } from '../bridge/bridgeEvents'; import type { AndroidBridgeV3 } from './bridgeTypes'; import { IngressRequest, IngressResponse, RouterInvokeOp, RouterQueryOp } from '../proto/dsm_app_pb'; +import { emitDeterministicSafetyForError } from '../utils/deterministicSafety'; function mustBridge(): AndroidBridgeV3 { const bridge = getBridgeInstance(); @@ -51,6 +52,8 @@ function unwrapIngressResponse(responseBytes: Uint8Array): Uint8Array { return response.result.value; } if (response.result.case === 'error') { + // A refusal Rust tagged as deterministic safety is announced by its tag. + emitDeterministicSafetyForError(response.result.value); throw new Error(response.result.value?.message || 'ingress boundary error'); } throw new Error('ingress boundary returned no result'); diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts index 4c9600e82..2749626bd 100644 --- a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts +++ b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts @@ -15,7 +15,7 @@ import { import { bridgeEvents } from "../../bridge/bridgeEvents"; import { getBridgeInstance } from "../../bridge/BridgeRegistry"; import type { AndroidBridgeV3 } from "../bridgeTypes"; -import { emitDeterministicSafetyIfPresent } from "../../utils/deterministicSafety"; +; import { buildRouterInvokeIngressRequest, buildRouterQueryIngressRequest, @@ -48,7 +48,6 @@ export const toBytes = (bytes: Uint8Array): Uint8Array => { return out; }; - export class BridgeError extends Error { errorCode?: number; details?: unknown; @@ -79,18 +78,10 @@ const unwrapProtobufResponse = async (_method: string, buf: Uint8Array): Promise const hex = `0x${code.toString(16).toUpperCase()}`; let uiMessage = err.message ?? `Bridge error ${hex}`; - if (code === 460) { - uiMessage = `Transfer Rejected (Offline Mode) - Check peer connection [${hex}]`; - } else if (code === 404) { - uiMessage = `Item Not Found - State may be stale [${hex}]`; - } else if (code === 408) { - uiMessage = `Protocol Timeout - Peer did not respond [${hex}]`; - } else if (!uiMessage.includes(hex)) { + if (!uiMessage.includes(hex)) { uiMessage += ` [${hex}]`; } - emitDeterministicSafetyIfPresent(uiMessage); - const be = new BridgeError(code, uiMessage); be.details = err; @@ -106,7 +97,6 @@ const unwrapProtobufResponse = async (_method: string, buf: Uint8Array): Promise throw be; } const errorMessage = new TextDecoder().decode(buf); - emitDeterministicSafetyIfPresent(errorMessage); try { bridgeEvents.emit("bridge.error", { code: 0, message: errorMessage, debugB32: "" }); } catch (_e) { @@ -117,7 +107,6 @@ const unwrapProtobufResponse = async (_method: string, buf: Uint8Array): Promise if (e instanceof BridgeError) throw e; const errorMessage = new TextDecoder().decode(buf); - emitDeterministicSafetyIfPresent(errorMessage); try { bridgeEvents.emit("bridge.error", { code: 0, message: errorMessage, debugB32: "" }); } catch (_e) { diff --git a/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts b/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts index 259d36395..f0eb6b8f2 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts @@ -26,9 +26,9 @@ describe('bilateral accept event dispatch', () => { jest.restoreAllMocks(); }); - // One accept, one committed signal. It used to be dispatched as a window + // One accept, one accepted signal. It used to be dispatched as a window // event the adapter re-emitted on the bus, and emitted on the bus again. - test('acceptOfflineTransfer emits wallet.bilateralCommitted exactly once', async () => { + test('acceptOfflineTransfer emits wallet.bilateralAccepted exactly once', async () => { const commitmentHash = new Uint8Array(32).fill(2); const counterpartyDeviceId = new Uint8Array(32).fill(3); const env = new pb.Envelope({ @@ -51,15 +51,13 @@ describe('bilateral accept event dispatch', () => { }; const handler = jest.fn(); - const off = bridgeEvents.on('wallet.bilateralCommitted', handler as any); + const off = bridgeEvents.on('wallet.bilateralAccepted', handler as any); await acceptOfflineTransfer({ commitmentHash, counterpartyDeviceId }); off(); expect(handler).toHaveBeenCalledTimes(1); expect(handler.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ - accepted: true, - committed: true, commitmentHash, counterpartyDeviceId, })); diff --git a/dsm_client/frontend/src/dsm/__tests__/events.test.ts b/dsm_client/frontend/src/dsm/__tests__/events.test.ts index 6b12ef1e7..aec395381 100644 --- a/dsm_client/frontend/src/dsm/__tests__/events.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/events.test.ts @@ -22,7 +22,7 @@ jest.mock('../../bridge/bridgeEvents', () => { }; }); -import { emitWalletRefresh, emitBilateralCommitted } from '../events'; +import { emitWalletRefresh, emitBilateralAccepted } from '../events'; import { bridgeEvents } from '../../bridge/bridgeEvents'; describe('events.ts', () => { @@ -42,27 +42,14 @@ describe('events.ts', () => { }); }); - describe('emitBilateralCommitted', () => { - test('emits wallet.bilateralCommitted with detail', () => { + describe('emitBilateralAccepted', () => { + test('emits wallet.bilateralAccepted with the transfer it names', () => { const detail = { commitmentHash: new Uint8Array(32).fill(0xAA), counterpartyDeviceId: new Uint8Array(32).fill(0xBB), - accepted: true, - committed: true, - rejected: false, }; - emitBilateralCommitted(detail); - expect(bridgeEvents.emit).toHaveBeenCalledWith('wallet.bilateralCommitted', detail); - }); - - test('emits empty object when no detail provided', () => { - emitBilateralCommitted(); - expect(bridgeEvents.emit).toHaveBeenCalledWith('wallet.bilateralCommitted', {}); - }); - - test('emits empty object when undefined is passed', () => { - emitBilateralCommitted(undefined); - expect(bridgeEvents.emit).toHaveBeenCalledWith('wallet.bilateralCommitted', {}); + emitBilateralAccepted(detail); + expect(bridgeEvents.emit).toHaveBeenCalledWith('wallet.bilateralAccepted', detail); }); }); @@ -75,12 +62,15 @@ describe('events.ts', () => { expect(listener).toHaveBeenCalledWith({ source: 'test' }); }); - test('wallet.bilateralCommitted event is received by listeners', () => { + test('wallet.bilateralAccepted event is received by listeners', () => { const listener = jest.fn(); - bridgeEvents.on('wallet.bilateralCommitted', listener); + bridgeEvents.on('wallet.bilateralAccepted', listener); - const detail = { accepted: true, committed: true }; - emitBilateralCommitted(detail); + const detail = { + commitmentHash: new Uint8Array(32).fill(1), + counterpartyDeviceId: new Uint8Array(32).fill(2), + }; + emitBilateralAccepted(detail); expect(listener).toHaveBeenCalledWith(detail); }); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/identity.test.ts b/dsm_client/frontend/src/dsm/__tests__/identity.test.ts index dc1d94a98..d974b224b 100644 --- a/dsm_client/frontend/src/dsm/__tests__/identity.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/identity.test.ts @@ -51,8 +51,6 @@ function makeHeadersBinary(deviceId: Uint8Array, genesisHash: Uint8Array): Uint8 describe('identity.ts', () => { beforeEach(() => { jest.clearAllMocks(); - const g = globalThis as any; - g.__dsmLastGoodHeaders = { deviceId: undefined, genesisHash: undefined }; }); // ── getHeaders ───────────────────────────────────────────────────── @@ -68,16 +66,20 @@ describe('identity.ts', () => { expect(headers.genesisHash).toEqual(genesisHash); }); - test('caches valid headers for subsequent calls', async () => { - const deviceId = makeValidDeviceId(); + test('reads the bridge on every call, so a changed identity is seen', async () => { + const first = makeValidDeviceId(); const genesisHash = makeValidGenesisHash(); - (queryTransportHeadersV3 as jest.Mock).mockResolvedValue(makeHeadersBinary(deviceId, genesisHash)); + const second = new Uint8Array(32).fill(0x5c); + (queryTransportHeadersV3 as jest.Mock) + .mockResolvedValueOnce(makeHeadersBinary(first, genesisHash)) + .mockResolvedValueOnce(makeHeadersBinary(second, genesisHash)); - await getHeaders(); + const headers1 = await getHeaders(); const headers2 = await getHeaders(); - // Second call should use cache, only 1 bridge call total - expect(queryTransportHeadersV3).toHaveBeenCalledTimes(1); - expect(headers2.deviceId).toEqual(deviceId); + // No cache: the second answer is what the bridge said the second time. + expect(queryTransportHeadersV3).toHaveBeenCalledTimes(2); + expect(headers1.deviceId).toEqual(first); + expect(headers2.deviceId).toEqual(second); }); test('throws when bridge returns empty bytes', async () => { diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts index 3f7d36829..10308da22 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts @@ -42,8 +42,12 @@ function recordingBridge() { ? framed(new pb.Envelope({ version: 3, payload: { - case: 'bilateralPrepareResponse', - value: new pb.BilateralPrepareResponse({ commitmentHash: new pb.Hash32({ v: COMMITMENT }) }), + case: 'bilateralTransferResponse', + value: new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: COMMITMENT }), + message: 'prepare sent over BLE', + }), }, })) : new Uint8Array(0); diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts index a1e44612e..bca52ef81 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts @@ -37,13 +37,16 @@ function decodeRouterInvoke(reqBytes: Uint8Array): { route: string; args: Uint8A }; } -function prepareResponseBytes(commitmentHash: Uint8Array): Uint8Array { +/** The SDK's answer to wallet.sendOffline: the prepare went out under this commitment. */ +function sendAnswerBytes(commitmentHash: Uint8Array): Uint8Array { const env = new pb.Envelope({ version: 3, payload: { - case: 'bilateralPrepareResponse', - value: new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: new Uint8Array(commitmentHash) }), + case: 'bilateralTransferResponse', + value: new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: new Uint8Array(commitmentHash) }), + message: 'prepare sent over BLE', }), }, }); @@ -80,7 +83,7 @@ describe('offlineSend', () => { amount: '1', memo: '', }).toBinary()); - return prepareResponseBytes(commitmentHash); + return sendAnswerBytes(commitmentHash); }; const promise = dsm.offlineSend({ to, amount: 1n, tokenId: 'ERA' }); @@ -105,7 +108,7 @@ describe('offlineSend', () => { (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { route } = decodeRouterInvoke(reqBytes); expect(route).toBe('wallet.sendOffline'); - return prepareResponseBytes(commitmentHash); + return sendAnswerBytes(commitmentHash); }; let settled = false; @@ -135,7 +138,9 @@ describe('offlineSend', () => { await expect(promise).resolves.toEqual(expect.objectContaining({ accepted: true })); }); - test('surfaces bilateral prepare rejects from wallet.sendOffline', async () => { + // The peer's reject is a BLE event, never wallet.sendOffline's own answer: + // an answer of any other shape is refused as what it is. + test("an answer that is not the SDK's send answer is refused", async () => { const to = new Uint8Array(32).fill(0x44); (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { @@ -152,7 +157,10 @@ describe('offlineSend', () => { }; await expect(dsm.offlineSend({ to, amount: 1n, tokenId: 'ERA' })).resolves.toEqual( - expect.objectContaining({ accepted: false, result: 'offline rejected' }), + expect.objectContaining({ + accepted: false, + result: 'offlineSend: unexpected payload case bilateralPrepareReject', + }), ); }); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts index c20b38546..f00653331 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts @@ -65,9 +65,11 @@ describe('offline transfer sender/recipient consistency through WebView bridge', const env = new pb.Envelope({ version: 3, payload: { - case: 'bilateralPrepareResponse', - value: new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: commitmentHash }), + case: 'bilateralTransferResponse', + value: new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: commitmentHash }), + message: 'prepare sent over BLE', }), }, }); @@ -98,9 +100,11 @@ describe('offline transfer sender/recipient consistency through WebView bridge', const env = new pb.Envelope({ version: 3, payload: { - case: 'bilateralPrepareResponse', - value: new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: commitmentHash }), + case: 'bilateralTransferResponse', + value: new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: commitmentHash }), + message: 'prepare sent over BLE', }), }, }); diff --git a/dsm_client/frontend/src/dsm/__tests__/policies.test.ts b/dsm_client/frontend/src/dsm/__tests__/policies.test.ts index 03c6caa5c..c419b9eaa 100644 --- a/dsm_client/frontend/src/dsm/__tests__/policies.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/policies.test.ts @@ -9,7 +9,7 @@ jest.mock('../WebViewBridge', () => ({ jest.mock('../events', () => ({ emitWalletRefresh: jest.fn(), - emitBilateralCommitted: jest.fn(), + emitBilateralAccepted: jest.fn(), })); import * as pb from '../../proto/dsm_app_pb'; diff --git a/dsm_client/frontend/src/dsm/decoding.ts b/dsm_client/frontend/src/dsm/decoding.ts index 7dd27522f..de4716968 100644 --- a/dsm_client/frontend/src/dsm/decoding.ts +++ b/dsm_client/frontend/src/dsm/decoding.ts @@ -2,6 +2,7 @@ import * as pb from '../proto/dsm_app_pb'; import { encodeBase32Crockford } from '../utils/textId'; +import { emitDeterministicSafetyForError } from '../utils/deterministicSafety'; /** @@ -42,6 +43,11 @@ export function decodeFramedEnvelopeV3(bytes: Uint8Array): pb.Envelope { throw new Error(`Expected Envelope v3, got v${env.version}`); } + // A refusal Rust tagged as deterministic safety is announced from the one + // place every transport envelope is decoded; the tag, never the text, says so. + if (env.payload.case === 'error') { + emitDeterministicSafetyForError(env.payload.value); + } return env; } diff --git a/dsm_client/frontend/src/dsm/events.ts b/dsm_client/frontend/src/dsm/events.ts index f86c9e095..83ff98ee7 100644 --- a/dsm_client/frontend/src/dsm/events.ts +++ b/dsm_client/frontend/src/dsm/events.ts @@ -3,15 +3,13 @@ // SPDX-License-Identifier: Apache-2.0 // Shared event definitions to avoid circular dependencies between index.ts and EventBridge.ts -// Standard event payload for UI updates: emitted when a bilateral transfer has been committed -export interface BilateralCommittedEventDetail { +// Emitted once the accept of an offline transfer has been sent. The transfer +// is not committed by then: the peer's confirm arrives later as a BLE event. +export interface BilateralAcceptedEventDetail { // Bytes-only: protocol boundary must not depend on hex/json. // If UI needs display, compute Base32 at render-time. - commitmentHash?: Uint8Array; - counterpartyDeviceId?: Uint8Array; - accepted?: boolean; - committed?: boolean; - rejected?: boolean; + commitmentHash: Uint8Array; + counterpartyDeviceId: Uint8Array; } /** @@ -37,6 +35,6 @@ export function emitWalletRefresh(detail: WalletRefreshDetail): void { bridgeEvents.emit('wallet.refresh', detail); } -export function emitBilateralCommitted(detail?: BilateralCommittedEventDetail): void { - bridgeEvents.emit('wallet.bilateralCommitted', detail ?? {}); +export function emitBilateralAccepted(detail: BilateralAcceptedEventDetail): void { + bridgeEvents.emit('wallet.bilateralAccepted', detail); } diff --git a/dsm_client/frontend/src/dsm/identity.ts b/dsm_client/frontend/src/dsm/identity.ts index 74d255b4e..ab45adaff 100644 --- a/dsm_client/frontend/src/dsm/identity.ts +++ b/dsm_client/frontend/src/dsm/identity.ts @@ -10,12 +10,6 @@ import { nativeSessionStore } from '../runtime/nativeSessionStore'; import { bridgeEvents } from '../bridge/bridgeEvents'; import { IdentityUnavailableError } from './identityUnavailable'; -// Cache the last known-good identity to avoid flip-flops. -const g: any = globalThis as any; -if (!g.__dsmLastGoodHeaders) { - g.__dsmLastGoodHeaders = { deviceId: undefined as Uint8Array | undefined, genesisHash: undefined as Uint8Array | undefined }; -} - export async function getHeaders(): Promise { const isAllZero = (u: Uint8Array) => u.every((v) => v === 0); @@ -42,14 +36,6 @@ export async function getHeaders(): Promise { } }; - const cached = g.__dsmLastGoodHeaders as { deviceId?: Uint8Array; genesisHash?: Uint8Array }; - const cachedDevOk = cached.deviceId instanceof Uint8Array && cached.deviceId.length === 32 && !isAllZero(cached.deviceId); - const cachedGhOk = cached.genesisHash instanceof Uint8Array && cached.genesisHash.length === 32 && !isAllZero(cached.genesisHash); - - if (cachedDevOk && cachedGhOk) { - return new pb.Headers({ deviceId: cached.deviceId as any, genesisHash: cached.genesisHash as any } as any); - } - let lastSeen: { deviceId?: Uint8Array; genesisHash?: Uint8Array } = {}; try { @@ -62,8 +48,6 @@ export async function getHeaders(): Promise { const ghOk = lastSeen.genesisHash instanceof Uint8Array && lastSeen.genesisHash.length === 32 && !isAllZero(lastSeen.genesisHash); if (devOk && ghOk) { - cached.deviceId = lastSeen.deviceId; - cached.genesisHash = lastSeen.genesisHash; return new pb.Headers({ deviceId: lastSeen.deviceId as any, genesisHash: lastSeen.genesisHash as any, diff --git a/dsm_client/frontend/src/dsm/transactions.ts b/dsm_client/frontend/src/dsm/transactions.ts index 29b2bc6cd..0d2e31e39 100644 --- a/dsm_client/frontend/src/dsm/transactions.ts +++ b/dsm_client/frontend/src/dsm/transactions.ts @@ -13,7 +13,7 @@ import { getPendingBilateralListStrictBridge, } from './WebViewBridge'; import { on as eventBridgeOn } from './EventBridge'; -import { emitBilateralCommitted } from './events'; +import { emitBilateralAccepted } from './events'; import { bridgeEvents } from '../bridge/bridgeEvents'; import logger from '../utils/logger'; @@ -259,14 +259,13 @@ export async function offlineSend(transfer: GenericTransaction): Promise void>(); constructor() { + // The bus carries whole snapshots (decodeSessionState refuses a partial + // one); the store adds only its own fact, that one arrived. bridgeEvents.on('session.state', (next) => { - if (!isNativeSessionSnapshot(next)) { - return; - } - this.snapshot = { - ...DEFAULT_NATIVE_SESSION, - ...next, - received: true, - }; + this.snapshot = { ...next, received: true }; this.emit(); }); } diff --git a/dsm_client/frontend/src/runtime/nativeSessionTypes.ts b/dsm_client/frontend/src/runtime/nativeSessionTypes.ts index 9bf2e0711..6520ee6bd 100644 --- a/dsm_client/frontend/src/runtime/nativeSessionTypes.ts +++ b/dsm_client/frontend/src/runtime/nativeSessionTypes.ts @@ -72,17 +72,3 @@ export const DEFAULT_NATIVE_SESSION: NativeSessionSnapshot = { fatal_error: null, wallet_refresh_hint: 0, }; - -export function isNativeSessionSnapshot(value: unknown): value is NativeSessionSnapshot { - if (!value || typeof value !== 'object') { - return false; - } - const snapshot = value as Partial; - return ( - typeof snapshot.phase === 'string' && - typeof snapshot.identity_status === 'string' && - typeof snapshot.env_config_status === 'string' && - typeof snapshot.lock_status === 'object' && - typeof snapshot.hardware_status === 'object' - ); -} diff --git a/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts b/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts index f2e6b25f9..17e3a856f 100644 --- a/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts +++ b/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts @@ -112,13 +112,14 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { if (ingress.operation.case === 'routerInvoke') { const ingressMethod = ingress.operation.value.method; if (ingressMethod === 'wallet.sendOffline') { - const resp = new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: new Uint8Array(32) } as any), - localSignature: new Uint8Array(64), + const resp = new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: new Uint8Array(32) } as any), + message: 'prepare sent over BLE', }); const env = new pb.Envelope({ version: 3, - payload: { case: 'bilateralPrepareResponse', value: resp }, + payload: { case: 'bilateralTransferResponse', value: resp }, } as any); return wrapIngressOk(frameEnvelope(env)); } diff --git a/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts b/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts index 4d2330e6a..ba308154b 100644 --- a/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts +++ b/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts @@ -85,12 +85,6 @@ describe('E2E: sendOnlineTransfer (unit-level, mocked storage)', () => { jest.restoreAllMocks(); // Provide a simple bridge with device identity getters and minimal hooks (global as any).window = (global as any).window || {}; - (global as any).__dsmLastGoodHeaders = { - deviceId: undefined, - genesisHash: undefined, - chainTip: undefined, - seq: undefined, - }; (global as any).window.DsmBridge = (global as any).window.DsmBridge || {}; // Bytes-only MessagePort bridge contract (required by WebViewBridge.callBin) diff --git a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts index 14d1bb94d..330199d0b 100644 --- a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts +++ b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts @@ -109,15 +109,16 @@ function makeContactsFramedEnvelope(bleAddress?: string): Uint8Array { return frameEnvelope(env); } -/** Build a BilateralPrepareResponse inside a framed Envelope. */ -function makeBilateralPrepareResponseEnvelope(commitHash: Uint8Array): Uint8Array { - // The commitment is what the frontend reads; the rest is Rust's to fill. - const resp = new pb.BilateralPrepareResponse({ - commitmentHash: new pb.Hash32({ v: commitHash } as any), +/** The SDK's answer to wallet.sendOffline: the prepare went out under this commitment. */ +function makeOfflineSendAnswerEnvelope(commitHash: Uint8Array): Uint8Array { + const resp = new pb.BilateralTransferResponse({ + success: true, + transactionHash: new pb.Hash32({ v: commitHash } as any), + message: 'prepare sent over BLE', } as any); const env = new pb.Envelope({ version: 3, - payload: { case: 'bilateralPrepareResponse', value: resp }, + payload: { case: 'bilateralTransferResponse', value: resp }, } as any); return frameEnvelope(env); } @@ -215,7 +216,7 @@ function installBridge(opts?: { contactBleAddress?: string }) { if (bilateralResponseOverride) { return wrapIngressOk(bilateralResponseOverride()); } - return wrapIngressOk(makeBilateralPrepareResponseEnvelope(COMMITMENT_HASH)); + return wrapIngressOk(makeOfflineSendAnswerEnvelope(COMMITMENT_HASH)); } return wrapIngressOk(new Uint8Array(0)); } @@ -254,8 +255,6 @@ beforeEach(() => { headersOverride = null; testIndex++; initializeEventBridge(); - // Clear headers cache so each test gets fresh headers from bridge - (global as any).__dsmLastGoodHeaders = { deviceId: undefined, genesisHash: undefined, chainTip: undefined }; }); // ───────────────────────────────────────────────────────────────── diff --git a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx index 07deb2baa..bf5c54a0d 100644 --- a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx +++ b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx @@ -355,19 +355,13 @@ describe('BridgeEventBus — core event delivery', () => { u1(); u2(); }); - test('wallet.bilateralCommitted carries typed payload', () => { + test('wallet.bilateralAccepted carries typed payload', () => { const spy = jest.fn(); - const unsub = bridgeEvents.on('wallet.bilateralCommitted', spy); - bridgeEvents.emit('wallet.bilateralCommitted', { - commitmentHash: makeCommitmentHash(), - counterpartyDeviceId: makeDeviceId(), - accepted: true, - committed: true, - }); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ - accepted: true, - committed: true, - })); + const unsub = bridgeEvents.on('wallet.bilateralAccepted', spy); + const commitmentHash = makeCommitmentHash(); + const counterpartyDeviceId = makeDeviceId(); + bridgeEvents.emit('wallet.bilateralAccepted', { commitmentHash, counterpartyDeviceId }); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ commitmentHash, counterpartyDeviceId })); unsub(); }); @@ -389,11 +383,11 @@ describe('useEventSignal — React external store integration', () => { test('increments on bridge event emission', async () => { const C: React.FC = () => { - const s = useEventSignal('wallet.bilateralCommitted'); + const s = useEventSignal('wallet.bilateralAccepted'); return
{s}
; }; const { unmount } = render(); - act(() => { bridgeEvents.emit('wallet.bilateralCommitted', {} as any); }); + act(() => { bridgeEvents.emit('wallet.bilateralAccepted', {} as any); }); await waitFor(() => { expect(parseInt(screen.getByTestId('sig2').textContent || '0')).toBeGreaterThan(0); }); @@ -637,9 +631,6 @@ describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { balancesState = [{ tokenId: 'ERA', available: 10000n }]; historyState = [{ amount: 100n, amountSigned: 100n }]; - // Clear identity cache - (global as any).__dsmLastGoodHeaders = { deviceId: undefined, genesisHash: undefined, chainTip: undefined }; - // Re-install bridge mock (in case previous test modified it) installCallBinMock(); }); @@ -934,11 +925,11 @@ describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { balancesState = [{ tokenId: 'ERA', available: 10500n }]; capturedMethods = []; - // What the accept path emits: the committed signal (the toast's trigger) + // What the accept path emits: the accepted signal (the toast's trigger) // and its own wallet.refresh (the reload's). The provider reloads on the // second alone. act(() => { - bridgeEvents.emit('wallet.bilateralCommitted', { accepted: true, committed: true } as any); + bridgeEvents.emit('wallet.bilateralAccepted', { commitmentHash: makeCommitmentHash(), counterpartyDeviceId: makeDeviceId() }); bridgeEvents.emit('wallet.refresh', { source: 'bilateral.accept_followup' }); }); @@ -1001,7 +992,6 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { capturedMethods = []; balancesState = [{ tokenId: 'ERA', available: 5000n }]; historyState = []; - (global as any).__dsmLastGoodHeaders = { deviceId: undefined, genesisHash: undefined, chainTip: undefined }; installCallBinMock(); }); @@ -1102,14 +1092,13 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { // Verify sendMessageBin was called for the balance refresh expect(capturedMethods).toContain('getAllBalancesStrict'); - // ──── Step 7: wallet.bilateralCommitted → refresh again via sendMessageBin ──── + // ──── Step 7: wallet.bilateralAccepted → refresh again via sendMessageBin ──── capturedMethods = []; act(() => { - bridgeEvents.emit('wallet.bilateralCommitted', { + bridgeEvents.emit('wallet.bilateralAccepted', { commitmentHash: makeCommitmentHash(0x22), - accepted: true, - committed: true, - } as any); + counterpartyDeviceId: makeDeviceId(), + }); }); await waitFor(() => { @@ -1198,9 +1187,9 @@ describe('Error resilience', () => { test('handler error does not break other handlers', () => { const bad = jest.fn(() => { throw new Error('crash'); }); const good = jest.fn(); - const u1 = bridgeEvents.on('wallet.bilateralCommitted', bad as any); - const u2 = bridgeEvents.on('wallet.bilateralCommitted', good); - bridgeEvents.emit('wallet.bilateralCommitted', { accepted: true } as any); + const u1 = bridgeEvents.on('wallet.bilateralAccepted', bad as any); + const u2 = bridgeEvents.on('wallet.bilateralAccepted', good); + bridgeEvents.emit('wallet.bilateralAccepted', { accepted: true } as any); expect(bad).toHaveBeenCalled(); expect(good).toHaveBeenCalled(); u1(); u2(); diff --git a/dsm_client/frontend/src/utils/__tests__/deterministicSafety.test.ts b/dsm_client/frontend/src/utils/__tests__/deterministicSafety.test.ts index 8033b0ed0..68ea6f7a8 100644 --- a/dsm_client/frontend/src/utils/__tests__/deterministicSafety.test.ts +++ b/dsm_client/frontend/src/utils/__tests__/deterministicSafety.test.ts @@ -7,66 +7,42 @@ declare const expect: any; declare const beforeEach: any; declare const afterEach: any; -import { parseDeterministicSafety, emitDeterministicSafetyIfPresent } from '../deterministicSafety'; +import { + DETERMINISTIC_SAFETY_SOURCE_TAG, + deterministicSafetyFromError, + emitDeterministicSafetyForError, +} from '../deterministicSafety'; import { bridgeEvents } from '../../bridge/bridgeEvents'; -describe('parseDeterministicSafety', () => { - test('returns null for null/undefined/empty', () => { - expect(parseDeterministicSafety(null)).toBeNull(); - expect(parseDeterministicSafety(undefined)).toBeNull(); - expect(parseDeterministicSafety('')).toBeNull(); - }); - - test('returns null for non-matching messages', () => { - expect(parseDeterministicSafety('some random error')).toBeNull(); - expect(parseDeterministicSafety('Deterministic safety')).toBeNull(); - expect(parseDeterministicSafety('Deterministic safety rejection')).toBeNull(); - }); - - test('parses a valid deterministic safety rejection message', () => { - const msg = 'Deterministic safety rejection [OVERFLOW]: value exceeds maximum'; - const result = parseDeterministicSafety(msg); - expect(result).toEqual({ - classification: 'OVERFLOW', - message: 'value exceeds maximum', - }); - }); - - test('is case-insensitive', () => { - const msg = 'deterministic safety rejection [Replay]: duplicate nonce detected'; - const result = parseDeterministicSafety(msg); - expect(result).toEqual({ - classification: 'Replay', - message: 'duplicate nonce detected', - }); - }); +const enc = (s: string) => new TextEncoder().encode(s); - test('handles empty classification gracefully', () => { - const msg = 'Deterministic safety rejection []: some detail'; - const result = parseDeterministicSafety(msg); - expect(result).toBeNull(); +describe('deterministicSafetyFromError', () => { + test('an error Rust did not tag is not a safety refusal, whatever it says', () => { + expect(deterministicSafetyFromError({ + sourceTag: 10, + message: 'Deterministic safety rejection [ParentConsumed]: parent already consumed', + context: enc('classification=ParentConsumed message=parent already consumed'), + })).toBeNull(); }); - test('handles empty detail', () => { - const msg = 'Deterministic safety rejection [CRITICAL]:'; - const result = parseDeterministicSafety(msg); - expect(result).toEqual({ - classification: 'CRITICAL', - message: '', - }); + test('a tagged error yields the class and message Rust put in its context', () => { + expect(deterministicSafetyFromError({ + sourceTag: DETERMINISTIC_SAFETY_SOURCE_TAG, + message: 'Deterministic safety rejection [StalePrecommit]: tip moved', + context: enc('classification=StalePrecommit message=tip moved'), + })).toEqual({ classification: 'StalePrecommit', message: 'tip moved' }); }); - test('trims classification and detail', () => { - const msg = 'Deterministic safety rejection [ BOUNDS ]: out of range '; - const result = parseDeterministicSafety(msg); - expect(result).toEqual({ - classification: 'BOUNDS', - message: 'out of range', - }); + test('a tagged error without the context format keeps its message and no class', () => { + expect(deterministicSafetyFromError({ + sourceTag: DETERMINISTIC_SAFETY_SOURCE_TAG, + message: 'refused', + context: new Uint8Array(0), + })).toEqual({ classification: '', message: 'refused' }); }); }); -describe('emitDeterministicSafetyIfPresent', () => { +describe('emitDeterministicSafetyForError', () => { let emitSpy: any; beforeEach(() => { @@ -77,29 +53,29 @@ describe('emitDeterministicSafetyIfPresent', () => { emitSpy.mockRestore(); }); - test('returns false and does not emit for non-matching messages', () => { - expect(emitDeterministicSafetyIfPresent('random error')).toBe(false); - expect(emitSpy).not.toHaveBeenCalled(); - }); - - test('returns false for null/undefined', () => { - expect(emitDeterministicSafetyIfPresent(null)).toBe(false); - expect(emitDeterministicSafetyIfPresent(undefined)).toBe(false); + test('emits nothing for an untagged error', () => { + expect(emitDeterministicSafetyForError({ sourceTag: 0, message: 'random error', context: new Uint8Array(0) })).toBe(false); expect(emitSpy).not.toHaveBeenCalled(); }); - test('returns true and emits for valid safety rejection', () => { - const msg = 'Deterministic safety rejection [DOUBLE_SPEND]: already spent'; - expect(emitDeterministicSafetyIfPresent(msg)).toBe(true); + test('emits the detail for a tagged error', () => { + expect(emitDeterministicSafetyForError({ + sourceTag: DETERMINISTIC_SAFETY_SOURCE_TAG, + message: 'x', + context: enc('classification=TipMismatch message=expected tip differs'), + })).toBe(true); expect(emitSpy).toHaveBeenCalledWith('dsm.deterministicSafety', { - classification: 'DOUBLE_SPEND', - message: 'already spent', + classification: 'TipMismatch', + message: 'expected tip differs', }); }); - test('returns true even if emit throws', () => { + test('answers true even if a listener throws', () => { emitSpy.mockImplementation(() => { throw new Error('fail'); }); - const msg = 'Deterministic safety rejection [ERROR]: something broke'; - expect(emitDeterministicSafetyIfPresent(msg)).toBe(true); + expect(emitDeterministicSafetyForError({ + sourceTag: DETERMINISTIC_SAFETY_SOURCE_TAG, + message: 'x', + context: enc('classification=ParentConsumed message=already consumed'), + })).toBe(true); }); }); diff --git a/dsm_client/frontend/src/utils/deterministicSafety.ts b/dsm_client/frontend/src/utils/deterministicSafety.ts index 70303b393..4416329de 100644 --- a/dsm_client/frontend/src/utils/deterministicSafety.ts +++ b/dsm_client/frontend/src/utils/deterministicSafety.ts @@ -7,25 +7,36 @@ export type DeterministicSafetyDetail = { message: string; }; -const SAFETY_REGEX = /Deterministic safety rejection \[([^\]]+)\]:\s*(.*)/i; +/** Rust's source tag on `DsmError::DeterministicSafety` (`dsm_sdk/src/wire/mod.rs`). */ +export const DETERMINISTIC_SAFETY_SOURCE_TAG = 11; -export function parseDeterministicSafety(message?: string | null): DeterministicSafetyDetail | null { - if (!message) return null; - const match = String(message).match(SAFETY_REGEX); - if (!match) return null; - const classification = String(match[1] || '').trim(); - const detail = String(match[2] || '').trim(); - if (!classification) return null; - return { classification, message: detail }; +/** The wire `Error` fields this reads. */ +export type WireErrorLike = { + sourceTag: number; + message: string; + context: Uint8Array; +}; + +/** + * A deterministic-safety refusal is what Rust tagged as one, never what a message + * happens to say. Rust's context for that error is + * `classification= message=`; the class is read from there. + */ +export function deterministicSafetyFromError(err: WireErrorLike): DeterministicSafetyDetail | null { + if (err.sourceTag !== DETERMINISTIC_SAFETY_SOURCE_TAG) return null; + const context = new TextDecoder().decode(err.context); + const match = context.match(/^classification=(\S+) message=([\s\S]*)$/); + if (!match) return { classification: '', message: err.message }; + return { classification: match[1], message: match[2] }; } -export function emitDeterministicSafetyIfPresent(message?: string | null): boolean { - const detail = parseDeterministicSafety(message); +export function emitDeterministicSafetyForError(err: WireErrorLike): boolean { + const detail = deterministicSafetyFromError(err); if (!detail) return false; try { bridgeEvents.emit('dsm.deterministicSafety', detail); } catch { - // ignore + // a listener's failure is its own } return true; } diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index ee29b8a1d..22353d916 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1319,7 +1319,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | S-K1 | `KT/bridge/BridgeLogger.kt`, `SinglePathWebViewBridge.kt`, `ui/MainActivity.kt` | 7 | The first 32 bytes of every bridge payload and result are appended to `filesDir/bridge_diagnostics.log` in release builds. `generateMnemonic`'s result is the mnemonic text and `createGenesisV2`'s payload carries it. The diagnostics bundle exports that log, and its first 2,200 characters are put into a GitHub issue URL. Console output is relayed to logcat at INFO in release. | Resolved in part: the persisted bridge line names the method, the sizes and a failure, never a byte of the payload or the answer; the page's console reaches logcat in debug builds only. Open: the `diagnosticsLog` RPC still appends the frontend's own report text, and the bundle's export path is unchanged. | | S-K2 | `KT/ui/MainActivity.kt` (network config override) | 7 | On every cold start the network configuration (storage set, CA, `allow_localhost`) can be overridden from `filesDir` and from the public `Downloads/dsm_env_config.toml`. | Open | | S-F2 | `FE/dsm/rigDebug.ts`, `FE/dsm/BridgeProvider.tsx` | 7 | `window.__dsmRigQuery` ships in the production bundle and runs any router query path (queries only; the invoke surface is not exposed). The owner's rig depends on it (`scripts/rig_compose_verdict.py`), so it is not cut here. | Open (owner: keep as the rig's read hook, or gate it to debug builds) | -| S-F14 | `dsm_client/frontend/public/index.html` (`installPortHandler`) | 3 | The bridge `MessagePort` is taken from any `message` event that carries ports, with no origin check, and is replaced every time one arrives. | Open | +| S-F14 | `dsm_client/frontend/public/index.html` (`installPortHandler`) | 3 | The bridge `MessagePort` is taken from any `message` event that carries ports, with no origin check, and is replaced every time one arrives. | Resolved: the page takes the bridge port once, from the empty-string handshake Kotlin posts with it; a later port-bearing message, or one without the handshake, is ignored and logged. | | S-LOCK | `FE/services/lock/lockService.ts`, `FE/components/lock/LockScreen.tsx`; `SDK/handlers/session_routes.rs` (`session.unlock`); `KT/…/KeystoreVault.kt` | 5, 3 | The wallet lock is decided in JavaScript: PBKDF2 in WebCrypto against a hash JavaScript stored through preferences (an unsalted SHA-256 form is still accepted), the cooldown is `Date.now` in preferences, `session.unlock` takes no credential, and the seed vault's keystore key requires no user authentication. A curtain, not a lock. | Open (owner: where the credential is checked) | | S-TOFU | `SDK/bluetooth/anchor_accept.rs`, `bilateral_ble_handler.rs`; `CORE/crypto/anchor_enrollment.rs` (`FusedAnchorPin.uncompromised`) | 5, 3 | On a first transfer the sender's self-disclosed anchor pin is admitted before the release predicate runs and stays if the release is refused; `uncompromised` is a constant `true` nothing can set false; with no prior frontier the release's own `prev_root` is the frontier it is checked against. | Open (owner: offline boundary) | | S-ACK | `SDK/handlers/recovery_routes.rs`, `sdk/recovery_impl.rs`, `storage/client_db/recovery.rs` | 1, 3 | The tombstone-ACK sync gate: a lost counterparty list gives an empty gate, and an empty gate reads as "everyone acknowledged". | Open (recovery boundary) | @@ -1328,7 +1328,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | S-CONTACT | `CORE/types/contact_types.rs`, `core/contact_manager.rs` | 3, 5 | `can_perform_bilateral_transaction` is the caller-set bool `genesis_verified_online`; `add_verified_contact` admits any caller-built "verified" contact. It gates every offline step. | Open | | S-SUBSTRATE | `CORE/economic/lineage.rs` (`AcceptedSubstrate::from_verified_*`) | 5 | Public constructors that verify nothing; the SDK's own admission builds one from local coordinates. | Open | | S-IDS | `CORE/types/operations.rs`, `core/bilateral_transaction_manager.rs` | 2 | The offline `policy_id` and `anchor_set_id` are `H(tag, ε)`: identifiers with no bytes behind them (the class of §6.32); bearer status is decided from the `mode` discriminant. | Open (owner) | -| S-PBI | `CORE/pbi.rs` (`PlatformContext::bootstrap`) | 3 | "The ONLY object the Core trusts" checks lengths and nothing else; its domain tag is unused. | Open | +| S-PBI | `CORE/pbi.rs` (`PlatformContext::bootstrap`) | 3 | "The ONLY object the Core trusts" checks lengths and nothing else; its domain tag is unused. | Resolved: the module says what it does — the identity pair as two 32-byte arrays, checked for length and nothing else; the unused domain-tag parameter and the claims of hashing, canonization and sole trust are gone. | | S-NONCE | `CORE/recovery/capsule.rs` | 2 | The capsule's XChaCha20 nonce is `H(counter ‖ rollup_hash)` under a per-mnemonic key and excludes the SMT root and tips: the same counter and rollup with different content reuse a nonce. (§6.8 fixed `bitcoin_accounts` only.) | Open (recovery boundary) | | S-EXTC | `CORE/core/bridge.rs` (`ExternalCommit`); `SDK/sdk/recovery_impl.rs` (`handle_nfc_tag`) | 3, 1 | The "check" recomputes `commit_id` from the request's own fields; `handle_nfc_tag` echoes its payload as `accepted: true`. | Open | | S-APPSTATE | `CORE/core/bridge.rs` (`AppStateRequest` get/set) | 1 | Both discard their result and answer a success string. | Resolved: the arm is gone; a Core-addressed `AppStateRequest` is refused as not a Core operation. Nothing sends one: the SDK's `prefs.*` and `recovery.*` routes carry app state as ArgPack bodies. | @@ -1336,7 +1336,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | S-ACT | `SDK/handlers/recovery_routes.rs` (`recovery.activate`), `sdk/recovery_sdk.rs` | 6 | A hard-coded refusal ("not yet wired"), matched by message substring, answered as the success status `assembled;awaiting-go-live`. | Open (recovery boundary) | | S-BEARER | `SDK/sdk/core_sdk.rs` (`stage_offline_bearer_transition`) | 2 | Rewrites the head's anchor-state leaf outside `advance`: no step, no receipt, not persisted; the next receipt's parent root is one no committed step produced. | Open (owner: offline boundary) | | S-BLE | `SDK/bluetooth/ble_frame_coordinator.rs`, `bilateral_transport_adapter.rs` | constraint, 1, 2 | A 60 s wall-clock dedup drops a complete frame delivered again (which #1000's owed-frame redelivery relies on); an unexpected or `Unspecified` frame is reflected back to the peer; a malformed header gets a zero frame commitment. | Open | -| S-INGRESS | `SDK/ingress.rs` (genesis bootstrap) | 3, 5 | Gated on a caller-supplied `trust_level` described as a "C-DBRW entropy health test"; C-DBRW is deleted and nothing sends the message. | Open | +| S-INGRESS | `SDK/ingress.rs` (genesis bootstrap) | 3, 5 | Gated on a caller-supplied `trust_level` described as a "C-DBRW entropy health test"; C-DBRW is deleted and nothing sends the message. | Resolved: the bootstrap-report op (`BootstrapMeasurementReport` envelopes: started, progress, finalize, aborted, error; the trust-level gate; the finalize path and its helpers) is deleted. Nothing sent it; Genesis v2 (`system.createGenesisV2`) is the one bootstrap path. Such an envelope now reaches the Core bridge and is refused as unsupported. | | S-K9 | `KT/bridge/BridgeIdentityHandler.kt` | 5 | Kotlin keeps `device_id` and the genesis in SharedPreferences and hands them to Rust's `restore_identity_context` on cold start, beside a comment forbidding a preferences side channel. Whether Rust checks them against its own store is not established. | Open | | S-K10 | `KT/bridge/SinglePathWebViewBridge.kt`, `BridgeIdentityHandler.kt` | 1 | Exceptions and bad input are answered as SUCCESS with empty data; the frontend then fails to decode and Rust's reason is lost. | Resolved: every arm lets its failure reach the dispatcher, which answers a `BridgeRpcResponse` error carrying the reason (`getAllBalancesStrict`, `getTransportHeadersV3Bin` — a failed identity restore or status read, with empty kept only for Rust's NO_IDENTITY — `openBluetoothSettings` without an activity, a wrong-sized accept commitment, an undecodable reject/cancel payload, a blank genesis mnemonic, a failed `isErrorEnvelope` read, and any `createGenesisV2` failure after the artifacts are cleared). The test-only `handleBinaryRpcRaw` (empty on error) and `handleBinaryRpcRawStrict` are deleted; `SinglePathWebViewBridgeErrorResponseTest` decodes the dispatcher's error through the real path. | | S-N1 | `NODE/lib.rs` (`/api/v2/health`) | 1 | Always "ok", with no pool checkout; a node whose Postgres is down is healthy to its healthcheck. | Resolved: `/api/v2/health` checks a connection out of the pool and runs `SELECT 1`; 503 with the cause otherwise. Test: `dsm_storage_node::health::the_health_route_answers_ok_only_over_a_live_postgres` (live → ok; the pool closed under the node → 503). | @@ -1349,16 +1349,16 @@ The State column says what this branch did; "Open" rows are holes left visible, | B-2 | `SDK/handlers/recovery_routes.rs` (`recovery.enable`) | A failed first capsule is answered `enabled: true`; an anchor publish that failed "will retry", and nothing retries. | Open (recovery boundary) | | B-3 | `SDK/handlers/recovery_routes.rs` (`completeResume`) | `let _ =` on the store, then success (§6.9). | Open (recovery boundary) | | B-4 | `SDK/sdk/recovery_sdk.rs` (status reads) | Read errors become 0 and false (§6.29 partly). | Open (recovery boundary) | -| B-5 | `SDK/sdk/token_sdk.rs` (`execute_simplified_bilateral_transfer` remains) | `new_transaction_hash` is the caller's own head; the receipt is `vec![]`. | Open | -| B-6 | `SDK/sdk/b0x_sdk.rs` (`EvidenceOracle`) | Every online send carries an empty oracle whose key is `get_public_key().unwrap_or_default()`. | Open | -| B-7 | `SDK/handlers/storage_routes.rs` (the finality barrier) | A staged half that does not decode is "nothing in flight". | Open | -| B-8 | `SDK/handlers/wallet_routes.rs` (`wallet.sendOffline`) | Answers the peer's message type with default fields. | Open | -| B-9 | `SDK/handlers/storage_routes.rs` (reply rows) | A reply row without its release submits an empty release. | Open | +| B-5 | `SDK/sdk/token_sdk.rs` (`execute_simplified_bilateral_transfer` remains) | `new_transaction_hash` is the caller's own head; the receipt is `vec![]`. | Corrected: no such function exists on `main` or in this tree; the row was stale. | +| B-6 | `SDK/sdk/b0x_sdk.rs` (`EvidenceOracle`) | Every online send carries an empty oracle whose key is `get_public_key().unwrap_or_default()`. | Resolved: the builder embeds exactly the key the route resolved from the signing authority (fail-closed; 64 bytes by `validate_submission_params`) or no evidence at all; the re-derivation under the state lock, the caller-supplied substitute and the persisted app-state default are gone. The receiver already roots verification in the stored contact and treats a disagreeing wire key as a signal. | +| B-7 | `SDK/storage/client_db/recipient_staging.rs` (`counterparty_has_unconverged_inbound`, read by the finality barrier in `relationship_status`) | A staged half that does not decode is "nothing in flight". | Resolved: a frozen half that does not decode is an error, which the barrier reports as a failed staging read instead of letting the send cross. Tests: `an_unconverged_inbound_half_names_its_sender`, `a_staged_half_that_does_not_decode_is_an_error_not_absence`. | +| B-8 | `SDK/handlers/wallet_routes.rs` (`wallet.sendOffline`) | Answers the peer's message type with default fields. | Resolved: the route answers `BilateralTransferResponse` (its own message: the prepare went out, `transaction_hash` = the proposal commitment) instead of a `BilateralPrepareResponse` with every field but the commitment defaulted; the frontend reads that answer and treats any other payload as unexpected. | +| B-9 | `SDK/handlers/storage_routes.rs` (reply rows) | A reply row without its release submits an empty release. | Resolved: a promoted reply row without its post-admission release is logged as a local defect and left unmarked; nothing is submitted for it. | | B-10 | `SDK/sdk/recovery_impl.rs` | A recovered `AppState` genesis is `succession.new_device_commitment`. | Open (recovery boundary) | | B-K11 | `KT/bridge/…` (`qr.available`) | Hard-coded `true`. | Resolved: `qr_available` is the device's camera feature (`FEATURE_CAMERA_ANY`), not a literal. | -| B-F4 | `FE/dsm/transactions.ts` (accept) | Emits `committed: true` before the confirm arrived. | Open | -| B-F7 | `FE/dsm/*` error mapping | Invents causes for error codes Kotlin never emits. | Open | -| B-F9 | `FE/runtime/nativeSessionStore.ts` | A session snapshot fills absent fields with defaults. | Open | +| B-F4 | `FE/dsm/transactions.ts` (accept) | Emits `committed: true` before the confirm arrived. | Resolved: the accept path emits `wallet.bilateralAccepted` naming the transfer; no `committed` flag exists to invent, and the transfer commits when the peer's confirm arrives as a BLE event. | +| B-F7 | `FE/dsm/*` error mapping | Invents causes for error codes Kotlin never emits. | Resolved: the bridge error's own message is shown with its code; the causes invented for codes 460, 404 and 408, which nothing emits, are gone. | +| B-F9 | `FE/runtime/nativeSessionStore.ts` | A session snapshot fills absent fields with defaults. | Resolved: a session snapshot missing its lock or hardware status is refused as malformed instead of filled with false; the store keeps the snapshot as decoded and adds only that it arrived. The guard and the default spread are gone. | | B-F11 | `FE/components/screens/StorageScreen.tsx` (DLVs) | A failed vault list shows as "No DLVs"; JavaScript sums locked dBTC. Bitcoin, parked. | Open | | B-F16 | `dsm_client/frontend/public/index.html` (battery LED) | Defaults to full and reads a `window.DSMBridge.getBatteryStatus` nothing installs. | Resolved: without the Battery API the LED is unlit and unclassed, and the phantom native fallback is deleted; the markup no longer starts as `full`. | | B-C13 | `CORE/recovery/tombstone.rs` (`TombstoneReceipt.old_counter`); `init_*` no-ops | Signs a counter DSM does not keep; initialisers that initialise nothing. | Open (recovery boundary) | @@ -1369,23 +1369,23 @@ The State column says what this branch did; "Open" rows are holes left visible, | ID | Location | Finding | State | |---|---|---|---| | C-F3 | `FE/components/tour/practiceMode.ts` | Practice mode monkey-patches the shared `dsmClient` with invented state, isolates by a method-name regex, and five screens bypass it. | Open | -| C-K4 | `KT/ui/MainActivity.kt` (WebView) | A CORS proxy and localhost CSP entries with no consumer. | Open | +| C-K4 | `KT/ui/MainActivity.kt` (WebView) | A CORS proxy and localhost CSP entries with no consumer. | Resolved: the CORS proxy, the host allowlist and its lock test are deleted (nothing in the page fetches an external host; the map tiles they served have no consumer), and with them the CSP's tile, maplibre, `dsm-wallet.io` and localhost entries and the service worker's tile cache. | | C-K5 | `KT/ui/MainActivity.kt` (`biometric.auth`, `setSystemBarColors`) | Two no-op ACK arms outside the RPC-name gate; `applySystemBarColors` ignores both arguments "so the route does not error" (A27 residual). No frontend caller. | Resolved: both arms and `applySystemBarColors` deleted (nothing in the frontend or `index.html` sends either; the biometric flow is the `biometric.authorize` host request). Kotlin compiles. | | C-K6 | `KT/bridge/NativeHostBridge.kt` | Five host-request arms nothing sends; `QR_STOP_SCAN` acknowledges and stops nothing. | Resolved: the bridge handles the four kinds the frontend builds (QR start, NFC reader start/stop, NFC tag write) and answers every other kind as unsupported; the seven pass-through parameters no arm read are gone. With the arms went their only reaches: `showBiometricPrompt`, the `androidx.biometric` dependency, `requestNamedPermissionsFromUi` with its camera request code, and the frontend's `BIOMETRIC_RESULT` event decode. The `'biometric'` lock-method value the frontend still declares is S-LOCK's. | | C-K17 | `KT/bridge/*` (`@VisibleForTesting` statics) | Statics that skip the port path; the instrumented proof never runs the port path production takes. | Open | -| C-F10 | `FE/dsm/*` (`__dsmLastGoodHeaders`) | A window global never invalidated. | Open | -| C-C19 | `dsm/Cargo.toml` | Six features that gate only a name. | Open | +| C-F10 | `FE/dsm/*` (`__dsmLastGoodHeaders`) | A window global never invalidated. | Resolved: `getHeaders` reads the bridge on every call; the window cache and the tests' resets of it are gone. | +| C-C19 | `dsm/Cargo.toml` | Six features that gate only a name. | Resolved: the six features (`bluetooth`, `storage`, `threadsafe`, `jni`, `web-stack`, `formal`) and the optional dependencies they carried (`jni`, `tokio-stream`, `rocksdb`, `axum`, `tower`) are deleted, with the build-info function that only listed them; `sphincs-trace` and `bitcoin-testnet-bypass` gate code and stay. | | C-C26 | `bitcoin-testnet-bypass` | §6.22; parked. | Open | **D. Repository constraints broken** | ID | Location | Finding | State | |---|---|---|---| -| D-CODEC | `KT/bridge/BridgeEnvelopeCodec.kt`; `dsm_client/frontend/public/index.html` | Hand-rolled protobuf codecs with hard-coded field numbers, beside "Kotlin MUST NOT implement custom wire decoders"; the index.html catch cleanup reads a variable out of scope. | Open | -| D-SAFETY | `KT/bridge/*` (safety scan); `FE/dsm/*` (safety classification) | Kotlin scans a field that cannot match on the ingress path; the frontend classifies safety by regex over message text instead of reading `Error.source_tag`. | Open | +| D-CODEC | `KT/bridge/BridgeEnvelopeCodec.kt`; `dsm_client/frontend/public/index.html` | Hand-rolled protobuf codecs with hard-coded field numbers, beside "Kotlin MUST NOT implement custom wire decoders"; the index.html catch cleanup reads a variable out of scope. | Resolved: the page's port handler declares the response id outside its `try`, so the catch releases the pending entry it used to miss; `BridgeEnvelopeCodec` decodes and encodes through the generated protobuf classes (request, response, error, app-router, preference and bilateral payloads, and the envelope's error), and Kotlin holds no varint parser. Tests that asserted the hand-rolled parser's private rules (a second oneof member, a wrong wire type as a failure, an empty-versus-absent debug string) now assert protobuf's semantics. | +| D-SAFETY | `KT/bridge/*` (safety scan); `FE/dsm/*` (safety classification) | Kotlin scans a field that cannot match on the ingress path; the frontend classifies safety by regex over message text instead of reading `Error.source_tag`. | Resolved: the page classifies a deterministic-safety refusal by Rust's `Error.source_tag` (11) and reads the class from the error's context, at the two places every transport error surfaces (`decodeFramedEnvelopeV3`, the ingress unwrapper); the regex over message text and Kotlin's scan of the answer bytes (which never parsed a framed answer) are gone. | | D-F8 | `FE/dsm/WebViewBridge/strictQueries.ts` (`wallet.history`) | Sixteen raw little-endian bytes in a `Codec.PROTO` ArgPack. | Open | | D-F13 | `FE/services/recovery/nfcRecoveryService.ts` | A `key=value` text protocol with `'0'` defaults; JavaScript decodes capsule bytes (§6.29 Open, recovery boundary). | Open | -| D-C15 | `CORE/deterministic_id.rs` | Hex UUID ids; dead. | Open | +| D-C15 | `CORE/deterministic_id.rs` | Hex UUID ids; dead. | Resolved: `deterministic_id.rs` deleted. | | D-DBTC | `SDK/policy/builtins.rs` | The dBTC commit is checked with raw BLAKE3, not `TAG_DSM_POLICY`. Parked. | Open | | D-F17 | `FE/*` | `alert()` on production paths. | Resolved: the transfer dialog and the diagnostics overlay report through the app's toasts; no `alert()` remains in production code. | @@ -1394,7 +1394,7 @@ The State column says what this branch did; "Open" rows are holes left visible, | ID | Location | Finding | State | |---|---|---|---| | E-C14 | `CORE/emissions/` | The whole module; emissions are out of this round and nothing reaches it. | Open | -| E-C15–18 | `CORE/deterministic_id.rs`; `crypto` (`calculate_next_entropy`, `hash_blake3`, `init_crypto`, `init_sphincs` — a self-test that never runs); `types/state_types.rs` (`State` methods nothing calls) | No production caller. | Open | +| E-C15–18 | `CORE/deterministic_id.rs`; `crypto` (`calculate_next_entropy`, `hash_blake3`, `init_crypto`, `init_sphincs` — a self-test that never runs); `types/state_types.rs` (`State` methods nothing calls) | No production caller. | Resolved for the named items: `deterministic_id.rs`, `calculate_next_entropy` and `hash_blake3` (each reached only by its own test), `init_crypto` and the `init_sphincs` self-test it alone called are deleted; `init_kyber` stays (key generation calls it). The `State` methods are not enumerated by the audit and remain Open. | | E-C21 | `CORE/*` (`verify_offline_allocation_leaf`, `verify_rollup_sequence`, `classical_verify` (P-256), `is_drain_proof`, `position_leader`, `dlv_manager::{try_unlock, claim, create_vault_post}`, `common/device_admission`, the `external_commitment` verifier family) | Verifiers and helpers with no production caller. | Open | | E-C9 | `CORE/…` (dBTC builtin literal) | Parked. | Open | | E-S21 | `SDK/lib.rs` (non-Android or non-Bluetooth builds) | `initialize_bilateral_sdk` answers `Ok(())` and `is_bilateral_ready` is `true` on builds that have no bilateral stack (A27 residual). The shipped Android path reads a real flag. | Resolved: on a build without the stack `initialize_bilateral_sdk` is an error naming the missing stack and `is_bilateral_ready` is false. |