From 02e760805088afeb426fa769338f27cccd1b00f3 Mon Sep 17 00:00:00 2001 From: "Seshanth.S" <35675963+seshanthS@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:29:33 +0530 Subject: [PATCH] SELF-2484: Delegate keychain to consumer (#1894) * SELF-2484: Delegate keychain to consumer * lint * update coderabbit comments --- .gitignore | 3 + common/src/utils/kyc/generateInputs.ts | 5 +- .../native-shell-android/build.gradle.kts | 1 - .../xyz/self/sdk/api/SecureStorageProvider.kt | 11 ++++ .../main/kotlin/xyz/self/sdk/api/SelfSdk.kt | 7 ++- .../xyz/self/sdk/api/SelfSdkLaunchConfig.kt | 8 +++ .../self/sdk/handlers/SecureStorageHandler.kt | 30 ++------- .../sdk/webview/SelfVerificationActivity.kt | 15 ++++- .../API/SecureStorageProvider.swift | 9 +++ .../Sources/SelfNativeShell/API/SelfSdk.swift | 2 +- .../SelfNativeShell/API/SelfSdkConfig.swift | 5 +- .../Handlers/SecureStorageHandler.swift | 63 +++---------------- .../sdk-test-app/android/app/build.gradle.kts | 1 + .../testapp/EncryptedPrefsStorageProvider.kt | 36 +++++++++++ .../kotlin/xyz/self/testapp/MainActivity.kt | 7 ++- .../ios/SelfTestApp/ContentView.swift | 3 +- .../SelfTestApp/KeychainStorageProvider.swift | 57 +++++++++++++++++ .../workstreams/native-shells-lite/SPEC.md | 4 +- .../plans/NSL-04-delegate-keychain.md | 3 +- 19 files changed, 179 insertions(+), 91 deletions(-) create mode 100644 packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SecureStorageProvider.kt create mode 100644 packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkLaunchConfig.kt create mode 100644 packages/native-shell-ios/Sources/SelfNativeShell/API/SecureStorageProvider.swift create mode 100644 packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/EncryptedPrefsStorageProvider.kt create mode 100644 packages/sdk-test-app/ios/SelfTestApp/KeychainStorageProvider.swift diff --git a/.gitignore b/.gitignore index ed55852af..9ba67c2c4 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ contracts/broadcast/ !packages/rn-sdk-test-app/react-native.config.cjs packages/native-shell-android/.gradle/ packages/native-shell-android/build/ + +app/build/* +packages/mobile-sdk-alpha-source/android/.gradle/* diff --git a/common/src/utils/kyc/generateInputs.ts b/common/src/utils/kyc/generateInputs.ts index 81f61f9a0..c502369ac 100644 --- a/common/src/utils/kyc/generateInputs.ts +++ b/common/src/utils/kyc/generateInputs.ts @@ -177,7 +177,10 @@ export const generateKycDiscloseInputFromData = ( ): KycDiscloseInput => { // Use raw bytes directly — .toString('utf-8') corrupts bytes >= 128 const raw = Buffer.from(serializedApplicantInfo, 'base64'); - const msgPadded = [...Array.from(raw, (b) => Number(b)), ...new Array(Math.max(0, KYC_MAX_LENGTH - raw.length)).fill(0)]; + const msgPadded = [ + ...Array.from(raw, (b) => Number(b)), + ...new Array(Math.max(0, KYC_MAX_LENGTH - raw.length)).fill(0), + ]; // Compute commitment const commitment = poseidon2([secret, packBytesAndPoseidon(msgPadded)]); diff --git a/packages/native-shell-android/build.gradle.kts b/packages/native-shell-android/build.gradle.kts index 2ce8cfd5e..f91fef659 100644 --- a/packages/native-shell-android/build.gradle.kts +++ b/packages/native-shell-android/build.gradle.kts @@ -56,7 +56,6 @@ tasks.named("preBuild") { dependencies { implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.webkit:webkit:1.9.0") - implementation("androidx.security:security-crypto:1.1.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SecureStorageProvider.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SecureStorageProvider.kt new file mode 100644 index 000000000..3c538edfd --- /dev/null +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SecureStorageProvider.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: BUSL-1.1 + +package xyz.self.sdk.api + +interface SecureStorageProvider { + @Throws(Exception::class) + fun get(key: String): String? + + fun set(key: String, value: String) + fun remove(key: String) +} diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt index e056fa86c..963355b60 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt @@ -7,7 +7,12 @@ import android.content.Intent import xyz.self.sdk.webview.SelfVerificationActivity object SelfSdk { - fun launch(activity: Activity, config: SelfSdkConfig, requestCode: Int = REQUEST_CODE_VERIFICATION) { + internal var secureStorageProvider: SecureStorageProvider? = null + private set + + fun launch(activity: Activity, launchConfig: SelfSdkLaunchConfig, requestCode: Int = REQUEST_CODE_VERIFICATION) { + secureStorageProvider = launchConfig.secureStorageProvider + val config = launchConfig.config val intent = Intent(activity, SelfVerificationActivity::class.java).apply { putExtra(SelfVerificationActivity.EXTRA_ENVIRONMENT, config.environment) putExtra(SelfVerificationActivity.EXTRA_VERIFICATION_ID, config.verificationId) diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkLaunchConfig.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkLaunchConfig.kt new file mode 100644 index 000000000..8d2e1a4ca --- /dev/null +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkLaunchConfig.kt @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 + +package xyz.self.sdk.api + +class SelfSdkLaunchConfig( + val config: SelfSdkConfig, + val secureStorageProvider: SecureStorageProvider, +) diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt index 59fcdd3ee..ef9134fd7 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/handlers/SecureStorageHandler.kt @@ -2,39 +2,19 @@ package xyz.self.sdk.handlers -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive +import xyz.self.sdk.api.SecureStorageProvider import xyz.self.sdk.bridge.BridgeDomain import xyz.self.sdk.bridge.BridgeHandler import xyz.self.sdk.bridge.BridgeHandlerException -class SecureStorageHandler(context: Context) : BridgeHandler { +class SecureStorageHandler(private val provider: SecureStorageProvider) : BridgeHandler { override val domain = BridgeDomain.SECURE_STORAGE - private val prefs: SharedPreferences - - // requireBiometric is intentionally ignored — device lock provides sufficient security per spec - init { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - prefs = EncryptedSharedPreferences.create( - context, - "self_sdk_secure_prefs", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } - override suspend fun handle( method: String, params: Map, @@ -48,7 +28,7 @@ class SecureStorageHandler(context: Context) : BridgeHandler { private fun get(params: Map): JsonElement { val key = params["key"]?.jsonPrimitive?.content ?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required") - val value = prefs.getString(key, null) + val value = provider.get(key) return buildJsonObject { put("value", if (value != null) JsonPrimitive(value) else JsonNull) } @@ -59,14 +39,14 @@ class SecureStorageHandler(context: Context) : BridgeHandler { ?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required") val value = params["value"]?.jsonPrimitive?.content ?: throw BridgeHandlerException("MISSING_VALUE", "Value parameter required") - prefs.edit().putString(key, value).apply() + provider.set(key, value) return null } private fun remove(params: Map): JsonElement? { val key = params["key"]?.jsonPrimitive?.content ?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required") - prefs.edit().remove(key).apply() + provider.remove(key) return null } } diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt index da673fe58..89caa5b22 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.os.Bundle import android.webkit.WebChromeClient import androidx.appcompat.app.AppCompatActivity +import xyz.self.sdk.api.SelfSdk import xyz.self.sdk.bridge.MessageRouter import xyz.self.sdk.handlers.CryptoHandler import xyz.self.sdk.handlers.LifecycleHandler @@ -36,7 +37,19 @@ class SelfVerificationActivity : AppCompatActivity() { }, ) - router.register(SecureStorageHandler(this)) + val storageProvider = SelfSdk.secureStorageProvider + if (storageProvider == null) { + setResult( + RESULT_FIRST_USER, + Intent().putExtra( + EXTRA_RESULT_DATA, + """{"error":{"code":"INIT_ERROR","message":"SecureStorageProvider not set"}}""", + ), + ) + finish() + return + } + router.register(SecureStorageHandler(storageProvider)) router.register(CryptoHandler()) router.register(LifecycleHandler(this)) diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/API/SecureStorageProvider.swift b/packages/native-shell-ios/Sources/SelfNativeShell/API/SecureStorageProvider.swift new file mode 100644 index 000000000..f8e40da11 --- /dev/null +++ b/packages/native-shell-ios/Sources/SelfNativeShell/API/SecureStorageProvider.swift @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation + +public protocol SecureStorageProvider: AnyObject { + func get(key: String) throws -> String? + func set(key: String, value: String) throws + func remove(key: String) throws +} diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift index 914e0d06f..091523301 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift @@ -54,7 +54,7 @@ final class SelfSdkViewController: UIViewController { let router = MessageRouter { [weak self] js in self?.webViewHost?.evaluateJs(js) } - router.register(handler: SecureStorageHandler()) + router.register(handler: SecureStorageHandler(provider: config.secureStorageProvider)) router.register(handler: CryptoHandler()) router.register(handler: lifecycleHandler) diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift index 6497f3f93..a9e0a4533 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift @@ -13,6 +13,7 @@ public struct SelfSdkConfig { public let appName: String? public let appEndpoint: String? public let resultType: String? + public let secureStorageProvider: SecureStorageProvider public init( verificationId: String, @@ -24,7 +25,8 @@ public struct SelfSdkConfig { disclosures: [String]? = nil, appName: String? = nil, appEndpoint: String? = nil, - resultType: String? = nil + resultType: String? = nil, + secureStorageProvider: SecureStorageProvider ) { self.verificationId = verificationId self.userId = userId @@ -36,6 +38,7 @@ public struct SelfSdkConfig { self.appName = appName self.appEndpoint = appEndpoint self.resultType = resultType + self.secureStorageProvider = secureStorageProvider } func toQueryParams() -> String { diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/Handlers/SecureStorageHandler.swift b/packages/native-shell-ios/Sources/SelfNativeShell/Handlers/SecureStorageHandler.swift index 900be9a63..637eae07b 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/Handlers/SecureStorageHandler.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/Handlers/SecureStorageHandler.swift @@ -1,13 +1,15 @@ // SPDX-License-Identifier: BUSL-1.1 import Foundation -import Security final class SecureStorageHandler: BridgeHandler { let domain: BridgeDomain = .secureStorage - // requireBiometric is intentionally ignored — device lock provides sufficient security per spec - private let service = "xyz.self.sdk" + private let provider: SecureStorageProvider + + init(provider: SecureStorageProvider) { + self.provider = provider + } func handle(method: String, params: [String: Any]?) async throws -> Any? { let result: Any? @@ -17,7 +19,7 @@ final class SecureStorageHandler: BridgeHandler { guard let key = params?["key"] as? String else { throw BridgeHandlerError.missingParam("key") } - if let value = get(key: key) { + if let value = try provider.get(key: key) { result = ["value": value] } else { result = ["value": NSNull()] @@ -30,14 +32,14 @@ final class SecureStorageHandler: BridgeHandler { guard let value = params?["value"] as? String else { throw BridgeHandlerError.missingParam("value") } - try set(key: key, value: value) + try provider.set(key: key, value: value) result = nil case "remove": guard let key = params?["key"] as? String else { throw BridgeHandlerError.missingParam("key") } - remove(key: key) + try provider.remove(key: key) result = nil default: @@ -45,53 +47,4 @@ final class SecureStorageHandler: BridgeHandler { } return result } - - private func get(key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, let data = result as? Data else { - return nil - } - return String(data: data, encoding: .utf8) - } - - private func set(key: String, value: String) throws { - guard let data = value.data(using: .utf8) else { - throw BridgeHandlerError.operationFailed("Failed to encode value") - } - - // Remove existing item first - remove(key: key) - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly - ] - - let status = SecItemAdd(query as CFDictionary, nil) - guard status == errSecSuccess else { - throw BridgeHandlerError.operationFailed("Keychain set failed: \(status)") - } - } - - private func remove(key: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key - ] - SecItemDelete(query as CFDictionary) - } } diff --git a/packages/sdk-test-app/android/app/build.gradle.kts b/packages/sdk-test-app/android/app/build.gradle.kts index e9e790eab..412d7dd82 100644 --- a/packages/sdk-test-app/android/app/build.gradle.kts +++ b/packages/sdk-test-app/android/app/build.gradle.kts @@ -38,6 +38,7 @@ android { dependencies { // Native shell SDK implementation("xyz.self.sdk:native-shell-android") + implementation("androidx.security:security-crypto:1.1.0") // Compose implementation(platform("androidx.compose:compose-bom:2024.01.00")) diff --git a/packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/EncryptedPrefsStorageProvider.kt b/packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/EncryptedPrefsStorageProvider.kt new file mode 100644 index 000000000..fb2a55b21 --- /dev/null +++ b/packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/EncryptedPrefsStorageProvider.kt @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BUSL-1.1 +package xyz.self.testapp + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import xyz.self.sdk.api.SecureStorageProvider + +class EncryptedPrefsStorageProvider(context: Context) : SecureStorageProvider { + private val prefs: SharedPreferences + + init { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + prefs = EncryptedSharedPreferences.create( + context, + "self_sdk_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + override fun get(key: String): String? = prefs.getString(key, null) + + override fun set(key: String, value: String) { + prefs.edit().putString(key, value).apply() + } + + override fun remove(key: String) { + prefs.edit().remove(key).apply() + } +} diff --git a/packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/MainActivity.kt b/packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/MainActivity.kt index eb9da81ec..5fb7524a7 100644 --- a/packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/MainActivity.kt +++ b/packages/sdk-test-app/android/app/src/main/kotlin/xyz/self/testapp/MainActivity.kt @@ -19,6 +19,7 @@ import xyz.self.sdk.api.SelfSdk import xyz.self.sdk.api.SelfSdkCallback import xyz.self.sdk.api.SelfSdkConfig import xyz.self.sdk.api.SelfSdkException +import xyz.self.sdk.api.SelfSdkLaunchConfig class MainActivity : ComponentActivity() { @@ -40,7 +41,11 @@ class MainActivity : ComponentActivity() { private fun launchVerification(config: SelfSdkConfig) { resultText = "Launching..." - SelfSdk.launch(this, config) + val launchConfig = SelfSdkLaunchConfig( + config = config, + secureStorageProvider = EncryptedPrefsStorageProvider(this), + ) + SelfSdk.launch(this, launchConfig) } @Deprecated("Use Activity Result API for newer apps") diff --git a/packages/sdk-test-app/ios/SelfTestApp/ContentView.swift b/packages/sdk-test-app/ios/SelfTestApp/ContentView.swift index 1261d5998..993e283bc 100644 --- a/packages/sdk-test-app/ios/SelfTestApp/ContentView.swift +++ b/packages/sdk-test-app/ios/SelfTestApp/ContentView.swift @@ -113,7 +113,8 @@ struct ContentView: View { disclosures: disclosures.isEmpty ? nil : disclosures.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }, appName: appName.isEmpty ? nil : appName, appEndpoint: appEndpoint.isEmpty ? nil : appEndpoint, - resultType: resultType.isEmpty ? nil : resultType + resultType: resultType.isEmpty ? nil : resultType, + secureStorageProvider: KeychainStorageProvider() ), onResult: { result in resultText = result diff --git a/packages/sdk-test-app/ios/SelfTestApp/KeychainStorageProvider.swift b/packages/sdk-test-app/ios/SelfTestApp/KeychainStorageProvider.swift new file mode 100644 index 000000000..167f154c9 --- /dev/null +++ b/packages/sdk-test-app/ios/SelfTestApp/KeychainStorageProvider.swift @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation +import Security +import SelfNativeShell + +final class KeychainStorageProvider: SecureStorageProvider { + private let service = "xyz.self.sdk" + + func get(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + + func set(key: String, value: String) throws { + guard let data = value.data(using: .utf8) else { + throw NSError(domain: "KeychainStorageProvider", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode value"]) + } + + try remove(key: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: "KeychainStorageProvider", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Keychain set failed: \(status)"]) + } + } + + func remove(key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/specs/projects/sdk/workstreams/native-shells-lite/SPEC.md b/specs/projects/sdk/workstreams/native-shells-lite/SPEC.md index cc770d681..15e1f184a 100644 --- a/specs/projects/sdk/workstreams/native-shells-lite/SPEC.md +++ b/specs/projects/sdk/workstreams/native-shells-lite/SPEC.md @@ -60,7 +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 | +| NSL-04 | Delegate keychain to SDK consumers | Done | Medium | NSL-01, NSL-02, NSL-03 | [plans/NSL-04-delegate-keychain.md](./plans/NSL-04-delegate-keychain.md) | Implemented on `dev` | Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done` @@ -71,7 +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 | +| [plans/NSL-04-delegate-keychain.md](./plans/NSL-04-delegate-keychain.md) | NSL-04 | Done | ## Completion Checklist diff --git a/specs/projects/sdk/workstreams/native-shells-lite/plans/NSL-04-delegate-keychain.md b/specs/projects/sdk/workstreams/native-shells-lite/plans/NSL-04-delegate-keychain.md index f8f7fd902..abf487cd6 100644 --- a/specs/projects/sdk/workstreams/native-shells-lite/plans/NSL-04-delegate-keychain.md +++ b/specs/projects/sdk/workstreams/native-shells-lite/plans/NSL-04-delegate-keychain.md @@ -1,7 +1,7 @@ ## Delegate Keychain to SDK Consumers > Last updated: 2026-03-31 -> Status: Ready +> Status: Done - Workstream: native-shells-lite - Backlog IDs: NSL-04 @@ -247,3 +247,4 @@ cd packages/sdk-test-app/ios && xcodegen generate && xcodebuild -project SelfTes ### Status Log - 2026-03-31: Plan created. +- 2026-03-31: Implementation complete. All files created/modified per spec.