Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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())
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(",")
Expand Down Expand Up @@ -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"
Expand Down
23 changes: 19 additions & 4 deletions app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
ben-kaufman marked this conversation as resolved.

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<String>? {
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 }
}
Expand Down Expand Up @@ -95,7 +110,7 @@ data class PubkyAuthRequest(
capabilities: String,
): Result<PubkyAuthClaim?> = 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)
Expand All @@ -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)
Expand Down
33 changes: 26 additions & 7 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand All @@ -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(),
Expand All @@ -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()
}
Expand Down Expand Up @@ -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),
Expand All @@ -306,7 +309,7 @@ class PaykitSdkService @Inject constructor(
claim: PubkyAuthCompanionClaim,
) {
isSetup.await()
PubkySessionBootstrap().approveAuthWithCompanionClaim(
bootstrap().approveAuthWithCompanionClaim(
authUrl = authUrl,
expectedCapabilities = expectedCapabilities,
localSecretKey = localSecretKey(secretKeyHex),
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class BuildOutputContractTest {
val fields = listOf(
"E2E",
"E2E_BACKEND",
"E2E_LOCAL_HOST",
"E2E_HOMEGATE_URL",
"TREZOR_BRIDGE",
"TREZOR_BRIDGE_URL",
Expand Down
43 changes: 43 additions & 0 deletions app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(",")
Comment thread
ben-kaufman marked this conversation as resolved.
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(
Expand Down Expand Up @@ -84,6 +103,30 @@ class PubkyAuthRequestTest {
assertIs<PubkyAuthRequestError.InvalidBitkitClaimCapabilities>(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<PubkyAuthRequestError.InvalidBitkitClaimCapabilities>(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<PubkyAuthRequestError.InvalidBitkitClaimCapabilities>(result.exceptionOrNull())
}

@Test
fun `parseCapabilities parses single permission`() {
val permissions = PubkyAuthRequest.parseCapabilities("/pub/bitkit.to/:rw")
Expand Down
28 changes: 28 additions & 0 deletions app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,13 +18,40 @@ 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)
assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing)
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 = (
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1131.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed Paykit Server authorization to require both public and private payment capabilities.
2 changes: 1 addition & 1 deletion docs/watch-only-account-claim-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading