Add NSL-04: Delegate keychain management to SDK consumers (#1893)

This commit is contained in:
Seshanth.S
2026-03-31 16:10:54 +05:30
committed by GitHub
parent 10f18d8d7f
commit 868532d12d
2 changed files with 251 additions and 0 deletions

View File

@@ -60,6 +60,7 @@
| NSL-01 | Android native shell (plain Kotlin) | In Progress | High | - | [plans/NSL-01-android-shell.md](./plans/NSL-01-android-shell.md) | Code complete on `feat/webview-sdk`, needs testing |
| NSL-02 | iOS native shell (plain Swift) | In Progress | High | - | [plans/NSL-02-ios-shell.md](./plans/NSL-02-ios-shell.md) | Code complete on `feat/webview-sdk`, needs testing |
| NSL-03 | Test apps (adapt from kmp-sdk-test-app) | In Progress | Medium | NSL-01, NSL-02 | [plans/NSL-03-test-apps.md](./plans/NSL-03-test-apps.md) | Code complete on `feat/webview-sdk`, needs build verification |
| NSL-04 | Delegate keychain to SDK consumers | Ready | Medium | NSL-01, NSL-02, NSL-03 | [plans/NSL-04-delegate-keychain.md](./plans/NSL-04-delegate-keychain.md) | ~500-700 LOC |
Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
@@ -70,6 +71,7 @@ Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
| [plans/NSL-01-android-shell.md](./plans/NSL-01-android-shell.md) | NSL-01 | In Progress (code complete, needs testing) |
| [plans/NSL-02-ios-shell.md](./plans/NSL-02-ios-shell.md) | NSL-02 | In Progress (code complete, needs testing) |
| [plans/NSL-03-test-apps.md](./plans/NSL-03-test-apps.md) | NSL-03 | In Progress (code complete, needs build verification) |
| [plans/NSL-04-delegate-keychain.md](./plans/NSL-04-delegate-keychain.md) | NSL-04 | Ready |
## Completion Checklist

View File

@@ -0,0 +1,249 @@
## Delegate Keychain to SDK Consumers
> Last updated: 2026-03-31
> Status: Ready
- Workstream: native-shells-lite
- Backlog IDs: NSL-04
- Owner: TBD
- Branch: TBD
- PR: TBD
### Why
- SDK consumers (host apps) cannot control how or where secrets are stored. The native shells hardcode `EncryptedSharedPreferences` (Android) and Keychain Services (iOS) in `SecureStorageHandler`.
- Some consumers need custom keychain configurations, different encryption schemes, or integration with their existing secure storage infrastructure.
- You are defining a `SecureStorageProvider` protocol/interface that consumers must implement and pass via config. The native shell's `SecureStorageHandler` becomes a thin bridge delegate instead of owning the implementation.
### Scope
- `secureStorage` domain only (get/set/remove)
- Android native shell: `packages/native-shell-android/`
- iOS native shell: `packages/native-shell-ios/`
- Test apps: `packages/sdk-test-app/`
### Out of Scope
- `crypto` domain — stays native-managed
- `lifecycle` domain — unchanged
- `packages/webview-bridge/` — bridge protocol unchanged (secureStorage domain stays the same)
- `packages/webview-app/` — WebView code unchanged (still calls bridge the same way)
- `packages/mobile-sdk-alpha/` — SDK core unchanged
- `app/` — RN app unaffected
- `packages/kmp-sdk/` — KMP SDK unchanged (already has its own provider pattern)
### Implementation Steps
#### 1. Define `SecureStorageProvider` interface
**Android** — New file: `packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SecureStorageProvider.kt`
```kotlin
interface SecureStorageProvider {
fun get(key: String): String?
fun set(key: String, value: String)
fun remove(key: String)
}
```
**iOS** — New file: `packages/native-shell-ios/Sources/SelfNativeShell/API/SecureStorageProvider.swift`
```swift
public protocol SecureStorageProvider: AnyObject {
func get(key: String) -> String?
func set(key: String, value: String) throws
func remove(key: String) throws
}
```
Reference: KMP SDK already has this pattern at `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/SecureStorageProvider.kt:7-18` (but with an extra `clear()` you are omitting).
#### 2. Add provider to SDK config
**Android** — New file: `packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkLaunchConfig.kt`
Android launches the SDK via Intent, which cannot carry interface references. You will introduce `SelfSdkLaunchConfig` that wraps the serializable `SelfSdkConfig` + non-serializable `SecureStorageProvider`. The serializable fields go through the Intent; the provider is stored in a companion object.
```kotlin
data class SelfSdkLaunchConfig(
val config: SelfSdkConfig,
val secureStorageProvider: SecureStorageProvider,
)
```
- `SelfSdkConfig` stays unchanged (serializable for Intent transport)
- `SelfSdk.launch()` signature changes to accept `SelfSdkLaunchConfig`
- Provider stored in companion object, config fields go through Intent extras
**iOS**`packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift`
- Add `public let secureStorageProvider: SecureStorageProvider` to `SelfSdkConfig`
- No serialization constraint on iOS
#### 3. Modify `SecureStorageHandler` to delegate
**Android**`packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt`
- Remove `EncryptedSharedPreferences` / `MasterKey` setup from `init`
- Constructor takes `SecureStorageProvider` instead of `Context`
- `get()``provider.get(key)`
- `set()``provider.set(key, value)`
- `remove()``provider.remove(key)`
**iOS**`packages/native-shell-ios/Sources/SelfNativeShell/Handlers/SecureStorageHandler.swift`
- Remove direct Keychain (`Security` framework) calls
- Constructor takes `SecureStorageProvider`
- All methods delegate to provider
#### 4. Update SDK initialization
**Android**`packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt:45`
- Change: `router.register(SecureStorageHandler(this))``router.register(SecureStorageHandler(SelfSdk.secureStorageProvider!!))`
- Add validation: fail fast if provider is null
**Android**`packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt`
- Store provider in companion object alongside callback (line 17 pattern)
- Validate provider is set in `launch()` before starting Activity
- Update `launch()` signature:
```kotlin
fun launch(activity: Activity, launchConfig: SelfSdkLaunchConfig, requestCode: Int) {
this.secureStorageProvider = launchConfig.secureStorageProvider
// pass launchConfig.config fields via Intent extras (existing pattern)
}
```
**iOS**`packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift:57`
- Change: `router.register(handler: SecureStorageHandler())``router.register(handler: SecureStorageHandler(provider: config.secureStorageProvider))`
#### 5. Remove built-in keychain dependencies
**Android:**
- Remove `androidx.security:security-crypto` dependency if no longer used elsewhere
- Remove `EncryptedSharedPreferences` and `MasterKey` imports from `SecureStorageHandler`
**iOS:**
- Remove `Security` framework import from `SecureStorageHandler`
- Remove `SecItemAdd`, `SecItemCopyMatching`, `SecItemDelete` calls
#### 6. Update sdk-test-app (consumer reference implementation)
The test app (`packages/sdk-test-app/`) is a minimal Jetpack Compose (Android) / SwiftUI (iOS) harness. It currently calls `SelfSdk.launch(this, config)` without providing any keychain. After this change, it must provide a `SecureStorageProvider`.
The test app will include a **reference implementation** of `SecureStorageProvider` that uses the same secure storage APIs the native shells currently use (effectively moving the implementation from the shell to the consumer).
**Android** — New file: `packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/EncryptedPrefsStorageProvider.kt`
- Implements `SecureStorageProvider` using `EncryptedSharedPreferences` (same as current `SecureStorageHandler`)
- Used as reference for SDK consumers
**Android** — Update: `packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/MainActivity.kt`
```kotlin
SelfSdk.launch(
activity = this,
launchConfig = SelfSdkLaunchConfig(
config = config,
secureStorageProvider = EncryptedPrefsStorageProvider(this),
),
requestCode = REQUEST_CODE,
)
```
**iOS** — New file: `packages/sdk-test-app/ios/SelfTestApp/KeychainStorageProvider.swift`
- Implements `SecureStorageProvider` using iOS Keychain (same as current `SecureStorageHandler`)
- Used as reference for SDK consumers
**iOS** — Update: `packages/sdk-test-app/ios/SelfTestApp/ContentView.swift`
- Pass `secureStorageProvider: KeychainStorageProvider()` in `SelfSdkConfig`
**Android build.gradle**`packages/sdk-test-app/android/app/build.gradle.kts`
- Add `androidx.security:security-crypto` dependency (moved from native shell)
### Files Created
| File | Purpose |
|------|---------|
| `packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SecureStorageProvider.kt` | Interface definition |
| `packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkLaunchConfig.kt` | Wraps SelfSdkConfig + SecureStorageProvider |
| `packages/native-shell-ios/Sources/SelfNativeShell/API/SecureStorageProvider.swift` | Protocol definition |
| `packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/EncryptedPrefsStorageProvider.kt` | Reference impl using EncryptedSharedPreferences |
| `packages/sdk-test-app/ios/SelfTestApp/KeychainStorageProvider.swift` | Reference impl using iOS Keychain |
### Files Modified
| File | Change |
|------|--------|
| `packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt` | Change `launch()` to accept `SelfSdkLaunchConfig` |
| `packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt` | Delegate to provider, remove EncryptedSharedPreferences |
| `packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt` | Pass provider to handler |
| `packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift` | Add `secureStorageProvider` property |
| `packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift` | Pass provider to handler |
| `packages/native-shell-ios/Sources/SelfNativeShell/Handlers/SecureStorageHandler.swift` | Delegate to provider, remove Keychain calls |
| `packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/MainActivity.kt` | Use SelfSdkLaunchConfig with provider |
| `packages/sdk-test-app/android/app/build.gradle.kts` | Add security-crypto dependency |
| `packages/sdk-test-app/ios/SelfTestApp/ContentView.swift` | Pass provider in SelfSdkConfig |
### Files NOT Modified
- `packages/webview-bridge/` — Bridge protocol unchanged
- `packages/webview-app/` — WebView code unchanged
- `packages/mobile-sdk-alpha/` — SDK core unchanged
- `app/` — RN app unaffected
- `packages/kmp-sdk/` — KMP SDK unchanged
- Crypto domain handlers — Stay native-managed
### Preconditions
- NSL-01 (Android shell) complete
- NSL-02 (iOS shell) complete
- NSL-03 (Test apps) complete
### Validation
```bash
# Android native shell
cd packages/native-shell-android && ./gradlew assembleDebug
# iOS native shell
cd packages/native-shell-ios && swift build
# Android test app
cd packages/sdk-test-app/android && ./gradlew :app:assembleDebug
# iOS test app
cd packages/sdk-test-app/ios && xcodegen generate && xcodebuild -project SelfTestApp.xcodeproj -scheme SelfTestApp -sdk iphonesimulator build
# Negative test: temporarily remove provider from test app launch call → verify compile error (provider is required)
# E2E: complete a verification flow in test app, confirm documents persist across sessions (proving keychain write/read works through provider)
```
### Definition of Done
- [ ] `SecureStorageProvider` interface exists on both platforms
- [ ] `SecureStorageHandler` delegates to provider on both platforms
- [ ] Built-in keychain dependencies removed from native shells
- [ ] `SelfSdk.launch()` requires provider via `SelfSdkLaunchConfig` (Android) / `SelfSdkConfig` (iOS)
- [ ] Test app includes reference implementations and builds on both platforms
- [ ] Compile error if consumer omits provider (fail closed)
- [ ] E2E verification flow works through the delegated provider
- [ ] Backlog row updated
- [ ] Plan status updated
### Estimated PR Size
~500700 LOC changed. Within the 1k3k target.
### Status Log
- 2026-03-31: Plan created.