Merge pull request #1960 from selfxyz/release/staging-2026-04-10

Release to Staging v2.9.17 - 2026-04-10
This commit is contained in:
Justin Hernandez
2026-04-10 10:25:45 -07:00
committed by GitHub
25 changed files with 422 additions and 226 deletions

View File

@@ -199,18 +199,18 @@ library Formatter {
return bytesArray;
}
function fieldElementsToBytesKyc(uint256[11] memory publicSignals) internal pure returns (bytes memory) {
for (uint256 i = 0; i < 11; i++) {
function fieldElementsToBytesKyc(uint256[10] memory publicSignals) internal pure returns (bytes memory) {
for (uint256 i = 0; i < 10; i++) {
if (publicSignals[i] >= SNARK_SCALAR_FIELD) {
revert InvalidFieldElement();
}
}
uint8[11] memory bytesCount = [31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 25];
bytes memory bytesArray = new bytes(335);
uint8[10] memory bytesCount = [31, 31, 31, 31, 31, 31, 31, 31, 31, 19];
bytes memory bytesArray = new bytes(298);
uint256 index = 0;
for (uint256 i = 0; i < 11; i++) {
for (uint256 i = 0; i < 10; i++) {
uint256 element = publicSignals[i];
for (uint8 j = 0; j < bytesCount[i]; j++) {
bytesArray[index++] = bytes1(uint8(element & 0xff));

View File

@@ -109,6 +109,13 @@ library OutputFormatterLib {
}
aadhaarOutput.revealedDataPacked = Formatter.fieldElementsToBytesAadhaar(revealedDataPacked);
// Extract forbidden countries list
for (uint256 i = 0; i < 4; i++) {
aadhaarOutput.forbiddenCountriesListPacked[i] = vcAndDiscloseProof.pubSignals[
indices.forbiddenCountriesListPackedIndex + i
];
}
return abi.encode(aadhaarOutput);
}
@@ -131,12 +138,19 @@ library OutputFormatterLib {
kycOutput.userIdentifier = userIdentifier;
kycOutput.nullifier = vcAndDiscloseProof.pubSignals[indices.nullifierIndex];
uint256[11] memory revealedDataPacked;
for (uint256 i = 0; i < 11; i++) {
uint256[10] memory revealedDataPacked;
for (uint256 i = 0; i < 10; i++) {
revealedDataPacked[i] = vcAndDiscloseProof.pubSignals[indices.revealedDataPackedIndex + i];
}
kycOutput.revealedDataPacked = Formatter.fieldElementsToBytesKyc(revealedDataPacked);
// Extract forbidden countries list
for (uint256 i = 0; i < 4; i++) {
kycOutput.forbiddenCountriesListPacked[i] = vcAndDiscloseProof.pubSignals[
indices.forbiddenCountriesListPackedIndex + i
];
}
return abi.encode(kycOutput);
}
}

View File

@@ -166,6 +166,63 @@ describe("Self Verification Flow V2 - KYC", () => {
expect(actualUserData).to.equal(expectedUserData);
});
it("should complete full KYC verification flow with non-empty forbidden countries", async () => {
const destChainId = 31337;
const user1Address = await deployedActors.user1.getAddress();
const userData = "test-user-data-for-verification";
const fcListStrings = ["IRN", "PRK", "RUS"];
// Reuse the same tree so the merkle root matches the on-chain registry
const fcTestInputs = generateKycDiscloseInputFromDummy(
false,
nameAndDob_smt,
nameAndYob_smt,
tree,
false,
scopeAsBigInt.toString(),
userIdentifierHash.toString(),
["GENDER", "FULL_NAME", "DOB", "ID_NUMBER", "ISSUANCE_DATE", "EXPIRY_DATE", "COUNTRY", "GENDER", "ADDRESS"],
fcListStrings,
0,
false,
KYC_ATTESTATION_ID,
);
const fcPacked = getPackedForbiddenCountries(fcListStrings as any);
const fcConfig = {
olderThanEnabled: true,
olderThan: "00",
forbiddenCountriesEnabled: true,
forbiddenCountriesListPacked: fcPacked as [BigNumberish, BigNumberish, BigNumberish, BigNumberish],
ofacEnabled: [false, false, false] as [boolean, boolean, boolean],
};
await deployedActors.testSelfVerificationRoot.setVerificationConfig(fcConfig);
const fcProof = await generateVcAndDiscloseKycProof(fcTestInputs);
const destChainIdHex = ethers.zeroPadValue(ethers.toBeHex(destChainId), 32);
const userContextData = ethers.solidityPacked(
["bytes32", "bytes32", "bytes"],
[destChainIdHex, ethers.zeroPadValue(user1Address, 32), ethers.toUtf8Bytes(userData)],
);
const attestationId = ethers.zeroPadValue(ethers.toBeHex(BigInt(KYC_ATTESTATION_ID)), 32);
const encodedProof = ethers.AbiCoder.defaultAbiCoder().encode(
["tuple(uint256[2] a, uint256[2][2] b, uint256[2] c, uint256[] pubSignals)"],
[[fcProof.a, fcProof.b, fcProof.c, fcProof.pubSignals]],
);
const proofData = ethers.solidityPacked(["bytes32", "bytes"], [attestationId, encodedProof]);
await deployedActors.testSelfVerificationRoot.resetTestState();
const tx = await deployedActors.testSelfVerificationRoot.verifySelfProof(proofData, userContextData);
await expect(tx).to.emit(deployedActors.testSelfVerificationRoot, "VerificationCompleted");
expect(await deployedActors.testSelfVerificationRoot.verificationSuccessful()).to.be.true;
});
it("should not verify if the config is not set", async () => {
const destChainId = ethers.zeroPadValue(ethers.toBeHex(31337), 32);
const user1Address = await deployedActors.user1.getAddress();

View File

@@ -11,7 +11,6 @@ import SelfSdkSwift
// exported from the ComposeApp (KMP) framework.
extension SecureStorageProviderImpl: SecureStorageProvider {}
extension CryptoProviderImpl: CryptoProvider {}
@main
struct iOSApp: App {
@@ -19,7 +18,6 @@ struct iOSApp: App {
// Register all Swift provider implementations with the KMP SdkProviderRegistry
let registry = SdkProviderRegistry.shared
registry.secureStorage = SecureStorageProviderImpl()
registry.crypto = CryptoProviderImpl()
}
var body: some Scene {

View File

@@ -9,7 +9,6 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import xyz.self.sdk.api.SelfSdk
import xyz.self.sdk.providers.AndroidKeystoreCryptoProvider
import xyz.self.sdk.providers.EncryptedSharedPreferencesProvider
import xyz.self.sdk.providers.SdkProviderRegistry
@@ -21,10 +20,6 @@ class MainActivity : ComponentActivity() {
if (SdkProviderRegistry.secureStorage == null) {
SdkProviderRegistry.secureStorage = EncryptedSharedPreferencesProvider(this)
}
if (SdkProviderRegistry.crypto == null) {
SdkProviderRegistry.crypto = AndroidKeystoreCryptoProvider()
}
SelfSdk.bindActivity(this)
enableEdgeToEdge()
setContent {

View File

@@ -52,7 +52,6 @@ data class SmokeResult(
@Composable
fun DomainSmokeScreen(navController: NavController) {
var storageResult by remember { mutableStateOf(SmokeResult()) }
var cryptoResult by remember { mutableStateOf(SmokeResult()) }
var running by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -69,7 +68,7 @@ fun DomainSmokeScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Tests secureStorage, crypto, and lifecycle providers directly.",
text = "Tests secureStorage and lifecycle providers directly.",
style = MaterialTheme.typography.bodyMedium,
)
@@ -77,10 +76,8 @@ fun DomainSmokeScreen(navController: NavController) {
onClick = {
running = true
storageResult = SmokeResult()
cryptoResult = SmokeResult()
scope.launch {
storageResult = runStorageSmoke()
cryptoResult = runCryptoSmoke()
running = false
}
},
@@ -91,7 +88,6 @@ fun DomainSmokeScreen(navController: NavController) {
}
SmokeCard("secureStorage", storageResult)
SmokeCard("crypto", cryptoResult)
SmokeCard("lifecycle", SmokeResult(CheckStatus.PASS, "ready/setResult validated via SDK launch flow"))
Text(
@@ -186,34 +182,3 @@ private fun runStorageSmoke(): SmokeResult {
SmokeResult(CheckStatus.FAIL, "Exception: ${e.message}")
}
}
private fun runCryptoSmoke(): SmokeResult {
val provider =
SdkProviderRegistry.crypto
?: return SmokeResult(CheckStatus.FAIL, "Provider not configured")
return try {
val keyRef = "smoke_test_key_${kotlin.random.Random.nextInt(100000)}"
provider.generateKey(keyRef)
val publicKey = provider.getPublicKey(keyRef)
if (publicKey.isNullOrEmpty()) {
return SmokeResult(CheckStatus.FAIL, "getPublicKey returned null/empty")
}
val testData = "dGVzdA==" // base64("test")
val signature = provider.sign(keyRef, testData)
if (signature.isNullOrEmpty()) {
return SmokeResult(CheckStatus.FAIL, "sign returned null/empty")
}
provider.deleteKey(keyRef)
SmokeResult(
CheckStatus.PASS,
"generateKey/getPublicKey/sign/deleteKey OK\npubKey=${publicKey.take(20)}...\nsig=${signature.take(20)}...",
)
} catch (e: Exception) {
SmokeResult(CheckStatus.FAIL, "Exception: ${e.message}")
}
}

View File

@@ -8,7 +8,6 @@ import SelfSdkSwift
// MARK: - Protocol conformance for required providers
extension SecureStorageProviderImpl: SecureStorageProvider {}
extension CryptoProviderImpl: CryptoProvider {}
extension WebViewProviderImpl: WebViewProvider {}
@main
@@ -16,7 +15,6 @@ struct iOSApp: App {
init() {
// Register only the 3-domain required providers
SdkProviderRegistry.shared.secureStorage = SecureStorageProviderImpl()
SdkProviderRegistry.shared.crypto = CryptoProviderImpl()
IosProviderRegistry.shared.webView = WebViewProviderImpl()
}

View File

@@ -1,8 +1,9 @@
// swift-tools-version:5.9
// 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.
// swift-tools-version:5.9
import PackageDescription
let package = Package(

View File

@@ -121,12 +121,8 @@ actual class SelfSdk private constructor(
// Create intent for SelfVerificationActivity
val intent =
Intent(activity, SelfVerificationActivity::class.java).apply {
putExtra(SelfVerificationActivity.EXTRA_DEBUG_MODE, config.debug)
putExtra(SelfVerificationActivity.EXTRA_VERIFICATION_REQUEST, serializeRequest(request))
putExtra(SelfVerificationActivity.EXTRA_CONFIG, serializeConfig(config))
if (config.devServerUrl != null) {
putExtra(SelfVerificationActivity.EXTRA_DEV_SERVER_URL, config.devServerUrl)
}
}
// Launch the verification activity

View File

@@ -12,7 +12,7 @@ import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
class AndroidKeystoreCryptoProvider : CryptoProvider {
internal class AndroidKeystoreCryptoProvider : CryptoProvider {
private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
override fun generateKey(keyRef: String) {

View File

@@ -23,13 +23,15 @@ import androidx.core.content.ContextCompat
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import xyz.self.sdk.api.SdkConstants
import xyz.self.sdk.bridge.MessageRouter
class AndroidWebViewHost(
private val context: Context,
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
private val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
private val isDebuggable: Boolean = false,
private val remoteWebAppBaseUrl: String = SdkConstants.DEFAULT_REMOTE_WEB_APP_BASE_URL,
private val devServerUrl: String? = null,
) {
private lateinit var webView: WebView
@@ -38,6 +40,7 @@ class AndroidWebViewHost(
@SuppressLint("SetJavaScriptEnabled")
fun createWebView(queryParams: String = ""): WebView {
val effectiveDebug = isDebugMode && isDebuggable
webView =
WebView(context).apply {
settings.apply {
@@ -47,7 +50,7 @@ class AndroidWebViewHost(
allowContentAccess = false
mediaPlaybackRequiresUserGesture = false
if (isDebugMode) {
if (effectiveDebug) {
WebView.setWebContentsDebuggingEnabled(true)
}
}
@@ -57,7 +60,7 @@ class AndroidWebViewHost(
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean = !isAllowedNavigationUrl(request?.url?.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl)
): Boolean = !isAllowedNavigationUrl(request?.url?.toString(), effectiveDebug, remoteWebAppBaseUrl, devServerUrl)
override fun onReceivedSslError(
view: WebView?,
@@ -77,7 +80,7 @@ class AndroidWebViewHost(
request.deny()
return
}
if (!isTrustedPermissionOrigin(origin.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl)) {
if (!isTrustedPermissionOrigin(origin.toString(), effectiveDebug, remoteWebAppBaseUrl, devServerUrl)) {
request.deny()
return
}
@@ -148,9 +151,9 @@ class AndroidWebViewHost(
}
}
installBridge(webView = this)
installBridge(webView = this, effectiveDebug = effectiveDebug)
loadUrl(initialContentUrl(queryParams, isDebugMode, remoteWebAppBaseUrl, devServerUrl))
loadUrl(initialContentUrl(queryParams, effectiveDebug, remoteWebAppBaseUrl, devServerUrl))
}
return webView
}
@@ -165,7 +168,10 @@ class AndroidWebViewHost(
webView.destroy()
}
private fun installBridge(webView: WebView) {
private fun installBridge(
webView: WebView,
effectiveDebug: Boolean,
) {
check(WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
"WEB_MESSAGE_LISTENER not supported — native bridge unavailable on this device"
}
@@ -173,7 +179,7 @@ class AndroidWebViewHost(
WebViewCompat.addWebMessageListener(
webView,
"SelfNativeAndroid",
buildAllowedOriginRules(isDebugMode, remoteWebAppBaseUrl, devServerUrl),
buildAllowedOriginRules(effectiveDebug, remoteWebAppBaseUrl, devServerUrl),
) { _, message: WebMessageCompat, sourceOrigin, isMainFrame, _ ->
if (!isMainFrame) {
return@addWebMessageListener
@@ -182,7 +188,7 @@ class AndroidWebViewHost(
val rawJson = message.data ?: return@addWebMessageListener
router.onMessageReceived(
rawJson = rawJson,
isTrustedSource = isTrustedBridgeOrigin(sourceOrigin.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl),
isTrustedSource = isTrustedBridgeOrigin(sourceOrigin.toString(), effectiveDebug, remoteWebAppBaseUrl, devServerUrl),
)
}
}
@@ -190,21 +196,17 @@ class AndroidWebViewHost(
companion object {
const val FILE_CHOOSER_REQUEST_CODE = 1001
const val CAMERA_PERMISSION_REQUEST_CODE = 1002
private const val BUNDLED_TOUR_PATH = "/tunnel/tour/1"
private const val DEBUG_HOST = "127.0.0.1"
private const val DEBUG_PORT = 5173
private const val DIDIT_HOST = "verify.didit.me"
internal fun initialContentUrl(
queryParams: String,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
remoteWebAppBaseUrl: String = SdkConstants.DEFAULT_REMOTE_WEB_APP_BASE_URL,
devServerUrl: String? = null,
): String {
val baseUrl =
when {
isDebugMode && devServerUrl != null -> devServerUrl.trimEnd('/')
isDebugMode -> "http://$DEBUG_HOST:$DEBUG_PORT"
isDebugMode -> "http://${SdkConstants.LOOPBACK_HOST}:${SdkConstants.DEBUG_PORT}"
else -> {
require(remoteWebAppBaseUrl.startsWith("https://")) {
"remoteWebAppBaseUrl must use HTTPS in release builds"
@@ -213,7 +215,7 @@ class AndroidWebViewHost(
}
}
return buildString {
append(baseUrl).append(BUNDLED_TOUR_PATH)
append(baseUrl).append(SdkConstants.BUNDLED_TOUR_PATH)
if (queryParams.isNotEmpty()) {
append("?").append(queryParams)
}
@@ -267,12 +269,12 @@ class AndroidWebViewHost(
private fun isDiditUrl(rawUrl: String?): Boolean {
val port = uriPort(rawUrl)
return uriScheme(rawUrl) == "https" &&
uriHost(rawUrl) == DIDIT_HOST &&
uriHost(rawUrl) == SdkConstants.DIDIT_HOST &&
(port == null || port == 443)
}
private fun isDebugLocalUrl(rawUrl: String?): Boolean =
uriScheme(rawUrl) == "http" && uriHost(rawUrl) == DEBUG_HOST && uriPort(rawUrl) == DEBUG_PORT
uriScheme(rawUrl) == "http" && uriHost(rawUrl) == SdkConstants.LOOPBACK_HOST && uriPort(rawUrl) == SdkConstants.DEBUG_PORT
private fun isDevServerUrl(
rawUrl: String?,
@@ -304,7 +306,7 @@ class AndroidWebViewHost(
}
}
if (isDebugMode) {
add("http://$DEBUG_HOST:$DEBUG_PORT")
add("http://${SdkConstants.LOOPBACK_HOST}:${SdkConstants.DEBUG_PORT}")
devServerUrl?.let { parseUri(it) }?.let { dev ->
val host = dev.host ?: dev.authority
val port = resolvedPort(dev)

View File

@@ -5,8 +5,8 @@
package xyz.self.sdk.webview
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.WebChromeClient
@@ -15,6 +15,10 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import xyz.self.sdk.api.QueryParamsBuilder
import xyz.self.sdk.api.SelfSdkConfig
import xyz.self.sdk.api.VerificationRequest
import xyz.self.sdk.api.verificationResultJson
import xyz.self.sdk.bridge.MessageRouter
import xyz.self.sdk.handlers.CryptoBridgeHandler
import xyz.self.sdk.handlers.LifecycleBridgeHandler
@@ -35,7 +39,37 @@ class SelfVerificationActivity : AppCompatActivity() {
}
private fun initVerificationFlow() {
val isDebugMode = intent.getBooleanExtra(EXTRA_DEBUG_MODE, false)
val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}"
val requestJson = intent.getStringExtra(EXTRA_VERIFICATION_REQUEST)
val config =
try {
verificationResultJson.decodeFromString(SelfSdkConfig.serializer(), configJson)
} catch (_: Exception) {
null
}
val request =
if (requestJson != null) {
try {
verificationResultJson.decodeFromString(VerificationRequest.serializer(), requestJson)
} catch (_: Exception) {
null
}
} else {
null
}
if (config == null || request == null) {
setResult(
RESULT_CODE_ERROR,
Intent().apply {
putExtra(EXTRA_ERROR_CODE, "INVALID_BOOTSTRAP")
putExtra(EXTRA_ERROR_MESSAGE, "Invalid verification request/config payload")
},
)
finish()
return
}
// Register default providers if consumer hasn't set custom ones
if (SdkProviderRegistry.secureStorage == null) {
@@ -56,31 +90,11 @@ class SelfVerificationActivity : AppCompatActivity() {
registerHandlers()
// Build query params from VerificationRequest JSON
val queryParams = buildQueryParams()
if (queryParams == null) {
setResult(
RESULT_CODE_ERROR,
Intent().apply {
putExtra(EXTRA_ERROR_CODE, "INVALID_BOOTSTRAP")
putExtra(EXTRA_ERROR_MESSAGE, "Invalid verification request/config payload")
},
)
finish()
return
}
val queryParams = QueryParamsBuilder.build(config, request)
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}"
val remoteWebAppBaseUrl =
try {
org.json.JSONObject(configJson).optString("remoteWebAppBaseUrl", "https://self-app-alpha.vercel.app")
} catch (_: Exception) {
"https://self-app-alpha.vercel.app"
}
val devServerUrl = intent.getStringExtra(EXTRA_DEV_SERVER_URL)
webViewHost = AndroidWebViewHost(this, router, isDebugMode, remoteWebAppBaseUrl, devServerUrl)
val webView = webViewHost.createWebView(queryParams)
webViewHost = AndroidWebViewHost(this, router, config.debug, isDebuggable, config.remoteWebAppBaseUrl, config.devServerUrl)
val webView = webViewHost.createWebView(queryParams ?: "")
val wrapper =
FrameLayout(this).apply {
addView(
@@ -115,63 +129,6 @@ class SelfVerificationActivity : AppCompatActivity() {
router.register(LifecycleBridgeHandler(this))
}
private fun buildQueryParams(): String? {
val requestJson = intent.getStringExtra(EXTRA_VERIFICATION_REQUEST) ?: return null
val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}"
return try {
val json = org.json.JSONObject(requestJson)
val config = org.json.JSONObject(configJson)
buildString {
var first = true
fun append(
key: String,
value: String?,
) {
if (value.isNullOrEmpty()) return
if (!first) append("&")
append("$key=${Uri.encode(value)}")
first = false
}
// Config params (always present)
val endpoint = config.optString("endpoint", "https://api.self.xyz")
append("endpoint", endpoint)
val appEndpoint = config.optString("appEndpoint", null)
append("appEndpoint", if (appEndpoint.isNullOrEmpty()) endpoint else appEndpoint)
append("environment", config.optString("environment", "prod"))
append("version", config.optInt("version", 1).toString())
// Optional config params
append("appName", config.optString("appName", null))
append("endpointType", config.optString("endpointType", null))
val chainID = config.optInt("chainID", 0)
if (chainID != 0) append("chainID", chainID.toString())
// Request params
append("verificationId", json.optString("verificationId", null))
append("userId", json.optString("userId", null))
append("scope", json.optString("scope", null))
val disclosures = json.optJSONArray("disclosures")
if (disclosures != null && disclosures.length() > 0) {
val items = (0 until disclosures.length()).map { disclosures.getString(it) }
append("disclosures", items.joinToString(","))
}
append("resultType", json.optString("resultType", null))
val excludedCountries = json.optJSONArray("excludedCountries")
if (excludedCountries != null && excludedCountries.length() > 0) {
val items = (0 until excludedCountries.length()).map { excludedCountries.getString(it) }
append("excludedCountries", items.joinToString(","))
}
append("userIdType", json.optString("userIdType", null))
append("userDefinedData", json.optString("userDefinedData", null))
append("selfDefinedData", json.optString("selfDefinedData", null))
}
} catch (_: Exception) {
null
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
@@ -218,10 +175,8 @@ class SelfVerificationActivity : AppCompatActivity() {
}
companion object {
const val EXTRA_DEBUG_MODE = "xyz.self.sdk.DEBUG_MODE"
const val EXTRA_VERIFICATION_REQUEST = "xyz.self.sdk.VERIFICATION_REQUEST"
const val EXTRA_CONFIG = "xyz.self.sdk.CONFIG"
const val EXTRA_DEV_SERVER_URL = "xyz.self.sdk.DEV_SERVER_URL"
const val RESULT_CODE_SUCCESS = RESULT_OK
const val RESULT_CODE_ERROR = RESULT_FIRST_USER

View File

@@ -118,4 +118,34 @@ class AndroidWebViewHostSecurityTest {
),
)
}
@Test
fun `isDebugMode true but isDebuggable false loads remote URL`() {
// This is the key security test: a release APK with config.debug=true
// must NOT load localhost — it should load the remote URL.
// The effectiveDebug = isDebugMode && isDebuggable guard ensures this.
// When isDebugMode=true but isDebuggable=false, effectiveDebug=false,
// so initialContentUrl with isDebugMode=false loads the remote URL.
assertEquals(
"https://self-app-alpha.vercel.app/tunnel/tour/1",
AndroidWebViewHost.initialContentUrl(
queryParams = "",
isDebugMode = false, // effectiveDebug after isDebugMode && !isDebuggable
remoteWebAppBaseUrl = remoteUrl,
),
)
}
@Test
fun `isDebugMode true and isDebuggable true loads localhost`() {
// Both flags true means we're in a genuine debug build — localhost is allowed.
assertEquals(
"http://127.0.0.1:5173/tunnel/tour/1",
AndroidWebViewHost.initialContentUrl(
queryParams = "",
isDebugMode = true, // effectiveDebug after isDebugMode && isDebuggable
remoteWebAppBaseUrl = remoteUrl,
),
)
}
}

View File

@@ -0,0 +1,51 @@
// 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.api
internal object QueryParamsBuilder {
fun build(
config: SelfSdkConfig,
request: VerificationRequest,
): String? {
val parts = mutableListOf<String>()
// Config params (always present)
parts.add("endpoint=${urlEncode(config.endpoint)}")
parts.add("appEndpoint=${urlEncode(config.appEndpoint ?: config.endpoint)}")
parts.add("environment=${urlEncode(config.environment.queryValue)}")
parts.add("version=${config.version}")
// Optional config params
config.appName?.let { parts.add("appName=${urlEncode(it)}") }
config.endpointType?.let { parts.add("endpointType=${urlEncode(it)}") }
config.chainID?.let { parts.add("chainID=$it") }
// Request params
request.verificationId?.let { parts.add("verificationId=${urlEncode(it)}") }
request.userId?.let { parts.add("userId=${urlEncode(it)}") }
request.scope?.let { parts.add("scope=${urlEncode(it)}") }
if (request.disclosures.isNotEmpty()) {
parts.add("disclosures=${urlEncode(request.disclosures.joinToString(","))}")
}
request.resultType?.let { parts.add("resultType=${urlEncode(it)}") }
if (request.excludedCountries.isNotEmpty()) {
parts.add("excludedCountries=${urlEncode(request.excludedCountries.joinToString(","))}")
}
request.userIdType?.let { parts.add("userIdType=${urlEncode(it)}") }
request.userDefinedData?.let { parts.add("userDefinedData=${urlEncode(it)}") }
request.selfDefinedData?.let { parts.add("selfDefinedData=${urlEncode(it)}") }
return parts.joinToString("&").ifEmpty { null }
}
}
internal fun urlEncode(value: String): String =
value
.replace("%", "%25")
.replace("&", "%26")
.replace("=", "%3D")
.replace("+", "%2B")
.replace(" ", "%20")
.replace("#", "%23")

View File

@@ -0,0 +1,14 @@
// 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.api
object SdkConstants {
const val LOOPBACK_HOST = "127.0.0.1"
const val DEBUG_PORT = 5173
const val DIDIT_HOST = "verify.didit.me"
const val BUNDLED_TOUR_PATH = "/tunnel/tour/1"
const val DEFAULT_ENDPOINT = "https://api.self.xyz"
const val DEFAULT_REMOTE_WEB_APP_BASE_URL = "https://self-app-alpha.vercel.app"
}

View File

@@ -26,7 +26,7 @@ enum class SelfEnvironment {
@Serializable
data class SelfSdkConfig(
val endpoint: String = "https://api.self.xyz",
val endpoint: String = SdkConstants.DEFAULT_ENDPOINT,
val environment: SelfEnvironment = SelfEnvironment.PROD,
val debug: Boolean = false,
val version: Int = 1,
@@ -34,6 +34,6 @@ data class SelfSdkConfig(
val appEndpoint: String? = null,
val endpointType: String? = null,
val chainID: Int? = null,
val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
val remoteWebAppBaseUrl: String = SdkConstants.DEFAULT_REMOTE_WEB_APP_BASE_URL,
val devServerUrl: String? = null,
)

View File

@@ -4,7 +4,7 @@
package xyz.self.sdk.providers
interface CryptoProvider {
internal interface CryptoProvider {
fun generateKey(keyRef: String)
fun getPublicKey(keyRef: String): String?

View File

@@ -6,14 +6,13 @@ package xyz.self.sdk.providers
object SdkProviderRegistry {
var secureStorage: SecureStorageProvider? = null
var crypto: CryptoProvider? = null
internal var crypto: CryptoProvider? = null
/**
* Returns true if the required 3-domain providers are configured.
* Only secureStorage and crypto are required — lifecycle is handler-only
* with no consumer-provided provider.
* Returns true if the required providers are configured.
* Only secureStorage is required — crypto is internal and lifecycle is handler-only.
*/
fun isConfigured(): Boolean = secureStorage != null && crypto != null
fun isConfigured(): Boolean = secureStorage != null
fun reset() {
secureStorage = null

View File

@@ -0,0 +1,131 @@
// 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.api
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class QueryParamsBuilderTest {
private val defaultConfig = SelfSdkConfig()
private val minimalRequest = VerificationRequest()
@Test
fun `builds params with default config and minimal request`() {
val result = QueryParamsBuilder.build(defaultConfig, minimalRequest)
assertNotNull(result)
assertTrue(result.contains("endpoint=https%3A%2F%2Fapi.self.xyz") || result.contains("endpoint=https://api.self.xyz"))
assertTrue(result.contains("environment=prod"))
assertTrue(result.contains("version=1"))
}
@Test
fun `includes all config params`() {
val config =
SelfSdkConfig(
endpoint = "https://custom.api.xyz",
environment = SelfEnvironment.STG,
version = 2,
appName = "TestApp",
appEndpoint = "https://app.endpoint.xyz",
endpointType = "custom",
chainID = 42,
)
val result = QueryParamsBuilder.build(config, minimalRequest)
assertNotNull(result)
assertTrue(result.contains("appName=TestApp"))
assertTrue(result.contains("endpointType=custom"))
assertTrue(result.contains("chainID=42"))
assertTrue(result.contains("environment=stg"))
assertTrue(result.contains("version=2"))
}
@Test
fun `appEndpoint falls back to endpoint when null`() {
val config = SelfSdkConfig(endpoint = "https://api.self.xyz", appEndpoint = null)
val result = QueryParamsBuilder.build(config, minimalRequest)
assertNotNull(result)
val params =
result.split("&").associate {
val (k, v) = it.split("=", limit = 2)
k to v
}
assertEquals(params["endpoint"], params["appEndpoint"])
}
@Test
fun `includes all request params`() {
val request =
VerificationRequest(
userId = "user-123",
scope = "identity",
verificationId = "ver-456",
resultType = "json",
userIdType = "email",
userDefinedData = "custom-data",
selfDefinedData = "self-data",
)
val result = QueryParamsBuilder.build(defaultConfig, request)
assertNotNull(result)
assertTrue(result.contains("userId=user-123"))
assertTrue(result.contains("scope=identity"))
assertTrue(result.contains("verificationId=ver-456"))
assertTrue(result.contains("resultType=json"))
assertTrue(result.contains("userIdType=email"))
assertTrue(result.contains("userDefinedData=custom-data"))
assertTrue(result.contains("selfDefinedData=self-data"))
}
@Test
fun `disclosures are comma-joined`() {
val request = VerificationRequest(disclosures = listOf("name", "dob", "nationality"))
val result = QueryParamsBuilder.build(defaultConfig, request)
assertNotNull(result)
assertTrue(result.contains("disclosures=name%2Cdob%2Cnationality") || result.contains("disclosures=name,dob,nationality"))
}
@Test
fun `excludedCountries are comma-joined`() {
val request = VerificationRequest(excludedCountries = listOf("US", "GB"))
val result = QueryParamsBuilder.build(defaultConfig, request)
assertNotNull(result)
assertTrue(result.contains("excludedCountries=US%2CGB") || result.contains("excludedCountries=US,GB"))
}
@Test
fun `empty lists are omitted`() {
val request = VerificationRequest(disclosures = emptyList(), excludedCountries = emptyList())
val result = QueryParamsBuilder.build(defaultConfig, request)
assertNotNull(result)
assertTrue(!result.contains("disclosures"))
assertTrue(!result.contains("excludedCountries"))
}
@Test
fun `null optional fields are omitted`() {
val result = QueryParamsBuilder.build(defaultConfig, minimalRequest)
assertNotNull(result)
assertTrue(!result.contains("userId="))
assertTrue(!result.contains("appName="))
assertTrue(!result.contains("chainID="))
}
@Test
fun `urlEncode encodes special characters`() {
assertEquals("hello%20world", urlEncode("hello world"))
assertEquals("a%26b", urlEncode("a&b"))
assertEquals("a%3Db", urlEncode("a=b"))
assertEquals("a%2Bb", urlEncode("a+b"))
assertEquals("a%25b", urlEncode("a%b"))
assertEquals("a%23b", urlEncode("a#b"))
}
@Test
fun `urlEncode preserves safe characters`() {
assertEquals("hello-world_123", urlEncode("hello-world_123"))
assertEquals("abc/def", urlEncode("abc/def"))
}
}

View File

@@ -57,7 +57,7 @@ actual class SelfSdk private constructor(
check(SdkProviderRegistry.isConfigured() && IosProviderRegistry.webView != null) {
"SDK providers not configured. " +
"Call SelfSdkSwift.configure() from your iOS app before launching the SDK. " +
"Required: secureStorage, crypto, and webView providers."
"Required: secureStorage and webView providers."
}
// Store callback for later
@@ -97,7 +97,7 @@ actual class SelfSdk private constructor(
registerHandlers(router!!, lifecycleHandler)
// Build query params from config + request
val queryParams = buildQueryParams(request)
val queryParams = QueryParamsBuilder.build(config, request)
// Create WebView host and the web view
webViewHost =
@@ -155,44 +155,4 @@ actual class SelfSdk private constructor(
router.register(CryptoBridgeHandler())
router.register(lifecycleHandler)
}
private fun buildQueryParams(request: VerificationRequest): String? {
val parts = mutableListOf<String>()
// Config params (always present)
parts.add("endpoint=${encodeParam(config.endpoint)}")
parts.add("appEndpoint=${encodeParam(config.appEndpoint ?: config.endpoint)}")
parts.add("environment=${encodeParam(config.environment.queryValue)}")
parts.add("version=${config.version}")
// Optional config params
config.appName?.let { parts.add("appName=${encodeParam(it)}") }
config.endpointType?.let { parts.add("endpointType=${encodeParam(it)}") }
config.chainID?.let { parts.add("chainID=$it") }
// Request params
request.verificationId?.let { parts.add("verificationId=${encodeParam(it)}") }
request.userId?.let { parts.add("userId=${encodeParam(it)}") }
request.scope?.let { parts.add("scope=${encodeParam(it)}") }
if (request.disclosures.isNotEmpty()) {
parts.add("disclosures=${encodeParam(request.disclosures.joinToString(","))}")
}
request.resultType?.let { parts.add("resultType=${encodeParam(it)}") }
if (request.excludedCountries.isNotEmpty()) {
parts.add("excludedCountries=${encodeParam(request.excludedCountries.joinToString(","))}")
}
request.userIdType?.let { parts.add("userIdType=${encodeParam(it)}") }
request.userDefinedData?.let { parts.add("userDefinedData=${encodeParam(it)}") }
request.selfDefinedData?.let { parts.add("selfDefinedData=${encodeParam(it)}") }
return parts.joinToString("&").ifEmpty { null }
}
private fun encodeParam(value: String): String =
value
.replace("%", "%25")
.replace("&", "%26")
.replace("=", "%3D")
.replace("+", "%2B")
.replace(" ", "%20")
}

View File

@@ -7,6 +7,7 @@ package xyz.self.sdk.webview
import kotlinx.cinterop.ExperimentalForeignApi
import platform.UIKit.UIView
import platform.UIKit.UIViewController
import xyz.self.sdk.api.SdkConstants
import xyz.self.sdk.bridge.MessageRouter
import xyz.self.sdk.providers.IosProviderRegistry
@@ -14,7 +15,7 @@ import xyz.self.sdk.providers.IosProviderRegistry
class IosWebViewHost(
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
private val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
private val remoteWebAppBaseUrl: String = SdkConstants.DEFAULT_REMOTE_WEB_APP_BASE_URL,
private val devServerUrl: String? = null,
) {
fun createWebView(queryParams: String? = null): UIView {

View File

@@ -0,0 +1,15 @@
// 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.
import Foundation
/// Constants shared with the KMP SDK layer.
/// Keep in sync with `packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SdkConstants.kt`.
enum SdkConstants {
static let loopbackHost = "127.0.0.1"
static let debugPort = 5173
static let diditHost = "verify.didit.me"
static let bundledTourPath = "/tunnel/tour/1"
static let defaultRemoteWebAppBaseURL = "https://self-app-alpha.vercel.app"
}

View File

@@ -9,10 +9,10 @@ import WebKit
/// Swift implementation of WebViewProvider using WKWebView.
/// Handles message passing between the WebView and the KMP bridge.
public class WebViewProviderImpl: NSObject {
static let loopbackHost = "127.0.0.1"
static let diditHost = "verify.didit.me"
static let debugPort: UInt16 = 5173
private static let defaultRemoteBaseURL = URL(string: "https://self-app-alpha.vercel.app")!
static let loopbackHost = SdkConstants.loopbackHost
static let diditHost = SdkConstants.diditHost
static let debugPort = SdkConstants.debugPort
private static let defaultRemoteBaseURL = URL(string: SdkConstants.defaultRemoteWebAppBaseURL)!
private var webView: WKWebView?
private var viewController: UIViewController?
@@ -198,7 +198,7 @@ extension WebViewProviderImpl {
components.scheme = baseURL.scheme
components.host = baseURL.host
components.port = baseURL.port
components.path = "/tunnel/tour/1"
components.path = SdkConstants.bundledTourPath
if let queryParams, !queryParams.isEmpty {
components.percentEncodedQuery = queryParams
}
@@ -209,8 +209,8 @@ extension WebViewProviderImpl {
var components = URLComponents()
components.scheme = "http"
components.host = Self.loopbackHost
components.port = Int(Self.debugPort)
components.path = "/tunnel/tour/1"
components.port = Self.debugPort
components.path = SdkConstants.bundledTourPath
if let queryParams, !queryParams.isEmpty {
components.percentEncodedQuery = queryParams
}
@@ -223,7 +223,7 @@ extension WebViewProviderImpl {
components.scheme = remoteWebAppBaseURL.scheme
components.host = remoteWebAppBaseURL.host
if let port = remoteWebAppBaseURL.port { components.port = port }
components.path = "/tunnel/tour/1"
components.path = SdkConstants.bundledTourPath
if let queryParams, !queryParams.isEmpty {
components.percentEncodedQuery = queryParams
}
@@ -244,7 +244,7 @@ extension WebViewProviderImpl {
if let devUrl = devServerUrl, !devUrl.isEmpty, let devBase = URL(string: devUrl) {
return url.scheme == devBase.scheme && url.host == devBase.host && resolvedPort(for: url) == resolvedPort(for: devBase)
}
return url.scheme == "http" && url.host == Self.loopbackHost && url.port == Int(Self.debugPort)
return url.scheme == "http" && url.host == Self.loopbackHost && url.port == Self.debugPort
}
#endif
return url.scheme == remoteWebAppBaseURL.scheme &&
@@ -259,7 +259,7 @@ extension WebViewProviderImpl {
let expectedPort = resolvedPort(for: devBase)
return origin.protocol == devBase.scheme && origin.host == devBase.host && resolvedSecurityOriginPort(origin) == expectedPort
}
return origin.protocol == "http" && origin.host == Self.loopbackHost && origin.port == Int(Self.debugPort)
return origin.protocol == "http" && origin.host == Self.loopbackHost && origin.port == Self.debugPort
}
#endif
let expectedPort = resolvedPort(for: remoteWebAppBaseURL)

View File

@@ -18,7 +18,6 @@ import Foundation
/// SdkProviderRegistry.shared.biometric = SelfSdkSwift.biometric
/// SdkProviderRegistry.shared.secureStorage = SelfSdkSwift.secureStorage
/// SdkProviderRegistry.shared.haptic = SelfSdkSwift.haptic
/// SdkProviderRegistry.shared.crypto = SelfSdkSwift.crypto
/// SdkProviderRegistry.shared.documents = SelfSdkSwift.documents
/// SdkProviderRegistry.shared.webView = SelfSdkSwift.webView
/// SdkProviderRegistry.shared.nfc = SelfSdkSwift.nfc
@@ -30,7 +29,6 @@ public final class SelfSdkSwift {
public static let biometric = BiometricProviderImpl()
public static let secureStorage = SecureStorageProviderImpl()
public static let haptic = HapticProviderImpl()
public static let crypto = CryptoProviderImpl()
public static let documents = DocumentsProviderImpl()
public static let webView = WebViewProviderImpl()
public static let nfc = NfcProviderImpl()

View File

@@ -89,6 +89,8 @@ function findLicenseHeaderIndex(lines) {
let i = 0;
// Skip shebang if present
if (lines[i]?.startsWith('#!')) i++;
// Skip swift-tools-version (must be first line in Package.swift)
if (lines[i]?.startsWith('// swift-tools-version')) i++;
// Skip leading blank lines
while (i < lines.length && lines[i].trim() === '') i++;
@@ -187,10 +189,24 @@ function fixLicenseHeader(filePath) {
if (headerInfo.index === -1) {
// No header exists - add the canonical header
// Preserve shebang and swift-tools-version prefixes
let insertIndex = 0;
if (lines[insertIndex]?.startsWith('#!')) {
insertIndex += 1;
}
if (lines[insertIndex]?.startsWith('// swift-tools-version')) {
insertIndex += 1;
// Ensure blank line between tools-version and license header
if (lines[insertIndex]?.trim() !== '') {
lines.splice(insertIndex, 0, '');
}
insertIndex += 1;
}
const newLines = [
...lines.slice(0, insertIndex),
...CANONICAL_HEADER_LINES,
'', // Add newline after header
...lines,
...lines.slice(insertIndex),
];
const fixedContent = newLines.join('\n');
writeFileSync(filePath, fixedContent, 'utf8');