Reject raw APDU commands in KMP NFC bridge (#1839)

* update specs

* update specs

* harden nfc apdu
This commit is contained in:
Justin Hernandez
2026-03-10 20:31:34 -07:00
committed by GitHub
parent 6dcaa63de3
commit 2ab33c727a
6 changed files with 137 additions and 11 deletions

View File

@@ -83,6 +83,7 @@ class NfcBridgeHandler(
}
private suspend fun scan(params: Map<String, JsonElement>): JsonElement {
NfcApduPolicy.requireSupportedParams(params)
val scanParams = json.decodeFromJsonElement(NfcScanParams.serializer(), JsonObject(params))
pushProgress("waiting_for_tag", 0, "Hold your phone near the passport")

View File

@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.sdk.handlers
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import xyz.self.sdk.bridge.BridgeHandlerException
internal object NfcApduPolicy {
private const val APDU_COMMANDS_PARAM = "apduCommands"
private const val REJECTION_CODE = "NFC_APDU_NOT_ALLOWED"
private const val REJECTION_MESSAGE = "Raw APDU commands are not supported by the KMP NFC bridge"
fun requireSupportedParams(params: Map<String, JsonElement>) {
val apduCommands = params[APDU_COMMANDS_PARAM] ?: return
if (apduCommands == JsonNull) return
if (apduCommands is JsonArray && apduCommands.isEmpty()) return
throw BridgeHandlerException(REJECTION_CODE, REJECTION_MESSAGE)
}
}

View File

@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.sdk.handlers
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import xyz.self.sdk.bridge.BridgeHandlerException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class NfcApduPolicyTest {
private val json = Json
@Test
fun requireSupportedParams_allowsStandardPassportScanParams() {
NfcApduPolicy.requireSupportedParams(
params(
"""
{
"passportNumber": "L898902C3",
"dateOfBirth": "690806",
"dateOfExpiry": "060815",
"sessionId": "session-1"
}
""".trimIndent(),
),
)
}
@Test
fun requireSupportedParams_allowsEmptyApduCommandList() {
NfcApduPolicy.requireSupportedParams(
params(
"""
{
"passportNumber": "L898902C3",
"dateOfBirth": "690806",
"dateOfExpiry": "060815",
"sessionId": "session-1",
"apduCommands": []
}
""".trimIndent(),
),
)
}
@Test
fun requireSupportedParams_rejectsNonEmptyApduCommandList() {
val error =
assertFailsWith<BridgeHandlerException> {
NfcApduPolicy.requireSupportedParams(
params(
"""
{
"passportNumber": "L898902C3",
"dateOfBirth": "690806",
"dateOfExpiry": "060815",
"sessionId": "session-1",
"apduCommands": ["00A4040C07A0000002471001"]
}
""".trimIndent(),
),
)
}
assertEquals("NFC_APDU_NOT_ALLOWED", error.code)
assertEquals("Raw APDU commands are not supported by the KMP NFC bridge", error.message)
}
@Test
fun requireSupportedParams_rejectsMalformedApduCommandParam() {
val error =
assertFailsWith<BridgeHandlerException> {
NfcApduPolicy.requireSupportedParams(
params(
"""
{
"passportNumber": "L898902C3",
"dateOfBirth": "690806",
"dateOfExpiry": "060815",
"sessionId": "session-1",
"apduCommands": "00A4040C07A0000002471001"
}
""".trimIndent(),
),
)
}
assertEquals("NFC_APDU_NOT_ALLOWED", error.code)
}
private fun params(rawJson: String) = json.parseToJsonElement(rawJson).jsonObject
}

View File

@@ -41,6 +41,7 @@ class NfcBridgeHandler(
}
private suspend fun scan(params: Map<String, JsonElement>): JsonElement {
NfcApduPolicy.requireSupportedParams(params)
val provider =
SdkProviderRegistry.nfc
?: throw BridgeHandlerException("NOT_CONFIGURED", "NFC provider not configured")

View File

@@ -83,7 +83,7 @@
| NS-01 | Physical-device validation matrix for Android + iOS NFC flows | Done | High | - | [plans/NS-01-physical-device-validation.md](./plans/NS-01-physical-device-validation.md) | - |
| NS-02 | iOS Camera MRZ Phase 2 | Deferred | Medium | NS-01 | - | - |
| NS-03 | Publishing readiness for AAR + XCFramework artifacts | Done | High | NS-01 | [plans/NS-03-publishing-readiness.md](./plans/NS-03-publishing-readiness.md) | N/A (audit-only) |
| NS-04 | APDU allowlist in KMP NFC bridge handler | Ready | High | - | [plans/NS-04-apdu-allowlist.md](./plans/NS-04-apdu-allowlist.md) | - |
| NS-04 | APDU allowlist in KMP NFC bridge handler | Done | High | - | [plans/NS-04-apdu-allowlist.md](./plans/NS-04-apdu-allowlist.md) | KMP rejects caller-supplied `apduCommands` at the bridge boundary; only the built-in passport scan sequence is allowed. |
| NS-05 | LifecycleBridgeHandler type/error semantics on iOS | Ready | Low | - | [plans/NS-05-lifecycle-handler-semantics.md](./plans/NS-05-lifecycle-handler-semantics.md) | - |
| NS-06 | Align KMP callback/result contract with canonical SDK types | Done | Medium | NS-01 | [plans/NS-06-kmp-callback-contract-alignment.md](./plans/NS-06-kmp-callback-contract-alignment.md) | - |
| NS-07 | Remove legacy `{ type }` shim from native lifecycle handlers | Blocked | Medium | - | - | Blocked on WebView bundle emitting canonical outcomes instead of flat `{ type }` payloads. Requires coordinated change: update `ConfirmIdentificationScreen.tsx` and `ProvingScreen.tsx` to send canonical `VerificationResult`, then remove the `type != null` branch from both Android and iOS `LifecycleBridgeHandler`. |
@@ -98,7 +98,7 @@ Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
| -------------------------------------------------------------------------------------------------- | ----- | ------ |
| [plans/NS-01-physical-device-validation.md](./plans/NS-01-physical-device-validation.md) | NS-01 | Done |
| [plans/NS-03-publishing-readiness.md](./plans/NS-03-publishing-readiness.md) | NS-03 | Done |
| [plans/NS-04-apdu-allowlist.md](./plans/NS-04-apdu-allowlist.md) | NS-04 | Ready |
| [plans/NS-04-apdu-allowlist.md](./plans/NS-04-apdu-allowlist.md) | NS-04 | Done |
| [plans/NS-05-lifecycle-handler-semantics.md](./plans/NS-05-lifecycle-handler-semantics.md) | NS-05 | Ready |
| [plans/NS-06-kmp-callback-contract-alignment.md](./plans/NS-06-kmp-callback-contract-alignment.md) | NS-06 | Done |
| [plans/NS-08-ship-artifacts-to-minipay.md](./plans/NS-08-ship-artifacts-to-minipay.md) | NS-08 | Ready |
@@ -117,6 +117,7 @@ Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
- `NS-06` completed on 2026-03-10. KMP now exposes the canonical `VerificationResult` shape (`success`, `userId`, `verificationId`, `proof`, `claims`, `error`), and `claims` now carries heterogeneous values via `Map<String, Any?>`.
- Flat lifecycle `{ type }` payloads remain supported as an internal compatibility shim while the embedded WebView bundle still emits them, but KMP host apps no longer receive a public `VerificationResult.type` field. Tracked as `NS-07` for removal once the WebView sends canonical outcomes.
- `NS-03` completed on 2026-03-10. Audit validated AAR and XCFramework generation. Four items block shipping to MiniPay: Maven repo target (~10 LOC config), release XCFramework variants (~3 LOC), hosted XCFramework for SPM, and NFCPassportReader fork accessibility. Tracked as NS-08 and NS-09.
- `NS-04` completed on 2026-03-10. KMP now rejects caller-supplied `apduCommands` on both Android and iOS before any NFC tag/provider work begins, preserving the existing high-level passport scan contract and preventing raw APDU passthrough.
## Overview

View File

@@ -1,7 +1,7 @@
# APDU Allowlist in KMP NFC Bridge Handler
> Last updated: 2026-03-10
> Status: Ready
> Status: Done
- Workstream: native-shells
- Backlog IDs: NS-04
@@ -11,13 +11,13 @@
## Why
- The NFC bridge currently accepts APDU commands from the WebView without allowlist validation.
- The React Native SDK exposes raw APDU exchange, but the KMP NFC bridge should not accept caller-supplied APDU commands at all.
- This is a security boundary issue and should be resolved before broader rollout.
## Scope
- Add APDU command-prefix allowlisting to the KMP NFC bridge handler.
- Add tests for rejected commands.
- Reject caller-supplied `apduCommands` at the KMP NFC bridge boundary on Android and iOS.
- Add tests for allowed scan params and rejected APDU input.
- Update security hardening tracking to point at this plan.
## Out of Scope
@@ -43,22 +43,25 @@
## Implementation Notes
- Allow only eMRTD command families needed for passport reading.
- Reject before transceive.
- KMP does not expose a raw APDU/transceive API; the only allowed NFC path is the built-in passport `scan` flow.
- Reject `apduCommands` before tag/provider work begins.
- Avoid leaking raw APDU bytes in error messages.
## Validation
```bash
cd packages/kmp-sdk && ./gradlew :shared:jvmTest
cd packages/kmp-sdk && ./gradlew :shared:compileDebugKotlinAndroid
cd packages/kmp-sdk && ./gradlew :shared:compileKotlinIosArm64
```
## Definition of Done
- [ ] KMP APDU allowlist implemented
- [ ] Reject-path tests added
- [ ] Spec backlog updated
- [x] KMP APDU allowlist implemented
- [x] Reject-path tests added
- [x] Spec backlog updated
## Status Log
- 2026-03-10: Created from security hardening follow-up.
- 2026-03-10: Implemented a bridge-boundary APDU policy for KMP. Caller-supplied `apduCommands` are rejected on both platforms; only the built-in passport scan sequence is allowed.