diff --git a/.env.example b/.env.example index 5af4ffb5d..78d23fa49 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ # KEY_PASSWORD= # E2E_BACKEND=local +# Use the development machine's LAN address when running on a physical device. +# E2E_LOCAL_HOST=192.168.1.10 # E2E_HOMEGATE_URL=http://127.0.0.1:6288 # TREZOR_BRIDGE=true diff --git a/Justfile b/Justfile index 4df82236c..1131f77c1 100644 --- a/Justfile +++ b/Justfile @@ -114,8 +114,6 @@ run mode="" logs="": build_env="" if [ "$mode" = "docker" ]; then echo "Forwarding bitkit-docker ports via adb reverse..." - adb -s "$device_id" reverse tcp:60001 tcp:60001 # local Electrum - adb -s "$device_id" reverse tcp:6288 tcp:6288 # local homegate adb -s "$device_id" reverse tcp:9735 tcp:9735 # local lnd peer adb -s "$device_id" reverse tcp:3000 tcp:3000 # local lnurl-server build_env="E2E=true" diff --git a/README.md b/README.md index 3c8ea83ba..d89a536e3 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,9 @@ Pass `E2E=true` and build any flavor. By default, E2E uses a local Electrum over just e2e ``` +Local E2E builds use `10.0.2.2` for Electrum, Homegate, and Pubky when running in an emulator. Set `E2E_LOCAL_HOST` to the development +machine's LAN address when running on a physical device. + #### Use Network Electrum (Staging/Mainnet) Set `E2E_BACKEND=network` to use the network Electrum based on the build flavor: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44595d6f5..11a69278c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,7 +76,9 @@ val bcp47Locales = listOf( ) val e2eEnv = envFlag("E2E", default = false) val e2eBackendEnv = providers.environmentVariable("E2E_BACKEND").orElse("local") -val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL").orElse("http://127.0.0.1:6288") +val e2eLocalHostEnv = providers.environmentVariable("E2E_LOCAL_HOST").orElse("10.0.2.2") +val e2eHomegateUrlEnv = providers.environmentVariable("E2E_HOMEGATE_URL") + .orElse(e2eLocalHostEnv.map { "http://$it:6288" }) val geoEnv = envFlag("GEO", default = true) val paykitUiDisabledEnv = envFlag("PAYKIT_UI_DISABLED", default = false) val trezorBridgeEnv = localProp("TREZOR_BRIDGE").map { it.toBoolean().toString() }.orElse("false") @@ -319,6 +321,7 @@ androidComponents { } buildConfigFields.put("E2E", e2eEnv.booleanField()) buildConfigFields.put("E2E_BACKEND", e2eBackendEnv.stringField()) + buildConfigFields.put("E2E_LOCAL_HOST", e2eLocalHostEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("TREZOR_BRIDGE", trezorBridgeEnv.booleanField()) buildConfigFields.put("TREZOR_BRIDGE_URL", trezorBridgeUrlEnv.stringField()) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index ba86bb38a..e857ef58f 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -22,6 +22,7 @@ internal object Env { const val isGeoblockingEnabled = BuildConfig.GEO val e2eBackend = BuildConfig.E2E_BACKEND.lowercase() val isLocalE2eBackend = isE2eTest && e2eBackend == "local" + const val e2eLocalHost = BuildConfig.E2E_LOCAL_HOST const val e2eHomegateUrl = BuildConfig.E2E_HOMEGATE_URL val network = Network.valueOf(BuildConfig.NETWORK) val locales = BuildConfig.LOCALES.split(",") @@ -285,7 +286,7 @@ private object ElectrumServers { object REGTEST { const val STAG = "ssl://electrs.bitkit.stag0.blocktank.to:9999" - const val LOCAL = "tcp://127.0.0.1:60001" + val LOCAL get() = "tcp://${Env.e2eLocalHost}:60001" } const val TESTNET = "ssl://electrum.blockstream.info:60002" diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index a212b0b25..4c42ed7d3 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -14,8 +14,23 @@ enum class PubkyAuthClaim(val wireValue: String) { /** Query parameter used for Bitkit-specific Pubky auth claims. */ const val QUERY_PARAMETER = "x-bitkit-claim" - /** Capabilities required by the watch-only Paykit Server setup flow. */ - const val WATCH_ONLY_ACCOUNT_CAPABILITIES = "/pub/paykit/v0/bitkit/server/:rw" + /** Both public and private Paykit Server capabilities required by the watch-only setup flow. */ + const val WATCH_ONLY_ACCOUNT_CAPABILITIES = + "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw" + + private val watchOnlyAccountCapabilitySet = WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").toSet() + + /** + * Matches exactly the required public and private capabilities regardless of ordering or surrounding spaces. + */ + fun matchesWatchOnlyAccountCapabilities(capabilities: String) = + capabilitySet(capabilities) == watchOnlyAccountCapabilitySet + + private fun capabilitySet(capabilities: String): Set? { + val entries = capabilities.split(",").map { it.trim() } + if (entries.any { it.isEmpty() }) return null + return entries.toSet() + } fun fromWireValue(value: String) = entries.firstOrNull { it.wireValue == value } } @@ -95,7 +110,7 @@ data class PubkyAuthRequest( capabilities: String, ): Result = when { claimValues.size > 1 -> Result.failure(PubkyAuthRequestError.DuplicateBitkitClaim) - claimValues.isEmpty() && capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES -> + claimValues.isEmpty() && PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) -> Result.failure(PubkyAuthRequestError.MissingBitkitClaim) claimValues.isEmpty() -> Result.success(null) else -> validateBitkitClaimValue(claimValues.first(), capabilities) @@ -108,7 +123,7 @@ data class PubkyAuthRequest( val claim = PubkyAuthClaim.fromWireValue(claimValue) ?: return Result.failure(PubkyAuthRequestError.UnsupportedBitkitClaim(claimValue)) - return if (capabilities == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES) { + return if (PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) { Result.success(claim) } else { Result.failure(PubkyAuthRequestError.InvalidBitkitClaimCapabilities) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index add30a715..818eb5c9d 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -36,6 +36,7 @@ import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthRequest +import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile import com.synonym.paykit.PubkySessionAccess @@ -53,6 +54,7 @@ import com.synonym.paykit.SdkStateBlobSnapshot import com.synonym.paykit.SdkStateBlobStore import com.synonym.paykit.decodeSdkStateBlobSnapshot import com.synonym.paykit.defaultConfig +import com.synonym.paykit.defaultPubkyClientConfig import com.synonym.paykit.encodeSdkStateBlobSnapshot import com.synonym.paykit.parsePubkyAuthUrl import com.synonym.paykit.pubkyPublicKeyFromSecret @@ -129,6 +131,7 @@ class PaykitSdkService @Inject constructor( private val stateStore = PaykitSdkStateBlobStore(keychain) private val sessionProvider = PaykitSdkSessionProvider(keychain) private val paymentAdapter = PaykitSdkPaymentAdapter() + private val pubkyClientConfig by lazy { paykitPubkyClientConfig() } private val handleMutex = Mutex() private val operationMutex = Mutex() private val setupMutex = Mutex() @@ -186,7 +189,7 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val result = PubkySessionBootstrap().importSession( + val result = bootstrap().importSession( sessionSecret = secret, localSecretKey = if (includeLocalSecret) sessionProvider.loadLocalSecretKey() else null, receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), @@ -210,7 +213,7 @@ class PaykitSdkService @Inject constructor( ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val result = PubkySessionBootstrap().signUp( + val result = bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, @@ -231,7 +234,7 @@ class PaykitSdkService @Inject constructor( suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } - val result = PubkySessionBootstrap().signIn( + val result = bootstrap().signIn( localSecretKey = localSecretKey(secretKeyHex), receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities = requiredCapabilities(), @@ -250,7 +253,7 @@ class PaykitSdkService @Inject constructor( suspend fun startAuth(): String { isSetup.await() return operationMutex.withLock { - val request = PubkySessionBootstrap().startSignInAuth(requiredCapabilities()) + val request = bootstrap().startSignInAuth(requiredCapabilities()) activeAuthRequest = request request.authorizationUrl() } @@ -292,7 +295,7 @@ class PaykitSdkService @Inject constructor( suspend fun approveAuth(authUrl: String, expectedCapabilities: String, secretKeyHex: String) { isSetup.await() - PubkySessionBootstrap().approveAuth( + bootstrap().approveAuth( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), @@ -306,7 +309,7 @@ class PaykitSdkService @Inject constructor( claim: PubkyAuthCompanionClaim, ) { isSetup.await() - PubkySessionBootstrap().approveAuthWithCompanionClaim( + bootstrap().approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), @@ -786,14 +789,17 @@ class PaykitSdkService @Inject constructor( private suspend fun handle(): PaykitSdk = handleMutex.withLock { sdk?.let { return@withLock it } - PaykitSdk.withPaymentAdapter( + PaykitSdk.withPaymentAdapterAndPubkyClientConfig( stateStore = stateStore, sessionProvider = sessionProvider, paymentAdapter = paymentAdapter, config = paykitSdkConfig(), + pubkyClient = pubkyClientConfig, ).also { sdk = it } } + private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig(pubkyClientConfig) + private fun resetRuntime() { sdk = null } @@ -845,6 +851,19 @@ internal fun paykitSdkConfig() = defaultConfig(PaykitReceiverPaths.WALLET).copy( publicContactSharing = BitkitPaykitSdkConfig.publicContactSharing, ) +internal fun paykitPubkyClientConfig( + isLocalE2eBackend: Boolean = Env.isLocalE2eBackend, + localTestnetHost: String = Env.e2eLocalHost, + baseConfig: PubkyClientConfig = defaultPubkyClientConfig(), +) = + if (isLocalE2eBackend) { + baseConfig.copy( + localTestnetHost = localTestnetHost, + ) + } else { + baseConfig + } + private class PaykitSdkStateBlobStore( private val keychain: Keychain, ) : SdkStateBlobStore { diff --git a/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt b/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt index a443203ae..bca0b81dc 100644 --- a/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt +++ b/app/src/test/java/to/bitkit/build/BuildOutputContractTest.kt @@ -79,6 +79,7 @@ class BuildOutputContractTest { val fields = listOf( "E2E", "E2E_BACKEND", + "E2E_LOCAL_HOST", "E2E_HOMEGATE_URL", "TREZOR_BRIDGE", "TREZOR_BRIDGE_URL", diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index db0087778..cbc7d5482 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -20,6 +20,25 @@ class PubkyAuthRequestTest { assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) } + @Test + fun `parse recognizes watch-only account claim with reordered capabilities`() { + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").reversed().joinToString(",") + val request = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ).getOrThrow() + + assertEquals(PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1, request.bitkitClaim) + } + + @Test + fun `matcher recognizes watch-only account claim with capability whitespace`() { + val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.replace(",", " , ") + + assertTrue(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) + } + @Test fun `parse preserves normal auth without Bitkit claim`() { val request = PubkyAuthRequest.parse( @@ -84,6 +103,30 @@ class PubkyAuthRequestTest { assertIs(result.exceptionOrNull()) } + @Test + fun `parse rejects watch-only claim without private capability`() { + val capabilities = "/pub/paykit/v0/bitkit/server/:rw" + val result = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `parse rejects watch-only claim with empty capability`() { + val capabilities = "${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}," + val result = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ) + + assertIs(result.exceptionOrNull()) + } + @Test fun `parseCapabilities parses single permission`() { val permissions = PubkyAuthRequest.parseCapabilities("/pub/bitkit.to/:rw") diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 20a85c7b8..c68f688c9 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope +import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PublicContactSharingPolicy import org.junit.Test import org.mockito.kotlin.mock @@ -17,6 +18,11 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class PaykitSdkServiceTest { + private val basePubkyClientConfig = PubkyClientConfig( + requestTimeoutSecs = 30uL, + localTestnetHost = null, + ) + @Test fun `config scopes public endpoint sync to Bitkit managed endpoints`() { assertEquals(EndpointManagementScope.MANAGED_ONLY, BitkitPaykitSdkConfig.endpointManagementScope) @@ -24,6 +30,28 @@ class PaykitSdkServiceTest { assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) } + @Test + fun `production preserves Pubky client config`() { + val config = paykitPubkyClientConfig( + isLocalE2eBackend = false, + baseConfig = basePubkyClientConfig, + ) + + assertEquals(basePubkyClientConfig, config) + } + + @Test + fun `local E2E uses configured host for Pubky testnet`() { + val config = paykitPubkyClientConfig( + isLocalE2eBackend = true, + localTestnetHost = "192.0.2.1", + baseConfig = basePubkyClientConfig, + ) + + assertEquals("192.0.2.1", config.localTestnetHost) + assertEquals(basePubkyClientConfig.requestTimeoutSecs, config.requestTimeoutSecs) + } + @Test fun `receiver noise derivation matches cross platform vector`() { val seed = ( diff --git a/changelog.d/next/1131.fixed.md b/changelog.d/next/1131.fixed.md new file mode 100644 index 000000000..5e47caa71 --- /dev/null +++ b/changelog.d/next/1131.fixed.md @@ -0,0 +1 @@ +Fixed Paykit Server authorization to require both public and private payment capabilities. diff --git a/docs/watch-only-account-claim-v1.md b/docs/watch-only-account-claim-v1.md index bc043e916..71f15d3e0 100644 --- a/docs/watch-only-account-claim-v1.md +++ b/docs/watch-only-account-claim-v1.md @@ -5,7 +5,7 @@ This document records the client contract implemented by Bitkit iOS and Android ## Request - The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. -- The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. +- The exact capabilities are `/pub/paykit/v0/bitkit/server/:rw` and `/pub/paykit/v0/private/bitkit/server/:rw`. - Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. - Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. - Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72f67fc66..6958a037a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.5" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc39" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc43" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }