Add remote webview integrity checks (#1907)

* Add remote webview integrity checks

* fixes

* feedback

* update tests; fix pipelines

* fix ci

* feat(webview): add subresource integrity (SRI) to build output

The SHA-256 remote integrity check only covers the entry HTML document.
Sub-resources (JS, CSS) loaded by that HTML were fetched without
integrity verification, allowing a compromised CDN to swap bundles.

Add a custom Vite plugin that injects SRI sha384 hashes into all
script and link tags in the built index.html. The browser natively
enforces these hashes, blocking any tampered sub-resources.

Includes tests verifying integrity attributes are present and that
hashes match the actual file contents on disk.

---------

Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
This commit is contained in:
Justin Hernandez
2026-04-03 20:55:31 -07:00
committed by GitHub
parent 5268ccb767
commit 9b8e081435
19 changed files with 1112 additions and 46 deletions

View File

@@ -35,6 +35,10 @@ object SelfSdk {
config.chainID?.let { putExtra(SelfVerificationActivity.EXTRA_CHAIN_ID, it) }
config.userDefinedData?.let { putExtra(SelfVerificationActivity.EXTRA_USER_DEFINED_DATA, it) }
config.selfDefinedData?.let { putExtra(SelfVerificationActivity.EXTRA_SELF_DEFINED_DATA, it) }
config.remoteWebAppBaseUrl?.let { putExtra(SelfVerificationActivity.EXTRA_REMOTE_WEB_APP_BASE_URL, it) }
config.remoteWebAppIntegritySha256?.let {
putExtra(SelfVerificationActivity.EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256, it)
}
}
activity.startActivityForResult(intent, requestCode)
}

View File

