SELF-2484: Delegate keychain to consumer (#1894)

* SELF-2484: Delegate keychain to consumer

* lint

* update coderabbit comments
This commit is contained in:
Seshanth.S
2026-03-31 18:29:33 +05:30
committed by GitHub
parent 868532d12d
commit 02e7608050
19 changed files with 179 additions and 91 deletions

3
.gitignore vendored
View File

@@ -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/*

View File

@@ -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)]);

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -0,0 +1,8 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.api
class SelfSdkLaunchConfig(
val config: SelfSdkConfig,
val secureStorageProvider: SecureStorageProvider,
)

View File

@@ -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<String, JsonElement>,
@@ -48,7 +28,7 @@ class SecureStorageHandler(context: Context) : BridgeHandler {
private fun get(params: Map<String, JsonElement>): 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<String, JsonElement>): JsonElement? {
val key = params["key"]?.jsonPrimitive?.content
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
prefs.edit().remove(key).apply()
provider.remove(key)
return null
}
}

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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"))

View File

@@ -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()
}
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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.