mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
SELF-2484: Delegate keychain to consumer (#1894)
* SELF-2484: Delegate keychain to consumer * lint * update coderabbit comments
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/*
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package xyz.self.sdk.api
|
||||
|
||||
class SelfSdkLaunchConfig(
|
||||
val config: SelfSdkConfig,
|
||||
val secureStorageProvider: SecureStorageProvider,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user