diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt index 44413fc17..d8b98a8b3 100644 --- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt +++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt @@ -83,6 +83,7 @@ class NfcBridgeHandler( } private suspend fun scan(params: Map): JsonElement { + NfcApduPolicy.requireSupportedParams(params) val scanParams = json.decodeFromJsonElement(NfcScanParams.serializer(), JsonObject(params)) pushProgress("waiting_for_tag", 0, "Hold your phone near the passport") diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/NfcApduPolicy.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/NfcApduPolicy.kt new file mode 100644 index 000000000..8fab8f892 --- /dev/null +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/NfcApduPolicy.kt @@ -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) { + val apduCommands = params[APDU_COMMANDS_PARAM] ?: return + if (apduCommands == JsonNull) return + if (apduCommands is JsonArray && apduCommands.isEmpty()) return + + throw BridgeHandlerException(REJECTION_CODE, REJECTION_MESSAGE) + } +} diff --git a/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/handlers/NfcApduPolicyTest.kt b/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/handlers/NfcApduPolicyTest.kt new file mode 100644 index 000000000..98cfa1c73 --- /dev/null +++ b/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/handlers/NfcApduPolicyTest.kt @@ -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 { + 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 { + 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 +} diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt index 6696dd80e..972b002cb 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/NfcBridgeHandler.kt @@ -41,6 +41,7 @@ class NfcBridgeHandler( } private suspend fun scan(params: Map): JsonElement { + NfcApduPolicy.requireSupportedParams(params) val provider = SdkProviderRegistry.nfc ?: throw BridgeHandlerException("NOT_CONFIGURED", "NFC provider not configured") diff --git a/specs/projects/sdk/workstreams/native-shells/SPEC.md b/specs/projects/sdk/workstreams/native-shells/SPEC.md index bf927b93f..b1ab5cd4d 100644 --- a/specs/projects/sdk/workstreams/native-shells/SPEC.md +++ b/specs/projects/sdk/workstreams/native-shells/SPEC.md @@ -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`. - 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 diff --git a/specs/projects/sdk/workstreams/native-shells/plans/NS-04-apdu-allowlist.md b/specs/projects/sdk/workstreams/native-shells/plans/NS-04-apdu-allowlist.md index 5ae336ed3..1c3ad1b08 100644 --- a/specs/projects/sdk/workstreams/native-shells/plans/NS-04-apdu-allowlist.md +++ b/specs/projects/sdk/workstreams/native-shells/plans/NS-04-apdu-allowlist.md @@ -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.