@@ -19,6 +19,8 @@ data class SelfSdkConfig(
val chainID: Int? = null,
val userDefinedData: String? = null,
val selfDefinedData: String? = null,
val remoteWebAppBaseUrl: String? = null,
val remoteWebAppIntegritySha256: String? = null,
)
class SelfSdkException(

View File

@@ -9,6 +9,7 @@ import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.net.http.SslError
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
@@ -22,39 +23,54 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.webkit.WebViewAssetLoader
import xyz.self.sdk.bridge.MessageRouter
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
class AndroidWebViewHost(
private val context: Context,
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
private val remoteWebAppBaseUrl: String? = null,
private val remoteWebAppIntegritySha256: String? = null,
) {
private lateinit var webView: WebView
@Volatile
private var isDestroyed = false
var fileUploadCallback: ValueCallback<Array<Uri>>? = null
var pendingPermissionRequest: PermissionRequest? = null
@SuppressLint("SetJavaScriptEnabled")
fun createWebView(queryParams: String): WebView {
isDestroyed = false
val selfWalletHandler =
WebViewAssetLoader.PathHandler { path ->
WebViewAssetLoader.PathHandler { rawPath ->
try {
val assetPath = "self-wallet/$path"
val normalizedPath = rawPath.removePrefix("/")
val assetPath =
if (normalizedPath.isEmpty() || !normalizedPath.contains('.')) {
"self-wallet/index.html"
} else {
"self-wallet/$normalizedPath"
}
val inputStream = context.assets.open(assetPath)
val mimeType =
when {
path.endsWith(".js") -> "application/javascript"
path.endsWith(".css") -> "text/css"
path.endsWith(".html") -> "text/html"
path.endsWith(".json") -> "application/json"
path.endsWith(".woff2") -> "font/woff2"
path.endsWith(".woff") -> "font/woff"
path.endsWith(".otf") -> "font/otf"
path.endsWith(".ttf") -> "font/ttf"
path.endsWith(".png") -> "image/png"
path.endsWith(".svg") -> "image/svg+xml"
assetPath.endsWith(".js") -> "application/javascript"
assetPath.endsWith(".css") -> "text/css"
assetPath.endsWith(".html") -> "text/html"
assetPath.endsWith(".json") -> "application/json"
assetPath.endsWith(".woff2") -> "font/woff2"
assetPath.endsWith(".woff") -> "font/woff"
assetPath.endsWith(".otf") -> "font/otf"
assetPath.endsWith(".ttf") -> "font/ttf"
assetPath.endsWith(".png") -> "image/png"
assetPath.endsWith(".svg") -> "image/svg+xml"
else -> "application/octet-stream"
}
WebResourceResponse(mimeType, "UTF-8", inputStream)
} catch (e: Exception) {
} catch (_: Exception) {
null
}
}
@@ -87,7 +103,7 @@ class AndroidWebViewHost(
): WebResourceResponse? {
request ?: return null
val url = request.url
if (url.host != "appassets.androidplatform.net") return null
if (url.host != BUNDLED_HOST) return null
return assetLoader.shouldInterceptRequest(url)
}
@@ -95,10 +111,10 @@ class AndroidWebViewHost(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return true
if (url.startsWith("https://appassets.androidplatform.net/")) return false
if (url.startsWith("https://self-app-alpha.vercel.app/")) return false
if (isDebugMode && url.startsWith("http://127.0.0.1:5173")) return false
val url = request?.url ?: return true
if (isBundledOrigin(url)) return false
if (isDebugMode && isDebugOrigin(url)) return false
if (isAllowedRemoteOrigin(url.toString())) return false
return true
}
@@ -116,13 +132,13 @@ class AndroidWebViewHost(
override fun onPermissionRequest(request: PermissionRequest?) {
request ?: return
// Only allow permissions from trusted origins
val origin = request.origin?.toString() ?: ""
val originStr = request.origin?.toString() ?: ""
val originUri = Uri.parse(originStr)
val isTrusted =
origin.startsWith("https://appassets.androidplatform.net") ||
origin.startsWith("https://self-app-alpha.vercel.app") ||
origin.startsWith("https://verify.didit.me") ||
(isDebugMode && origin.startsWith("http://127.0.0.1"))
isBundledOrigin(originUri) ||
(isDebugMode && isDebugOrigin(originUri)) ||
isMatchingOrigin(originUri, "https", "verify.didit.me", 443) ||
isAllowedRemoteOrigin(originStr)
if (!isTrusted) {
request.deny()
return
@@ -134,7 +150,6 @@ class AndroidWebViewHost(
return
}
// Collect required Android permissions
val neededPermissions = mutableListOf<String>()
if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
neededPermissions.add(Manifest.permission.CAMERA)
@@ -143,7 +158,6 @@ class AndroidWebViewHost(
neededPermissions.add(Manifest.permission.RECORD_AUDIO)
}
// Check if any runtime permissions are missing
val missingPermissions =
neededPermissions.filter {
ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED
@@ -178,7 +192,7 @@ class AndroidWebViewHost(
}
try {
activity.startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE)
} catch (e: Exception) {
} catch (_: Exception) {
fileUploadCallback = null
return false
}
@@ -189,9 +203,10 @@ class AndroidWebViewHost(
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
if (isDebugMode) {
loadUrl("http://127.0.0.1:5173/tunnel/tour/1?$queryParams")
loadUrl(buildDebugUrl(queryParams))
} else {
loadUrl("https://self-app-alpha.vercel.app/tunnel/tour/1?$queryParams")
loadUrl(buildBundledUrl(queryParams))
maybeLoadVerifiedRemoteContent(queryParams)
}
}
return webView
@@ -199,7 +214,7 @@ class AndroidWebViewHost(
fun evaluateJs(js: String) {
if (!::webView.isInitialized) {
android.util.Log.e("WebViewHost", "evaluateJs called but webView not initialized")
Log.e("WebViewHost", "evaluateJs called but webView not initialized")
return
}
webView.evaluateJavascript(js, null)
@@ -207,9 +222,102 @@ class AndroidWebViewHost(
fun destroy() {
if (!::webView.isInitialized) return
isDestroyed = true
webView.destroy()
}
private fun buildBundledUrl(queryParams: String): String = buildEntryUrl(BUNDLED_ORIGIN, queryParams)
private fun buildDebugUrl(queryParams: String): String = buildEntryUrl(DEBUG_ORIGIN, queryParams)
private fun buildRemoteUrl(queryParams: String): String? = RemoteNavigationPolicy.buildRemoteEntryUrl(remoteWebAppBaseUrl, queryParams)
private fun buildEntryUrl(
baseUrl: String,
queryParams: String,
): String {
val separator = if (queryParams.isEmpty()) "" else "?$queryParams"
return "$baseUrl/tunnel/tour/1$separator"
}
private fun maybeLoadVerifiedRemoteContent(queryParams: String) {
val remoteUrl = buildRemoteUrl(queryParams) ?: return
val expectedSha256 = remoteWebAppIntegritySha256?.takeIf { it.isNotBlank() } ?: return
Thread {
val verifiedHtml = fetchAndVerifyRemoteEntry(remoteUrl, expectedSha256)
if (verifiedHtml == null || !::webView.isInitialized || isDestroyed) {
return@Thread
}
webView.post {
if (::webView.isInitialized && !isDestroyed) {
webView.loadDataWithBaseURL(
remoteUrl,
verifiedHtml,
"text/html",
"UTF-8",
null,
)
}
}
}.start()
}
private fun fetchAndVerifyRemoteEntry(
remoteUrl: String,
expectedSha256: String,
): String? =
try {
val connection = URL(remoteUrl).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.instanceFollowRedirects = false
connection.connectTimeout = 5_000
connection.readTimeout = 5_000
connection.connect()
if (connection.responseCode !in 200..299) {
Log.w("WebViewHost", "Remote web app integrity check failed with HTTP ${connection.responseCode}")
null
} else if (!RemoteContentIntegrity.isAcceptableContentType(connection.contentType)) {
Log.w("WebViewHost", "Remote web app integrity check failed due to unexpected content type ${connection.contentType}")
null
} else {
val body =
connection.inputStream.use { stream ->
val buffer = java.io.ByteArrayOutputStream()
val chunk = ByteArray(8192)
var totalRead = 0
var bytesRead: Int
while (stream.read(chunk).also { bytesRead = it } != -1) {
totalRead += bytesRead
if (totalRead > MAX_REMOTE_ENTRY_BYTES) {
throw IllegalStateException("Remote entry response exceeded ${MAX_REMOTE_ENTRY_BYTES} bytes")
}
buffer.write(chunk, 0, bytesRead)
}
buffer.toByteArray()
}
if (sha256Hex(body) == normalizeSha256(expectedSha256)) {
String(body, Charsets.UTF_8)
} else {
Log.w("WebViewHost", "Remote web app integrity check failed: hash mismatch")
null
}
}
} catch (error: Exception) {
Log.w("WebViewHost", "Remote web app integrity check failed", error)
null
}
private fun isAllowedRemoteOrigin(url: String): Boolean = RemoteNavigationPolicy.isAllowedRemoteOrigin(url, remoteWebAppBaseUrl)
private fun resolvePort(uri: Uri): Int = RemoteNavigationPolicy.resolvePort(URI(uri.toString()))
private fun sha256Hex(bytes: ByteArray): String = RemoteContentIntegrity.sha256Hex(bytes)
private fun normalizeSha256(value: String): String = RemoteContentIntegrity.normalizeSha256(value)
inner class BridgeJsInterface {
@JavascriptInterface
fun postMessage(json: String) {
@@ -217,8 +325,24 @@ class AndroidWebViewHost(
}
}
private fun isBundledOrigin(uri: Uri): Boolean = isMatchingOrigin(uri, "https", BUNDLED_HOST, 443)
private fun isDebugOrigin(uri: Uri): Boolean = isMatchingOrigin(uri, "http", "127.0.0.1", 5173)
private fun isMatchingOrigin(
uri: Uri,
scheme: String,
host: String,
port: Int,
): Boolean = uri.scheme == scheme && uri.host == host && resolvePort(uri) == port
companion object {
private const val BUNDLED_HOST = "appassets.androidplatform.net"
private const val BUNDLED_ORIGIN = "https://$BUNDLED_HOST"
private const val DEBUG_ORIGIN = "http://127.0.0.1:5173"
const val FILE_CHOOSER_REQUEST_CODE = 1001
const val CAMERA_PERMISSION_REQUEST_CODE = 1002
private const val MAX_REMOTE_ENTRY_BYTES = 5 * 1024 * 1024
}
}

View File

@@ -0,0 +1,20 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import java.security.MessageDigest
// null contentType is allowed — some CDNs omit Content-Type; the SHA-256 hash is the primary integrity gate.
internal object RemoteContentIntegrity {
fun normalizeSha256(value: String): String = value.lowercase().removePrefix("sha256-").removePrefix("0x")
fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { byte ->
"%02x".format(byte)
}
fun isAcceptableContentType(rawContentType: String?): Boolean {
val normalized = rawContentType?.substringBefore(";")?.trim()?.lowercase()
return normalized == null || normalized == "text/html"
}
}

View File

@@ -0,0 +1,56 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import java.net.URI
internal object RemoteNavigationPolicy {
private const val REMOTE_SCHEME = "https"
fun buildRemoteEntryUrl(
baseUrl: String?,
queryParams: String,
): String? {
val normalizedBaseUrl = baseUrl?.takeIf { it.isNotBlank() } ?: return null
val uri = parseUri(normalizedBaseUrl) ?: return null
if (uri.scheme != REMOTE_SCHEME || uri.host.isNullOrBlank()) return null
return buildEntryUrl(normalizedBaseUrl.trimEnd('/'), queryParams)
}
fun isAllowedRemoteOrigin(
candidateUrl: String,
baseUrl: String?,
): Boolean {
val normalizedBaseUrl = baseUrl?.takeIf { it.isNotBlank() } ?: return false
val baseUri = parseUri(normalizedBaseUrl) ?: return false
if (baseUri.scheme != REMOTE_SCHEME || baseUri.host.isNullOrBlank()) return false
val candidateUri = parseUri(candidateUrl) ?: return false
return baseUri.scheme == candidateUri.scheme &&
baseUri.host == candidateUri.host &&
resolvePort(baseUri) == resolvePort(candidateUri)
}
fun resolvePort(uri: URI): Int =
when {
uri.port != -1 -> uri.port
uri.scheme == REMOTE_SCHEME -> 443
uri.scheme == "http" -> 80
else -> -1
}
private fun parseUri(value: String): URI? =
try {
URI(value)
} catch (_: IllegalArgumentException) {
null
}
private fun buildEntryUrl(
baseUrl: String,
queryParams: String,
): String {
val separator = if (queryParams.isEmpty()) "" else "?$queryParams"
return "$baseUrl/tunnel/tour/1$separator"
}
}

View File

@@ -36,6 +36,8 @@ class SelfVerificationActivity : AppCompatActivity() {
val chainID = if (intent.hasExtra(EXTRA_CHAIN_ID)) intent.getIntExtra(EXTRA_CHAIN_ID, 0) else null
val userDefinedData = intent.getStringExtra(EXTRA_USER_DEFINED_DATA)
val selfDefinedData = intent.getStringExtra(EXTRA_SELF_DEFINED_DATA)
val remoteWebAppBaseUrl = intent.getStringExtra(EXTRA_REMOTE_WEB_APP_BASE_URL)
val remoteWebAppIntegritySha256 = intent.getStringExtra(EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256)
router =
MessageRouter(
@@ -60,7 +62,14 @@ class SelfVerificationActivity : AppCompatActivity() {
router.register(CryptoHandler())
router.register(LifecycleHandler(this))
webViewHost = AndroidWebViewHost(this, router, isDebugMode)
webViewHost =
AndroidWebViewHost(
context = this,
router = router,
isDebugMode = isDebugMode,
remoteWebAppBaseUrl = remoteWebAppBaseUrl,
remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256,
)
val queryParams =
buildString {
@@ -151,6 +160,8 @@ class SelfVerificationActivity : AppCompatActivity() {
const val EXTRA_CHAIN_ID = "xyz.self.sdk.CHAIN_ID"
const val EXTRA_USER_DEFINED_DATA = "xyz.self.sdk.USER_DEFINED_DATA"
const val EXTRA_SELF_DEFINED_DATA = "xyz.self.sdk.SELF_DEFINED_DATA"
const val EXTRA_REMOTE_WEB_APP_BASE_URL = "xyz.self.sdk.REMOTE_WEB_APP_BASE_URL"
const val EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256 = "xyz.self.sdk.REMOTE_WEB_APP_INTEGRITY_SHA256"
const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA"
}
}

View File

@@ -0,0 +1,121 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RemoteContentIntegrityTest {
// -- normalizeSha256 --
@Test
fun `normalizeSha256 strips sha256- prefix`() {
assertEquals(
"abcdef1234567890",
RemoteContentIntegrity.normalizeSha256("sha256-abcdef1234567890"),
)
}
@Test
fun `normalizeSha256 strips 0x prefix`() {
assertEquals(
"abcdef1234567890",
RemoteContentIntegrity.normalizeSha256("0xabcdef1234567890"),
)
}
@Test
fun `normalizeSha256 strips sha256- then 0x prefix`() {
assertEquals(
"abcdef",
RemoteContentIntegrity.normalizeSha256("sha256-0xabcdef"),
)
}
@Test
fun `normalizeSha256 lowercases input`() {
assertEquals(
"abcdef",
RemoteContentIntegrity.normalizeSha256("ABCDEF"),
)
}
@Test
fun `normalizeSha256 returns raw hex unchanged`() {
assertEquals(
"abcdef1234567890",
RemoteContentIntegrity.normalizeSha256("abcdef1234567890"),
)
}
@Test
fun `normalizeSha256 does not strip interior sha256-`() {
assertEquals(
"absha256-cd",
RemoteContentIntegrity.normalizeSha256("absha256-cd"),
)
}
// -- sha256Hex --
@Test
fun `sha256Hex produces correct hash for known input`() {
// SHA-256 of empty byte array
assertEquals(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
RemoteContentIntegrity.sha256Hex(byteArrayOf()),
)
}
@Test
fun `sha256Hex produces correct hash for hello`() {
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
RemoteContentIntegrity.sha256Hex("hello".toByteArray(Charsets.UTF_8)),
)
}
// -- isAcceptableContentType --
@Test
fun `accepts null content type`() {
assertTrue(RemoteContentIntegrity.isAcceptableContentType(null))
}
@Test
fun `rejects empty content type`() {
assertFalse(RemoteContentIntegrity.isAcceptableContentType(""))
}
@Test
fun `accepts text html`() {
assertTrue(RemoteContentIntegrity.isAcceptableContentType("text/html"))
}
@Test
fun `accepts text html with charset`() {
assertTrue(RemoteContentIntegrity.isAcceptableContentType("text/html; charset=utf-8"))
}
@Test
fun `accepts Text HTML case insensitive`() {
assertTrue(RemoteContentIntegrity.isAcceptableContentType("Text/HTML"))
}
@Test
fun `rejects application javascript`() {
assertFalse(RemoteContentIntegrity.isAcceptableContentType("application/javascript"))
}
@Test
fun `rejects application json`() {
assertFalse(RemoteContentIntegrity.isAcceptableContentType("application/json"))
}
@Test
fun `rejects text plain`() {
assertFalse(RemoteContentIntegrity.isAcceptableContentType("text/plain"))
}
}

View File

@@ -0,0 +1,89 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import java.net.URI
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RemoteNavigationPolicyTest {
@Test
fun `resolvePort defaults https to 443`() {
assertEquals(443, RemoteNavigationPolicy.resolvePort(URI("https://verify.self.xyz")))
}
@Test
fun `resolvePort defaults http to 80`() {
assertEquals(80, RemoteNavigationPolicy.resolvePort(URI("http://127.0.0.1")))
}
@Test
fun `resolvePort preserves explicit port`() {
assertEquals(8443, RemoteNavigationPolicy.resolvePort(URI("https://verify.self.xyz:8443")))
}
@Test
fun `isAllowedRemoteOrigin accepts matching https origin with implicit default port`() {
assertTrue(
RemoteNavigationPolicy.isAllowedRemoteOrigin(
candidateUrl = "https://verify.self.xyz/tunnel/tour/1",
baseUrl = "https://verify.self.xyz:443",
),
)
}
@Test
fun `isAllowedRemoteOrigin rejects different host`() {
assertFalse(
RemoteNavigationPolicy.isAllowedRemoteOrigin(
candidateUrl = "https://evil.self.xyz/tunnel/tour/1",
baseUrl = "https://verify.self.xyz",
),
)
}
@Test
fun `isAllowedRemoteOrigin rejects different scheme`() {
assertFalse(
RemoteNavigationPolicy.isAllowedRemoteOrigin(
candidateUrl = "http://verify.self.xyz/tunnel/tour/1",
baseUrl = "https://verify.self.xyz",
),
)
}
@Test
fun `isAllowedRemoteOrigin rejects different port`() {
assertFalse(
RemoteNavigationPolicy.isAllowedRemoteOrigin(
candidateUrl = "https://verify.self.xyz:8443/tunnel/tour/1",
baseUrl = "https://verify.self.xyz",
),
)
}
@Test
fun `isAllowedRemoteOrigin rejects blank or invalid base url`() {
assertFalse(RemoteNavigationPolicy.isAllowedRemoteOrigin("https://verify.self.xyz", ""))
assertFalse(RemoteNavigationPolicy.isAllowedRemoteOrigin("https://verify.self.xyz", "http://verify.self.xyz"))
assertFalse(RemoteNavigationPolicy.isAllowedRemoteOrigin("https://verify.self.xyz", "https:///missing-host"))
}
@Test
fun `buildRemoteEntryUrl appends hosted entry path and query`() {
assertEquals(
"https://verify.self.xyz/tunnel/tour/1?foo=bar",
RemoteNavigationPolicy.buildRemoteEntryUrl("https://verify.self.xyz/", "foo=bar"),
)
}
@Test
fun `buildRemoteEntryUrl rejects non https and blank host`() {
assertNull(RemoteNavigationPolicy.buildRemoteEntryUrl("http://verify.self.xyz", ""))
assertNull(RemoteNavigationPolicy.buildRemoteEntryUrl("https:///missing-host", ""))
assertNull(RemoteNavigationPolicy.buildRemoteEntryUrl(" ", ""))
}
}

View File

@@ -62,7 +62,12 @@ final class SelfSdkViewController: UIViewController {
router.register(handler: CryptoHandler())
router.register(handler: lifecycleHandler)
let host = SelfWebViewHost(router: router, isDebugMode: config.isDebugMode)
let host = SelfWebViewHost(
router: router,
isDebugMode: config.isDebugMode,
remoteWebAppBaseURL: config.remoteWebAppBaseURL,
remoteWebAppIntegritySha256: config.remoteWebAppIntegritySha256
)
self.webViewHost = host
let webView = host.createWebView()

View File

@@ -19,6 +19,8 @@ public struct SelfSdkConfig {
public let chainID: Int?
public let userDefinedData: String?
public let selfDefinedData: String?
public let remoteWebAppBaseURL: URL?
public let remoteWebAppIntegritySha256: String?
public let secureStorageProvider: SecureStorageProvider
public init(
@@ -38,6 +40,8 @@ public struct SelfSdkConfig {
chainID: Int? = nil,
userDefinedData: String? = nil,
selfDefinedData: String? = nil,
remoteWebAppBaseURL: URL? = nil,
remoteWebAppIntegritySha256: String? = nil,
secureStorageProvider: SecureStorageProvider
) {
self.verificationId = verificationId
@@ -56,6 +60,8 @@ public struct SelfSdkConfig {
self.chainID = chainID
self.userDefinedData = userDefinedData
self.selfDefinedData = selfDefinedData
self.remoteWebAppBaseURL = remoteWebAppBaseURL
self.remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256
self.secureStorageProvider = secureStorageProvider
}

View File

@@ -0,0 +1,20 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
enum BundledAssetPathResolver {
static func resolveFileURL(for requestURL: URL, rootURL: URL) -> URL? {
let rawPath = requestURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let normalizedPath = rawPath.removingPercentEncoding ?? rawPath
let relativePath = normalizedPath.isEmpty || !normalizedPath.contains(".") ? "index.html" : normalizedPath
let fileURL = rootURL.appendingPathComponent(relativePath, isDirectory: false).standardized
let standardizedRootURL = rootURL.standardizedFileURL
let rootPath = standardizedRootURL.path.hasSuffix("/")
? standardizedRootURL.path
: standardizedRootURL.path + "/"
guard fileURL.path.hasPrefix(rootPath) || fileURL.path == standardizedRootURL.path else {
return nil
}
return fileURL
}
}

View File

@@ -0,0 +1,21 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
// nil mimeType is allowed some CDNs omit Content-Type; the SHA-256 hash is the primary integrity gate.
enum RemoteContentIntegrity {
static func normalizeSha256(_ value: String) -> String {
var normalized = value.lowercased()
if normalized.hasPrefix("sha256-") {
normalized.removeFirst("sha256-".count)
}
if normalized.hasPrefix("0x") {
normalized.removeFirst(2)
}
return normalized
}
static func isAcceptableMimeType(_ mimeType: String?) -> Bool {
mimeType == nil || mimeType == "text/html"
}
}

View File

@@ -0,0 +1,73 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
enum RemoteNavigationPolicy {
private static let bundledScheme = SelfWebViewHost.bundledScheme
private static let bundledHost = SelfWebViewHost.bundledHost
private static let allowedSubframeHosts: Set<String> = ["verify.didit.me"]
static func makeEntryURL(baseURL: URL?, queryParams: String) -> URL? {
guard let baseURL else { return nil }
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
return nil
}
let basePath = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
components.path = "/" + [basePath, "tunnel", "tour", "1"].filter { !$0.isEmpty }.joined(separator: "/")
components.percentEncodedQuery = queryParams.isEmpty ? nil : queryParams
return components.url
}
static func isAllowedMainFrameNavigation(
url: URL,
remoteWebAppBaseURL: URL?,
isDebugMode: Bool
) -> Bool {
if isDebugMode {
return url.absoluteString.hasPrefix("http://localhost:5173")
}
if url.scheme == bundledScheme, url.host == bundledHost {
return true
}
guard let remoteWebAppBaseURL,
remoteWebAppBaseURL.scheme == "https",
remoteWebAppBaseURL.host != nil else {
return false
}
return url.scheme == remoteWebAppBaseURL.scheme &&
url.host == remoteWebAppBaseURL.host &&
resolvedPort(for: url) == resolvedPort(for: remoteWebAppBaseURL)
}
static func isAllowedSubframeNavigation(
url: URL,
remoteWebAppBaseURL: URL?,
isDebugMode: Bool
) -> Bool {
if isAllowedMainFrameNavigation(url: url, remoteWebAppBaseURL: remoteWebAppBaseURL, isDebugMode: isDebugMode) {
return true
}
guard url.scheme == "https", let host = url.host else {
return false
}
return allowedSubframeHosts.contains(host)
}
static func resolvedPort(for url: URL) -> Int {
if let port = url.port {
return port
}
switch url.scheme {
case "https":
return 443
case "http":
return 80
default:
return -1
}
}
}

View File

@@ -1,17 +1,31 @@
// SPDX-License-Identifier: BUSL-1.1
import CryptoKit
import Foundation
import UIKit
import WebKit
final class SelfWebViewHost: NSObject {
static let bundledScheme = "self-sdk"
static let bundledHost = "app"
fileprivate static let bundledRootFolder = "self-sdk-web"
private var webView: WKWebView?
private let router: MessageRouter
private let isDebugMode: Bool
private let remoteWebAppBaseURL: URL?
private let remoteWebAppIntegritySha256: String?
init(router: MessageRouter, isDebugMode: Bool = false) {
init(
router: MessageRouter,
isDebugMode: Bool = false,
remoteWebAppBaseURL: URL? = nil,
remoteWebAppIntegritySha256: String? = nil
) {
self.router = router
self.isDebugMode = isDebugMode
self.remoteWebAppBaseURL = remoteWebAppBaseURL
self.remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256
super.init()
}
@@ -23,11 +37,13 @@ final class SelfWebViewHost: NSObject {
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
config.setURLSchemeHandler(SelfBundledAssetSchemeHandler(), forURLScheme: SelfWebViewHost.bundledScheme)
let webView = WKWebView(frame: .zero, configuration: config)
webView.scrollView.bounces = false
webView.isOpaque = false
webView.backgroundColor = .clear
webView.navigationDelegate = self
if #available(iOS 16.4, *) {
webView.isInspectable = isDebugMode
@@ -41,15 +57,19 @@ final class SelfWebViewHost: NSObject {
func loadContent(queryParams: String) {
guard let webView = webView else { return }
var urlString = "https://self-app-alpha.vercel.app/tunnel/tour/1"
if !queryParams.isEmpty {
urlString += "?\(queryParams)"
}
guard let url = URL(string: urlString) else {
NSLog("SelfWebViewHost: Failed to construct URL from: %@", urlString)
if isDebugMode {
let debugBase = URL(string: "http://localhost:5173")
if let url = RemoteNavigationPolicy.makeEntryURL(baseURL: debugBase, queryParams: queryParams) {
webView.load(URLRequest(url: url))
}
return
}
webView.load(URLRequest(url: url))
if let bundledURL = makeBundledEntryURL(queryParams: queryParams) {
webView.load(URLRequest(url: bundledURL))
}
loadVerifiedRemoteContent(queryParams: queryParams)
}
func evaluateJs(_ js: String) {
@@ -57,6 +77,91 @@ final class SelfWebViewHost: NSObject {
self?.webView?.evaluateJavaScript(js, completionHandler: nil)
}
}
private func makeBundledEntryURL(queryParams: String) -> URL? {
RemoteNavigationPolicy.makeEntryURL(
baseURL: URL(string: "\(SelfWebViewHost.bundledScheme)://\(SelfWebViewHost.bundledHost)"),
queryParams: queryParams
)
}
private func loadVerifiedRemoteContent(queryParams: String) {
guard let baseURL = remoteWebAppBaseURL,
baseURL.scheme == "https",
baseURL.host != nil,
let expectedSha256 = remoteWebAppIntegritySha256?.trimmingCharacters(in: .whitespacesAndNewlines),
!expectedSha256.isEmpty,
let remoteURL = RemoteNavigationPolicy.makeEntryURL(baseURL: baseURL, queryParams: queryParams) else {
return
}
Task.detached { [weak self] in
guard let self else { return }
guard let verifiedHTML = await self.fetchAndVerifyRemoteEntry(
url: remoteURL, expectedSha256: expectedSha256
) else {
return
}
await MainActor.run {
self.webView?.loadHTMLString(verifiedHTML, baseURL: remoteURL)
}
}
}
private static let maxRemoteEntryBytes = 5 * 1024 * 1024
private func fetchAndVerifyRemoteEntry(url: URL, expectedSha256: String) async -> String? {
do {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 5
configuration.timeoutIntervalForResource = 5
let session = URLSession(configuration: configuration)
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
return nil
}
guard RemoteContentIntegrity.isAcceptableMimeType(response.mimeType) else {
return nil
}
guard data.count <= SelfWebViewHost.maxRemoteEntryBytes else {
return nil
}
let digest = SHA256.hash(data: data)
let actualHash = digest.map { String(format: "%02x", $0) }.joined()
guard actualHash == normalizeSha256(expectedSha256) else {
return nil
}
return String(data: data, encoding: .utf8)
} catch {
return nil
}
}
private func normalizeSha256(_ value: String) -> String {
RemoteContentIntegrity.normalizeSha256(value)
}
private func isAllowedNavigation(url: URL) -> Bool {
RemoteNavigationPolicy.isAllowedMainFrameNavigation(
url: url,
remoteWebAppBaseURL: remoteWebAppBaseURL,
isDebugMode: isDebugMode
)
}
private func resolvedPort(for url: URL) -> Int {
RemoteNavigationPolicy.resolvedPort(for: url)
}
private func isAllowedSubframeNavigation(url: URL) -> Bool {
RemoteNavigationPolicy.isAllowedSubframeNavigation(
url: url,
remoteWebAppBaseURL: remoteWebAppBaseURL,
isDebugMode: isDebugMode
)
}
}
extension SelfWebViewHost: WKNavigationDelegate {
@@ -65,14 +170,17 @@ extension SelfWebViewHost: WKNavigationDelegate {
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url, let host = url.host else {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
let isTrusted =
(url.scheme == "https" && host == "self-app-alpha.vercel.app") ||
(isDebugMode && url.scheme == "http" && host == "127.0.0.1")
decisionHandler(isTrusted ? .allow : .cancel)
if let targetFrame = navigationAction.targetFrame, !targetFrame.isMainFrame {
decisionHandler(isAllowedSubframeNavigation(url: url) ? .allow : .cancel)
return
}
decisionHandler(isAllowedNavigation(url: url) ? .allow : .cancel)
}
}
@@ -89,6 +197,81 @@ extension SelfWebViewHost: WKScriptMessageHandler {
}
}
private final class SelfBundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let requestURL = urlSchemeTask.request.url,
let rootURL = Bundle.module.resourceURL?.appendingPathComponent(
SelfWebViewHost.bundledRootFolder,
isDirectory: true
),
let fileURL = resolveFileURL(for: requestURL, rootURL: rootURL) else {
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
return
}
do {
let data = try Data(contentsOf: fileURL)
let response = URLResponse(
url: requestURL,
mimeType: mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: textEncodingName(for: fileURL.pathExtension)
)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
} catch {
urlSchemeTask.didFailWithError(error)
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
private func resolveFileURL(for requestURL: URL, rootURL: URL) -> URL? {
BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)
}
private func mimeType(for pathExtension: String) -> String {
switch pathExtension.lowercased() {
case "html":
return "text/html"
case "js":
return "application/javascript"
case "css":
return "text/css"
case "json":
return "application/json"
case "svg":
return "image/svg+xml"
case "png":
return "image/png"
case "jpg", "jpeg":
return "image/jpeg"
case "woff2":
return "font/woff2"
case "woff":
return "font/woff"
case "ttf":
return "font/ttf"
case "otf":
return "font/otf"
case "wav":
return "audio/wav"
default:
return "application/octet-stream"
}
}
private func textEncodingName(for pathExtension: String) -> String? {
switch pathExtension.lowercased() {
case "html", "js", "css", "json", "svg":
return "utf-8"
default:
return nil
}
}
}
// Prevents WKWebView retain cycle with WKScriptMessageHandler
private final class WeakScriptMessageProxy: NSObject, WKScriptMessageHandler {
private weak var handler: WKScriptMessageHandler?

View File

@@ -0,0 +1,41 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
import XCTest
@testable import SelfNativeShell
final class BundledAssetPathResolverTests: XCTestCase {
func testResolvesIndexForDirectoryStyleRequest() {
let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true)
let requestURL = URL(string: "self-sdk://app/tunnel/tour/1")!
XCTAssertEqual(
BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)?.path,
rootURL.appendingPathComponent("index.html").path
)
}
func testResolvesStaticAssetInsideBundle() {
let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true)
let requestURL = URL(string: "self-sdk://app/assets/app.js")!
XCTAssertEqual(
BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)?.path,
rootURL.appendingPathComponent("assets/app.js").path
)
}
func testRejectsPathTraversal() {
let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true)
let requestURL = URL(string: "self-sdk://app/../../secret.txt")!
XCTAssertNil(BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL))
}
func testRejectsPercentEncodedTraversal() {
let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true)
let requestURL = URL(string: "self-sdk://app/%2E%2E/%2E%2E/secret.txt")!
XCTAssertNil(BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL))
}
}

View File

@@ -0,0 +1,84 @@
// SPDX-License-Identifier: BUSL-1.1
import XCTest
@testable import SelfNativeShell
final class RemoteContentIntegrityTests: XCTestCase {
// MARK: - normalizeSha256
func testStripsShaPrefixOnly() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("sha256-abcdef1234567890"),
"abcdef1234567890"
)
}
func testStrips0xPrefixOnly() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("0xabcdef1234567890"),
"abcdef1234567890"
)
}
func testStripsSha256Then0xPrefix() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("sha256-0xabcdef"),
"abcdef"
)
}
func testLowercasesInput() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("ABCDEF"),
"abcdef"
)
}
func testRawHexPassesThrough() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("abcdef1234567890"),
"abcdef1234567890"
)
}
func testDoesNotStripInteriorSha256() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("absha256-cd"),
"absha256-cd"
)
}
func testDoesNotStripInterior0x() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("ab0xcd"),
"ab0xcd"
)
}
// MARK: - isAcceptableMimeType
func testAcceptsNilMimeType() {
XCTAssertTrue(RemoteContentIntegrity.isAcceptableMimeType(nil))
}
func testAcceptsTextHtml() {
XCTAssertTrue(RemoteContentIntegrity.isAcceptableMimeType("text/html"))
}
func testRejectsApplicationJavascript() {
XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("application/javascript"))
}
func testRejectsApplicationJson() {
XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("application/json"))
}
func testRejectsTextPlain() {
XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("text/plain"))
}
func testRejectsEmptyString() {
XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType(""))
}
}

