mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Reject raw APDU commands in KMP NFC bridge (#1839)
* update specs * update specs * harden nfc apdu
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user