Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b537530
feat: identity data model + walletId keying
jvsena42 Aug 6, 2026
57b1c75
test: cover hw multi-wallet identities
jvsena42 Aug 6, 2026
4d9faff
feat: add Trezor passphrase wallet pairing
jvsena42 Aug 6, 2026
dd794a0
feat: verify passphrase before hw signing
jvsena42 Aug 6, 2026
9cbb34e
feat: add screenshot protection for passphrase inputs
jvsena42 Aug 6, 2026
0a9e5b9
feat: add shield image
jvsena42 Aug 6, 2026
d676acc
test: add passphrase wallet journeys
jvsena42 Aug 6, 2026
cb0550a
fix: shield shrink
jvsena42 Aug 6, 2026
d833939
fix: coin stack image padding
jvsena42 Aug 6, 2026
f993806
fix: prefill passphrase wallet name
jvsena42 Aug 6, 2026
c203bc5
test: update journeys passphrase flow
jvsena42 Aug 6, 2026
8885a6f
fix: TrezorBridgeTransport reused a released session id on re-acquire
jvsena42 Aug 6, 2026
ade56eb
chore: rename changelog fragment
jvsena42 Aug 6, 2026
f6f0dae
Merge branch 'master' into feat/hw-passphrase-wallets
jvsena42 Aug 7, 2026
b95a582
refactor: replace runCatching with runSuspendCatching, limited to the…
jvsena42 Aug 7, 2026
7117b38
fix: HW wallet label persistence
jvsena42 Aug 7, 2026
7e4c8e3
fix: resolve HW wallet name from wallet id
jvsena42 Aug 7, 2026
37b22b2
fix: use stored devices as source of true when deleting
jvsena42 Aug 7, 2026
6c2e2d9
fix: make the device section belongs to walletId instead of merely to…
jvsena42 Aug 7, 2026
712914d
fix: reconnectWithPassphrase requires a live session that, by constru…
jvsena42 Aug 7, 2026
e21c660
fix: report failure if no target is found
jvsena42 Aug 7, 2026
bfd61d7
fix: cancel hwTransferSignJob when dismiss passphrase
jvsena42 Aug 7, 2026
891d9bf
fix: check connectedWalletId on forgetDevice instead of only deviceId
jvsena42 Aug 7, 2026
6aac9f3
fix: connect now supersedes entries of a seed the device no longer ca…
jvsena42 Aug 7, 2026
35824b4
fix: add guard to passphraseProtected to protect default wallets
jvsena42 Aug 7, 2026
97165e7
fix: warns if walletId was not found in onFinishClick
jvsena42 Aug 7, 2026
e14d114
fix: observeConnectedWallet falls back to wallets.firstOrNull { devic…
jvsena42 Aug 7, 2026
a9a6a9d
fix: Released-session cleanup sits inside runCatching after the relea…
jvsena42 Aug 7, 2026
b64c6d4
chore: lint
jvsena42 Aug 7, 2026
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
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/models/HardwareWallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ data class HwWallet(
val activities: ImmutableList<Activity>,
val fundingBalanceSats: ULong = balanceSats,
val deviceIds: ImmutableSet<String> = persistentSetOf(id),
val passphraseProtected: Boolean = false,
)

/** Serializable per-device balance snapshot carried by [BalanceState]. */
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/models/KnownDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,16 @@ data class KnownDevice(
/** Bitkit-side funds label set by the user while pairing; null until renamed within Bitkit. */
val customLabel: String? = null,
val walletId: String = "",
/**
* Whether this entry is a passphrase (hidden) wallet. Nothing else in the record can tell one
* apart from the standard wallet: the xpubs are opaque and the selected mode only lives in
* memory, so reconnects would silently fall back to the standard wallet without this. The
* passphrase itself is never persisted.
*/
val passphraseProtected: Boolean = false,
/**
* The Trezor's own device id, which it regenerates when wiped. Entries of the same transport
* that report a different one belong to a seed the device can no longer sign for.
*/
val trezorDeviceId: String? = null,
)
272 changes: 201 additions & 71 deletions app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt

Large diffs are not rendered by default.

207 changes: 159 additions & 48 deletions app/src/main/java/to/bitkit/repositories/TrezorRepo.kt

Large diffs are not rendered by default.

48 changes: 37 additions & 11 deletions app/src/main/java/to/bitkit/services/TrezorBridgeTransport.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ class TrezorBridgeTransport(
private const val READ_TIMEOUT_MS = 30_000
private const val CALL_READ_TIMEOUT_MS = 120_000

/** What the bridge calls the absence of a held session in an acquire path. */
private const val NO_SESSION = "null"

/** The bridge's answer when the session offered as the previous one is not the one it holds. */
private const val WRONG_PREVIOUS_SESSION = "wrong previous session"

/**
* Trezor protobuf MessageType_SignTx. This is the only call that waits
* for on-device signing.
Expand Down Expand Up @@ -90,18 +96,35 @@ class TrezorBridgeTransport(

fun openDevice(path: String): TrezorTransportWriteResult {
val rawPath = rawBridgePath(path)
val previousSession = openSessions.remove(path) ?: enumeratedSessions[path] ?: "null"
val previousSession = openSessions.remove(path) ?: enumeratedSessions[path] ?: NO_SESSION

return runCatching {
val response = post("/acquire/${encode(rawPath)}/${encode(previousSession)}")
val session = json.decodeFromString<BridgeSession>(response).session
openSessions[path] = session
Logger.info("Opened Trezor Bridge device '$path'", context = TAG)
TrezorTransportWriteResult(success = true, error = "", errorCode = null)
}.getOrElse {
Logger.warn("Failed to open Trezor Bridge device '$path'", it, context = TAG)
TrezorTransportWriteResult(success = false, error = it.message ?: "Bridge open failed", errorCode = null)
}
return acquire(path, rawPath, previousSession)
.recoverCatching { error ->
// The remembered session goes stale in both directions: a release the bridge applied
// but never confirmed, and one that never reached it at all. Rather than trust the
// cache, ask which session it holds and try once more.
if (error.message?.contains(WRONG_PREVIOUS_SESSION, ignoreCase = true) != true) throw error
Logger.info("Refreshing the session held for '$path' after a stale acquire", context = TAG)
runCatching { enumerateDevices() }
acquire(path, rawPath, enumeratedSessions[path] ?: NO_SESSION).getOrThrow()
}
.fold(
onSuccess = { TrezorTransportWriteResult(success = true, error = "", errorCode = null) },
onFailure = {
Logger.warn("Failed to open Trezor Bridge device '$path'", it, context = TAG)
TrezorTransportWriteResult(
success = false,
error = it.message ?: "Bridge open failed",
errorCode = null,
)
},
)
}

private fun acquire(path: String, rawPath: String, previousSession: String): Result<Unit> = runCatching {
val response = post("/acquire/${encode(rawPath)}/${encode(previousSession)}")
openSessions[path] = json.decodeFromString<BridgeSession>(response).session
Logger.info("Opened Trezor Bridge device '$path' after session '$previousSession'", context = TAG)
}

fun closeDevice(path: String): TrezorTransportWriteResult {
Expand All @@ -110,6 +133,9 @@ class TrezorBridgeTransport(

return runCatching {
post("/release/${encode(session)}")
// The released session must not be offered as the previous one on the next acquire:
// the bridge holds none afterwards and rejects a stale id with 'wrong previous session'.
enumeratedSessions.remove(path)
Logger.info("Closed Trezor Bridge device '$path'", context = TAG)
TrezorTransportWriteResult(success = true, error = "", errorCode = null)
}.getOrElse {
Expand Down
36 changes: 18 additions & 18 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -804,10 +804,10 @@ private fun RootNavHost(
)
}
deepLinkableComposable<Routes.SpendingIntroHw> { entry ->
val deviceId = entry.toRoute<Routes.SpendingIntroHw>().deviceId
val walletId = entry.toRoute<Routes.SpendingIntroHw>().walletId
SpendingIntroScreen(
onContinueClick = {
navController.navigateTo(Routes.SpendingAmountHw(deviceId))
navController.navigateTo(Routes.SpendingAmountHw(walletId))
settingsViewModel.setHasSeenSpendingIntro(true)
},
onBackClick = { navController.popBackStack() },
Expand All @@ -831,20 +831,20 @@ private fun RootNavHost(
)
}
deepLinkableComposable<Routes.SpendingAmountHw> { entry ->
val deviceId = entry.toRoute<Routes.SpendingAmountHw>().deviceId
val walletId = entry.toRoute<Routes.SpendingAmountHw>().walletId
val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle()
SpendingAmountHwScreen(
deviceId = deviceId,
walletId = walletId,
viewModel = transferViewModel,
isOffline = connectivityState != ConnectivityState.CONNECTED,
onBackClick = { navController.popBackStack() },
onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(deviceId)) },
onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(walletId)) },
)
}
composableWithDefaultTransitions<Routes.SpendingHwSign> { entry ->
val deviceId = entry.toRoute<Routes.SpendingHwSign>().deviceId
val walletId = entry.toRoute<Routes.SpendingHwSign>().walletId
SpendingHwSignScreen(
deviceId = deviceId,
walletId = walletId,
viewModel = transferViewModel,
onBackClick = { navController.popBackStack() },
onCloseClick = { navController.navigateToHome() },
Expand Down Expand Up @@ -1075,10 +1075,10 @@ private fun NavGraphBuilder.home(
)
}
deepLinkableComposable<Routes.HardwareWallet> {
val deviceId = it.toRoute<Routes.HardwareWallet>().deviceId
val walletId = it.toRoute<Routes.HardwareWallet>().walletId
val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle()
HardwareWalletScreen(
deviceId = deviceId,
walletId = walletId,
onActivityItemClick = { navController.navToActivityDetail(it) },
onTransferToSpendingClick = { selectedDeviceId ->
navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, selectedDeviceId)
Expand Down Expand Up @@ -1906,8 +1906,8 @@ fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean)

fun NavController.navigateToTransferSpendingStart(
hasSeenSpendingIntro: Boolean,
deviceId: String,
) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId))
walletId: String,
) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, walletId))

internal fun shouldDismissSheetForScreenLink(handled: Boolean, currentSheet: Sheet?): Boolean =
handled && currentSheet != null
Expand All @@ -1925,10 +1925,10 @@ internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes =

internal fun transferSpendingStartRoute(
hasSeenSpendingIntro: Boolean,
deviceId: String,
walletId: String,
): Routes = when {
hasSeenSpendingIntro -> Routes.SpendingAmountHw(deviceId)
else -> Routes.SpendingIntroHw(deviceId)
hasSeenSpendingIntro -> Routes.SpendingAmountHw(walletId)
else -> Routes.SpendingIntroHw(walletId)
}

fun NavController.navigateToTransferIntro() = navigateTo(Routes.TransferIntro)
Expand Down Expand Up @@ -1978,7 +1978,7 @@ sealed interface Routes {
data object Spending : Routes.DeepLinkable

@Serializable
data class HardwareWallet(val deviceId: String) : Routes.DeepLinkable
data class HardwareWallet(val walletId: String) : Routes.DeepLinkable

@Serializable
data object Settings : Routes.DeepLinkable
Expand Down Expand Up @@ -2106,16 +2106,16 @@ sealed interface Routes {
data object SpendingIntro : Routes.DeepLinkable

@Serializable
data class SpendingIntroHw(val deviceId: String) : Routes.DeepLinkable
data class SpendingIntroHw(val walletId: String) : Routes.DeepLinkable

@Serializable
data object SpendingAmount : Routes.DeepLinkable

@Serializable
data class SpendingAmountHw(val deviceId: String) : Routes.DeepLinkable
data class SpendingAmountHw(val walletId: String) : Routes.DeepLinkable

@Serializable
data class SpendingHwSign(val deviceId: String) : Routes.InternalOnly
data class SpendingHwSign(val walletId: String) : Routes.InternalOnly

@Serializable
data object SpendingHwSigned : Routes.InternalOnly
Expand Down
Loading
Loading