View File

@@ -0,0 +1,85 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
import XCTest
@testable import SelfNativeShell
final class RemoteNavigationPolicyTests: XCTestCase {
func testResolvedPortDefaultsHTTPS() {
XCTAssertEqual(
RemoteNavigationPolicy.resolvedPort(for: URL(string: "https://verify.self.xyz")!),
443
)
}
func testResolvedPortDefaultsHTTP() {
XCTAssertEqual(
RemoteNavigationPolicy.resolvedPort(for: URL(string: "http://localhost")!),
80
)
}
func testResolvedPortPreservesExplicitPort() {
XCTAssertEqual(
RemoteNavigationPolicy.resolvedPort(for: URL(string: "https://verify.self.xyz:8443")!),
8443
)
}
func testMainFrameAllowsMatchingRemoteOriginWithDefaultPort() {
XCTAssertTrue(
RemoteNavigationPolicy.isAllowedMainFrameNavigation(
url: URL(string: "https://verify.self.xyz/tunnel/tour/1")!,
remoteWebAppBaseURL: URL(string: "https://verify.self.xyz:443"),
isDebugMode: false
)
)
}
func testMainFrameRejectsDifferentRemotePort() {
XCTAssertFalse(
RemoteNavigationPolicy.isAllowedMainFrameNavigation(
url: URL(string: "https://verify.self.xyz:8443/tunnel/tour/1")!,
remoteWebAppBaseURL: URL(string: "https://verify.self.xyz"),
isDebugMode: false
)
)
}
func testMainFrameRejectsInvalidRemoteBaseURL() {
XCTAssertFalse(
RemoteNavigationPolicy.isAllowedMainFrameNavigation(
url: URL(string: "https://verify.self.xyz/tunnel/tour/1")!,
remoteWebAppBaseURL: URL(string: "http://verify.self.xyz"),
isDebugMode: false
)
)
}
func testSubframeAllowsDiditHostOnlyOverHTTPS() {
XCTAssertTrue(
RemoteNavigationPolicy.isAllowedSubframeNavigation(
url: URL(string: "https://verify.didit.me/flow")!,
remoteWebAppBaseURL: nil,
isDebugMode: false
)
)
XCTAssertFalse(
RemoteNavigationPolicy.isAllowedSubframeNavigation(
url: URL(string: "http://verify.didit.me/flow")!,
remoteWebAppBaseURL: nil,
isDebugMode: false
)
)
}
func testMakeEntryURLAppendsHostedPathAndQuery() {
XCTAssertEqual(
RemoteNavigationPolicy.makeEntryURL(
baseURL: URL(string: "https://verify.self.xyz/"),
queryParams: "foo=bar"
)?.absoluteString,
"https://verify.self.xyz/tunnel/tour/1?foo=bar"
)
}
}

View File

@@ -0,0 +1,70 @@
// 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 { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const DIST_DIR = join(__dirname, '../../dist');
function readDistFile(path: string): Buffer {
return readFileSync(join(DIST_DIR, path));
}
function sha384(content: Buffer): string {
return createHash('sha384').update(content).digest('base64');
}
describe('subresource integrity', () => {
let html: string;
try {
html = readFileSync(join(DIST_DIR, 'index.html'), 'utf-8');
} catch {
// Build output not available — skip gracefully
html = '';
}
it('index.html contains integrity attributes on script tags', () => {
if (!html) return; // no build output
const scripts = html.match(/<script[^>]*src="[^"]*"[^>]*>/g) ?? [];
expect(scripts.length).toBeGreaterThan(0);
for (const tag of scripts) {
expect(tag).toContain('integrity="sha384-');
}
});
it('index.html contains integrity attributes on stylesheet links', () => {
if (!html) return;
const links = html.match(/<link[^>]*rel="stylesheet"[^>]*>/g) ?? [];
expect(links.length).toBeGreaterThan(0);
for (const tag of links) {
expect(tag).toContain('integrity="sha384-');
}
});
it('script integrity hashes match file contents', () => {
if (!html) return;
const matches = [...html.matchAll(/src="([^"]+)"[^>]*integrity="sha384-([^"]+)"/g)];
expect(matches.length).toBeGreaterThan(0);
for (const [, src, expectedHash] of matches) {
const fileContent = readDistFile(src);
const actualHash = sha384(fileContent);
expect(actualHash).toBe(expectedHash);
}
});
it('stylesheet integrity hashes match file contents', () => {
if (!html) return;
const matches = [...html.matchAll(/href="([^"]+)"[^>]*integrity="sha384-([^"]+)"/g)];
expect(matches.length).toBeGreaterThan(0);
for (const [, href, expectedHash] of matches) {
const fileContent = readDistFile(href);
const actualHash = sha384(fileContent);
expect(actualHash).toBe(expectedHash);
}
});
});

View File

@@ -2,14 +2,65 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { defineConfig } from 'vite';
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';
/**
* Adds Subresource Integrity (SRI) hashes to script/link tags in HTML output.
* Runs after all files are written to disk so hashes match the final bytes
* (including sourcemap comments appended by Rollup).
*/
function subresourceIntegrity(): Plugin {
let outDir = 'dist';
return {
name: 'subresource-integrity',
enforce: 'post',
apply: 'build',
configResolved(config) {
outDir = config.build.outDir;
},
closeBundle() {
const htmlPath = join(outDir, 'index.html');
let html: string;
try {
html = readFileSync(htmlPath, 'utf-8');
} catch {
return;
}
const updated = html.replace(
/(<(?:script|link)[^>]*(?:src|href)="([^"]+)"[^>]*)(\/?>)/g,
(match, before, assetPath, close) => {
if (match.includes('integrity=')) return match;
const filePath = join(outDir, assetPath);
try {
const content = readFileSync(filePath);
const hash = createHash('sha384').update(content).digest('base64');
return `${before} integrity="sha384-${hash}"${close}`;
} catch {
return match;
}
},
);
if (updated !== html) {
writeFileSync(htmlPath, updated);
}
},
};
}
export default defineConfig({
base: '/',
plugins: [
react(),
subresourceIntegrity(),
{
name: 'serve-public-files',
configureServer(server) {