diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt index d3d0042a5..3f51407a7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -338,6 +338,11 @@ class AppManager private constructor() : FfiReconcile { } } + is MultiFormat.KeyTeleportSenderPacket -> { + // A sender packet scanned outside the flow — start the receive flow from the top + pushRoute(RouteFactory().keyTeleportReceive()) + } + is MultiFormat.Bip329Labels -> { val selectedWallet = database.globalConfig().selectedWallet() if (selectedWallet == null) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveContainer.kt new file mode 100644 index 000000000..f4d3df06d --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveContainer.kt @@ -0,0 +1,85 @@ +package org.bitcoinppl.cove.flows.KeyTeleportFlow + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove_core.KeyTeleportPayload +import org.bitcoinppl.cove_core.KeyTeleportPayloadKind +import org.bitcoinppl.cove_core.KeyTeleportReceiveRoute +import org.bitcoinppl.cove_core.KeyTeleportReceiverSession +import org.bitcoinppl.cove_core.NewWalletRoute +import org.bitcoinppl.cove_core.Route + +private fun ktRoute(inner: KeyTeleportReceiveRoute): Route = + Route.NewWallet(NewWalletRoute.KeyTeleportReceive(inner)) + +@Composable +fun KeyTeleportReceiveContainer( + app: AppManager, + route: KeyTeleportReceiveRoute, +) { + var session by remember { mutableStateOf(null) } + // Decoded payload held in ephemeral memory — never stored in route state + var decodedPayload by remember { mutableStateOf(null) } + + DisposableEffect(Unit) { + session = KeyTeleportReceiverSession() + onDispose { session?.destroy() } + } + + val s = session ?: return + + when (route) { + is KeyTeleportReceiveRoute.ShowQr -> { + KeyTeleportReceiveShowQrScreen( + app = app, + session = s, + onContinue = { app.pushRoute(ktRoute(KeyTeleportReceiveRoute.ScanSender)) }, + ) + } + + is KeyTeleportReceiveRoute.ScanSender -> { + KeyTeleportReceiveScanSenderScreen( + app = app, + onScanned = { senderPacketBbqr -> + app.pushRoute( + ktRoute(KeyTeleportReceiveRoute.EnterPassword(senderPacketBbqr)), + ) + }, + ) + } + + is KeyTeleportReceiveRoute.EnterPassword -> { + KeyTeleportReceivePasswordScreen( + app = app, + session = s, + senderPacketBbqr = route.senderPacketBbqr, + onDecoded = { payload -> + decodedPayload = payload + val kind = when (payload) { + is KeyTeleportPayload.Mnemonic -> KeyTeleportPayloadKind.MNEMONIC + is KeyTeleportPayload.Xprv -> KeyTeleportPayloadKind.XPRV + } + app.pushRoute(ktRoute(KeyTeleportReceiveRoute.ReviewImport(kind))) + }, + ) + } + + is KeyTeleportReceiveRoute.ReviewImport -> { + val payload = decodedPayload + if (payload == null) { + // Session expired / back-nav edge case — go back to start + app.pushRoute(ktRoute(KeyTeleportReceiveRoute.ShowQr)) + return + } + KeyTeleportReceiveImportScreen( + app = app, + payload = payload, + ) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveImportScreen.kt new file mode 100644 index 000000000..1fa4932b0 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveImportScreen.kt @@ -0,0 +1,216 @@ +package org.bitcoinppl.cove.flows.KeyTeleportFlow + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.ImportWalletManager +import org.bitcoinppl.cove.Log +import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove_core.AppAlertState +import org.bitcoinppl.cove_core.ImportWalletException +import org.bitcoinppl.cove_core.KeyTeleportPayload +import org.bitcoinppl.cove_core.Route + +private const val TAG = "KeyTeleportImportScreen" + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun KeyTeleportReceiveImportScreen( + app: AppManager, + payload: KeyTeleportPayload, +) { + var isImporting by remember { mutableStateOf(false) } + + val isMnemonic = payload is KeyTeleportPayload.Mnemonic + val words = if (payload is KeyTeleportPayload.Mnemonic) payload.words.split(" ") else emptyList() + + fun doImport() { + if (isImporting) return + isImporting = true + + try { + when (payload) { + is KeyTeleportPayload.Mnemonic -> { + val metadata = try { + val manager = ImportWalletManager() + try { + manager.importWallet(listOf(words)) + } finally { + manager.close() + } + } catch (e: ImportWalletException.WalletAlreadyExists) { + Log.w(TAG, "Wallet already exists: ${e.v1}") + app.alertState = TaggedItem(AppAlertState.DuplicateWallet(e.v1)) + return + } + app.rust.selectWallet(metadata.id) + app.resetRoute(Route.SelectedWallet(metadata.id)) + app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully) + } + + is KeyTeleportPayload.Xprv -> { + // XPRV hot-wallet import is not yet supported in this version. + app.alertState = TaggedItem( + AppAlertState.General( + title = "Not Yet Supported", + message = "Importing an XPRV via Key Teleport is not yet supported. Only mnemonic transfer is supported at this time.", + ), + ) + } + } + } catch (e: Exception) { + Log.e(TAG, "Import failed", e) + app.alertState = TaggedItem( + AppAlertState.General( + title = "Import Failed", + message = e.message ?: "Unknown error", + ), + ) + } finally { + isImporting = false + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Review & Import", fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = { app.popRoute() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + ), + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (isMnemonic) "Received mnemonic (${words.size} words)" else "Received XPRV", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + + Text( + text = "Review the received secret before importing. Once imported, this will create a new wallet on your device.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + if (isMnemonic) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(12.dp), + ) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + words.forEachIndexed { index, word -> + Row( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(6.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${index + 1}.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = word, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + ) + } + } + } + } else if (payload is KeyTeleportPayload.Xprv) { + Text( + text = payload.xprv, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier + .fillMaxWidth() + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(12.dp), + ) + .padding(12.dp), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { doImport() }, + enabled = !isImporting, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) { + Text(if (isImporting) "Importing…" else "Import Wallet") + } + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceivePasswordScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceivePasswordScreen.kt new file mode 100644 index 000000000..222a285b2 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceivePasswordScreen.kt @@ -0,0 +1,176 @@ +package org.bitcoinppl.cove.flows.KeyTeleportFlow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.Log +import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove_core.AppAlertState +import org.bitcoinppl.cove_core.KeyTeleportException +import org.bitcoinppl.cove_core.KeyTeleportPayload +import org.bitcoinppl.cove_core.KeyTeleportReceiverSession + +private const val TAG = "KeyTeleportPasswordScreen" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KeyTeleportReceivePasswordScreen( + app: AppManager, + session: KeyTeleportReceiverSession, + senderPacketBbqr: String, + onDecoded: (KeyTeleportPayload) -> Unit, +) { + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var isDecoding by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + fun tryDecode() { + if (password.isBlank() || isDecoding) return + isDecoding = true + scope.launch { + try { + val payload = session.decode(senderPacketBbqr, password) + onDecoded(payload) + } catch (e: KeyTeleportException.DecodeFailed) { + Log.w(TAG, "Wrong password or code: $e") + app.alertState = TaggedItem( + AppAlertState.General( + title = "Wrong Password", + message = "The teleport password is incorrect. Check with the sender and try again.", + ), + ) + } catch (e: KeyTeleportException.InvalidSenderPacket) { + Log.w(TAG, "Invalid sender packet: $e") + app.alertState = TaggedItem( + AppAlertState.General( + title = "Invalid Packet", + message = "The scanned QR code is not a valid Key Teleport sender packet.", + ), + ) + } catch (e: Exception) { + Log.e(TAG, "Unexpected decode error", e) + app.alertState = TaggedItem( + AppAlertState.General( + title = "Error", + message = e.message ?: "Unknown error", + ), + ) + } finally { + isDecoding = false + } + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Enter Password", fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = { app.popRoute() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + ), + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Enter the teleport password", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + + Text( + text = "The sender shared a one-time password alongside the QR code. Enter it here to decrypt the received secret.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Teleport password") }, + visualTransformation = if (showPassword) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (showPassword) "Hide password" else "Show password", + ) + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { tryDecode() }), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { tryDecode() }, + enabled = password.isNotBlank() && !isDecoding, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) { + Text(if (isDecoding) "Decrypting…" else "Decrypt") + } + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveScanSenderScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveScanSenderScreen.kt new file mode 100644 index 000000000..407c512b3 --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveScanSenderScreen.kt @@ -0,0 +1,76 @@ +package org.bitcoinppl.cove.flows.KeyTeleportFlow + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.QrCodeScanView +import org.bitcoinppl.cove.TaggedItem +import org.bitcoinppl.cove_core.AppAlertState +import org.bitcoinppl.cove_core.MultiFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KeyTeleportReceiveScanSenderScreen( + app: AppManager, + onScanned: (senderPacketBbqr: String) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text("Scan Sender QR", fontWeight = FontWeight.SemiBold, color = Color.White) + }, + navigationIcon = { + IconButton(onClick = { app.popRoute() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, + containerColor = Color.Black, + modifier = Modifier.fillMaxSize(), + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + QrCodeScanView( + showTopBar = false, + app = app, + onDismiss = { app.popRoute() }, + onScanned = { multiFormat -> + when (multiFormat) { + is MultiFormat.KeyTeleportSenderPacket -> { + onScanned(multiFormat.v1) + } + else -> { + app.alertState = TaggedItem( + AppAlertState.General( + title = "Wrong QR Code", + message = "Please scan the sender's Key Teleport QR code (starts with B\$2S)", + ), + ) + app.popRoute() + } + } + }, + ) + } + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveShowQrScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveShowQrScreen.kt new file mode 100644 index 000000000..d58deefad --- /dev/null +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/KeyTeleportFlow/KeyTeleportReceiveShowQrScreen.kt @@ -0,0 +1,218 @@ +package org.bitcoinppl.cove.flows.KeyTeleportFlow + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.QrCodeGenerator +import org.bitcoinppl.cove_core.KeyTeleportReceiverSession + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KeyTeleportReceiveShowQrScreen( + app: AppManager, + session: KeyTeleportReceiverSession, + onContinue: () -> Unit, +) { + val context = LocalContext.current + val bbqr = remember(session) { session.receiverPacketBbqr() } + val code = remember(session) { session.numericCodeDisplay() } + val qrBitmap = remember(bbqr) { QrCodeGenerator.generate(bbqr, 512) } + + fun copyToClipboard() { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Key Teleport receiver packet", bbqr)) + } + + fun sharePacket() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, bbqr) + } + context.startActivity(Intent.createChooser(intent, "Share Key Teleport receiver packet")) + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Key Teleport", fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = { app.popRoute() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + ), + ) + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + // Scrollable content — no weight() here (would crash inside verticalScroll) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 88.dp) // leave room for the pinned button + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Show this QR to the sender", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + + Text( + text = "The sender will scan your QR code, then you share your numeric code with them over a separate channel.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // QR code + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = "Receiver QR code", + modifier = Modifier + .size(260.dp) + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(12.dp), + ) + + // Copy / Share buttons for remote senders + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton(onClick = { copyToClipboard() }) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text("Copy") + } + OutlinedButton(onClick = { sharePacket() }) { + Icon( + Icons.Filled.Share, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text("Share") + } + } + + // numeric code display + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Verification Code", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + modifier = Modifier + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(8.dp), + ) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = code.take(4), + fontSize = 28.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 4.sp, + ) + Text( + text = code.drop(4), + fontSize = 28.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 4.sp, + ) + } + } + Text( + text = "Share this code over a separate channel (chat, phone call, etc.)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + } // end scrollable Column + + // Button pinned to bottom, outside the scrollable column + Button( + onClick = onContinue, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp), + ) { + Text("Sender has scanned — Continue") + } + } // end Box + } +} diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletContainer.kt index 03d867842..7d3437324 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletContainer.kt @@ -5,12 +5,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import org.bitcoinppl.cove.AppManager +import org.bitcoinppl.cove.flows.KeyTeleportFlow.KeyTeleportReceiveContainer import org.bitcoinppl.cove.flows.NewWalletFlow.cold_wallet.ColdWalletQrScanScreen import org.bitcoinppl.cove.flows.NewWalletFlow.hot_wallet.NewHotWalletContainer import org.bitcoinppl.cove.utils.intoRoute import org.bitcoinppl.cove_core.ColdWalletRoute import org.bitcoinppl.cove_core.HotWalletRoute import org.bitcoinppl.cove_core.NewWalletRoute +import org.bitcoinppl.cove_core.RouteFactory /** * New wallet container - simple router for new wallet flows @@ -47,6 +49,9 @@ fun NewWalletContainer( onOpenNfcScan = { app.scanNfc() }, + onOpenKeyTeleport = { + app.pushRoute(RouteFactory().keyTeleportReceive()) + }, snackbarHostState = snackbarHostState, ) } @@ -64,5 +69,11 @@ fun NewWalletContainer( } } } + is NewWalletRoute.KeyTeleportReceive -> { + KeyTeleportReceiveContainer( + app = app, + route = route.v1, + ) + } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletSelectScreen.kt index 7dde315c6..db898d15b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletSelectScreen.kt @@ -92,6 +92,7 @@ fun NewWalletSelectScreen( onOpenNewHotWallet: () -> Unit, onOpenQrScan: () -> Unit, onOpenNfcScan: () -> Unit, + onOpenKeyTeleport: () -> Unit = {}, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { var showHardwareWalletSheet by remember { mutableStateOf(false) } @@ -411,6 +412,25 @@ fun NewWalletSelectScreen( } }, ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Key Teleport option + ListItem( + headlineContent = { Text("Key Teleport") }, + supportingContent = { Text("Receive mnemonic or XPRV wirelessly") }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icon_qr_code), + contentDescription = null, + ) + }, + modifier = + Modifier.clickable { + showHardwareWalletSheet = false + onOpenKeyTeleport() + }, + ) } } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index c5fe15897..bdbab615e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1322,6 +1322,12 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_filehandler_read( ): Short + external fun uniffi_cove_checksum_method_keyteleportreceiversession_decode( + ): Short + external fun uniffi_cove_checksum_method_keyteleportreceiversession_numeric_code_display( + ): Short + external fun uniffi_cove_checksum_method_keyteleportreceiversession_receiver_packet_bbqr( + ): Short external fun uniffi_cove_checksum_method_labelmanager_delete_labels_for_txn( ): Short external fun uniffi_cove_checksum_method_labelmanager_export( @@ -1658,6 +1664,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_routefactory_is_same_parent_route( ): Short + external fun uniffi_cove_checksum_method_routefactory_key_teleport_receive( + ): Short external fun uniffi_cove_checksum_method_routefactory_load_and_reset_nested_to( ): Short external fun uniffi_cove_checksum_method_routefactory_load_and_reset_to( @@ -1862,6 +1870,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_constructor_filehandler_new( ): Short + external fun uniffi_cove_checksum_constructor_keyteleportreceiversession_new( + ): Short external fun uniffi_cove_checksum_constructor_addressargs_new( ): Short external fun uniffi_cove_checksum_constructor_labelmanager_new( @@ -2316,6 +2326,18 @@ internal object UniffiLib { ): Long external fun uniffi_cove_fn_free_hardwareexport(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit + external fun uniffi_cove_fn_clone_keyteleportreceiversession(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_fn_free_keyteleportreceiversession(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_cove_fn_constructor_keyteleportreceiversession_new(uniffi_out_err: UniffiRustCallStatus, + ): Long + external fun uniffi_cove_fn_method_keyteleportreceiversession_decode(`ptr`: Long,`senderPacketBbqr`: RustBuffer.ByValue,`teleportPassword`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_keyteleportreceiversession_numeric_code_display(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_cove_fn_method_keyteleportreceiversession_receiver_packet_bbqr(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_clone_addressargs(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_addressargs(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2776,6 +2798,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_cove_fn_method_routefactory_is_same_parent_route(`ptr`: Long,`route`: RustBuffer.ByValue,`routeToCheck`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Byte + external fun uniffi_cove_fn_method_routefactory_key_teleport_receive(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_cove_fn_method_routefactory_load_and_reset_nested_to(`ptr`: Long,`defaultRoute`: RustBuffer.ByValue,`nestedRoutes`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_method_routefactory_load_and_reset_to(`ptr`: Long,`resetTo`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -3914,6 +3938,15 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_filehandler_read() != 12343.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_keyteleportreceiversession_decode() != 58164.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_keyteleportreceiversession_numeric_code_display() != 40430.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_cove_checksum_method_keyteleportreceiversession_receiver_packet_bbqr() != 48518.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_labelmanager_delete_labels_for_txn() != 50691.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4418,6 +4451,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_routefactory_is_same_parent_route() != 8524.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_routefactory_key_teleport_receive() != 8004.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_routefactory_load_and_reset_nested_to() != 27109.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4724,6 +4760,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_constructor_filehandler_new() != 14514.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_constructor_keyteleportreceiversession_new() != 14340.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_constructor_addressargs_new() != 7657.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -13832,6 +13871,300 @@ public object FfiConverterTypeHistoricalPricesResponse: FfiConverter + UniffiLib.uniffi_cove_fn_constructor_keyteleportreceiversession_new( + + _status) +} + ) + + protected val handle: Long + protected val cleanable: UniffiCleaner.Cleanable? + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + /** + * Whether the current object has been destroyed and its reference is gone in the Rust side. + */ + val uniffiIsDestroyed: Boolean get() = wasDestroyed.get() + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithHandle(block: (handle: Long) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the handle being freed concurrently. + try { + return block(this.uniffiCloneHandle()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable?.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val handle: Long) : Runnable { + override fun run() { + if (handle == 0.toLong()) { + // Fake object created with `NoHandle`, don't try to free. + return; + } + uniffiRustCall { status -> + UniffiLib.uniffi_cove_fn_free_keyteleportreceiversession(handle, status) + } + } + } + + /** + * @suppress + */ + fun uniffiCloneHandle(): Long { + if (handle == 0.toLong()) { + throw InternalException("uniffiCloneHandle() called on NoHandle object"); + } + return uniffiRustCall() { status -> + UniffiLib.uniffi_cove_fn_clone_keyteleportreceiversession(handle, status) + } + } + + + @Throws(KeyTeleportException::class)override fun `decode`(`senderPacketBbqr`: kotlin.String, `teleportPassword`: kotlin.String): KeyTeleportPayload { + return FfiConverterTypeKeyTeleportPayload.lift( + callWithHandle { + uniffiRustCallWithError(KeyTeleportException) { _status -> + UniffiLib.uniffi_cove_fn_method_keyteleportreceiversession_decode( + it, + FfiConverterString.lower(`senderPacketBbqr`),FfiConverterString.lower(`teleportPassword`),_status) +} + } + ) + } + + + override fun `numericCodeDisplay`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_keyteleportreceiversession_numeric_code_display( + it, + _status) +} + } + ) + } + + + override fun `receiverPacketBbqr`(): kotlin.String { + return FfiConverterString.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_keyteleportreceiversession_receiver_packet_bbqr( + it, + _status) +} + } + ) + } + + + + + + + + + + /** + * @suppress + */ + companion object + +} + + +/** + * @suppress + */ +public object FfiConverterTypeKeyTeleportReceiverSession: FfiConverter { + override fun lower(value: KeyTeleportReceiverSession): Long { + return value.uniffiCloneHandle() + } + + override fun lift(value: Long): KeyTeleportReceiverSession { + return KeyTeleportReceiverSession(UniffiWithHandle, value) + } + + override fun read(buf: ByteBuffer): KeyTeleportReceiverSession { + return lift(buf.getLong()) + } + + override fun allocationSize(value: KeyTeleportReceiverSession) = 8UL + + override fun write(value: KeyTeleportReceiverSession, buf: ByteBuffer) { + buf.putLong(lower(value)) + } +} + + +// This template implements a class for working with a Rust struct via a handle +// to the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque handle to the underlying Rust struct. +// Method calls need to read this handle from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its handle should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the handle, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the handle, but is interrupted +// before it can pass the handle over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read handle value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + public interface LabelManagerInterface { fun `deleteLabelsForTxn`(`txId`: TxId) @@ -16511,6 +16844,8 @@ public interface RouteFactoryInterface { fun `isSameParentRoute`(`route`: Route, `routeToCheck`: Route): kotlin.Boolean + fun `keyTeleportReceive`(): Route + fun `loadAndResetNestedTo`(`defaultRoute`: Route, `nestedRoutes`: List): Route fun `loadAndResetTo`(`resetTo`: Route): Route @@ -16718,6 +17053,19 @@ open class RouteFactory: Disposable, AutoCloseable, RouteFactoryInterface } + override fun `keyTeleportReceive`(): Route { + return FfiConverterTypeRoute.lift( + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_routefactory_key_teleport_receive( + it, + _status) +} + } + ) + } + + override fun `loadAndResetNestedTo`(`defaultRoute`: Route, `nestedRoutes`: List): Route { return FfiConverterTypeRoute.lift( callWithHandle { @@ -38645,6 +38993,340 @@ public object FfiConverterTypeInsertOrUpdate : FfiConverterRustBuffer { + override fun lift(error_buf: RustBuffer.ByValue): KeyTeleportException = FfiConverterTypeKeyTeleportError.lift(error_buf) + } + + +} + +/** + * @suppress + */ +public object FfiConverterTypeKeyTeleportError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): KeyTeleportException { + + + return when(buf.getInt()) { + 1 -> KeyTeleportException.InvalidSenderPacket( + FfiConverterString.read(buf), + ) + 2 -> KeyTeleportException.DecodeFailed( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: KeyTeleportException): ULong { + return when(value) { + is KeyTeleportException.InvalidSenderPacket -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is KeyTeleportException.DecodeFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + } + } + + override fun write(value: KeyTeleportException, buf: ByteBuffer) { + when(value) { + is KeyTeleportException.InvalidSenderPacket -> { + buf.putInt(1) + FfiConverterString.write(value.v1, buf) + Unit + } + is KeyTeleportException.DecodeFailed -> { + buf.putInt(2) + FfiConverterString.write(value.v1, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + +} + + + +sealed class KeyTeleportPayload { + + /** + * A BIP-39 mnemonic — the word list as a space-separated string. + */ + data class Mnemonic( + val `words`: kotlin.String) : KeyTeleportPayload() + + { + + + companion object + } + + /** + * A serialized XPRV (base58). + */ + data class Xprv( + val `xprv`: kotlin.String) : KeyTeleportPayload() + + { + + + companion object + } + + + + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeKeyTeleportPayload : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): KeyTeleportPayload { + return when(buf.getInt()) { + 1 -> KeyTeleportPayload.Mnemonic( + FfiConverterString.read(buf), + ) + 2 -> KeyTeleportPayload.Xprv( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: KeyTeleportPayload): ULong = when(value) { + is KeyTeleportPayload.Mnemonic -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`words`) + ) + } + is KeyTeleportPayload.Xprv -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`xprv`) + ) + } + } + + override fun write(value: KeyTeleportPayload, buf: ByteBuffer) { + when(value) { + is KeyTeleportPayload.Mnemonic -> { + buf.putInt(1) + FfiConverterString.write(value.`words`, buf) + Unit + } + is KeyTeleportPayload.Xprv -> { + buf.putInt(2) + FfiConverterString.write(value.`xprv`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + +/** + * Payload type indicator — carried through the route so the import screen + * can display appropriate labels without re-decrypting. + */ + +enum class KeyTeleportPayloadKind { + + MNEMONIC, + XPRV; + + + + + companion object +} + + +/** + * @suppress + */ +public object FfiConverterTypeKeyTeleportPayloadKind: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + KeyTeleportPayloadKind.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: KeyTeleportPayloadKind) = 4UL + + override fun write(value: KeyTeleportPayloadKind, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + +sealed class KeyTeleportReceiveRoute { + + /** + * Show the receiver QR / BBQr and numeric code. + */ + object ShowQr : KeyTeleportReceiveRoute() + + + /** + * Scan (or paste) the sender's BBQr packet. + */ + object ScanSender : KeyTeleportReceiveRoute() + + + /** + * Enter the teleport password that the sender shared out-of-band. + */ + data class EnterPassword( + val `senderPacketBbqr`: kotlin.String) : KeyTeleportReceiveRoute() + + { + + + companion object + } + + /** + * Review the decoded payload before importing as a wallet. + * The actual secret is held in ephemeral container state, not here. + */ + data class ReviewImport( + val `payloadKind`: org.bitcoinppl.cove_core.KeyTeleportPayloadKind) : KeyTeleportReceiveRoute() + + { + + + companion object + } + + + + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeKeyTeleportReceiveRoute : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): KeyTeleportReceiveRoute { + return when(buf.getInt()) { + 1 -> KeyTeleportReceiveRoute.ShowQr + 2 -> KeyTeleportReceiveRoute.ScanSender + 3 -> KeyTeleportReceiveRoute.EnterPassword( + FfiConverterString.read(buf), + ) + 4 -> KeyTeleportReceiveRoute.ReviewImport( + FfiConverterTypeKeyTeleportPayloadKind.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: KeyTeleportReceiveRoute): ULong = when(value) { + is KeyTeleportReceiveRoute.ShowQr -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is KeyTeleportReceiveRoute.ScanSender -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } + is KeyTeleportReceiveRoute.EnterPassword -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`senderPacketBbqr`) + ) + } + is KeyTeleportReceiveRoute.ReviewImport -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeKeyTeleportPayloadKind.allocationSize(value.`payloadKind`) + ) + } + } + + override fun write(value: KeyTeleportReceiveRoute, buf: ByteBuffer) { + when(value) { + is KeyTeleportReceiveRoute.ShowQr -> { + buf.putInt(1) + Unit + } + is KeyTeleportReceiveRoute.ScanSender -> { + buf.putInt(2) + Unit + } + is KeyTeleportReceiveRoute.EnterPassword -> { + buf.putInt(3) + FfiConverterString.write(value.`senderPacketBbqr`, buf) + Unit + } + is KeyTeleportReceiveRoute.ReviewImport -> { + buf.putInt(4) + FfiConverterTypeKeyTeleportPayloadKind.write(value.`payloadKind`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + + + sealed class LabelDbException: kotlin.Exception() { class Database( @@ -39368,6 +40050,28 @@ sealed class MultiFormat: Disposable { { + // The local Rust `Eq` implementation - only `eq` is used. + override fun equals(other: Any?): Boolean { + if (other !is MultiFormat) return false + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_multiformat_uniffi_trait_eq_eq(FfiConverterTypeMultiFormat.lower(this), + FfiConverterTypeMultiFormat.lower(`other`),_status) +} + ) + } + companion object + } + + /** + * A Key Teleport sender packet (BBQr `S` type) scanned by the receiver. + */ + data class KeyTeleportSenderPacket( + val v1: kotlin.String) : MultiFormat() + + { + + // The local Rust `Eq` implementation - only `eq` is used. override fun equals(other: Any?): Boolean { if (other !is MultiFormat) return false @@ -39437,6 +40141,13 @@ sealed class MultiFormat: Disposable { } is MultiFormat.SignedPsbt -> { + Disposable.destroy( + this.v1 + ) + + } + is MultiFormat.KeyTeleportSenderPacket -> { + Disposable.destroy( this.v1 ) @@ -39493,6 +40204,9 @@ public object FfiConverterTypeMultiFormat : FfiConverterRustBuffer{ 8 -> MultiFormat.SignedPsbt( FfiConverterTypePsbt.read(buf), ) + 9 -> MultiFormat.KeyTeleportSenderPacket( + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -39554,6 +40268,13 @@ public object FfiConverterTypeMultiFormat : FfiConverterRustBuffer{ + FfiConverterTypePsbt.allocationSize(value.v1) ) } + is MultiFormat.KeyTeleportSenderPacket -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + } } override fun write(value: MultiFormat, buf: ByteBuffer) { @@ -39598,6 +40319,11 @@ public object FfiConverterTypeMultiFormat : FfiConverterRustBuffer{ FfiConverterTypePsbt.write(value.v1, buf) Unit } + is MultiFormat.KeyTeleportSenderPacket -> { + buf.putInt(9) + FfiConverterString.write(value.v1, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } @@ -39924,6 +40650,15 @@ sealed class NewWalletRoute { companion object } + data class KeyTeleportReceive( + val v1: org.bitcoinppl.cove_core.KeyTeleportReceiveRoute) : NewWalletRoute() + + { + + + companion object + } + @@ -39947,6 +40682,9 @@ public object FfiConverterTypeNewWalletRoute : FfiConverterRustBuffer NewWalletRoute.ColdWallet( FfiConverterTypeColdWalletRoute.read(buf), ) + 4 -> NewWalletRoute.KeyTeleportReceive( + FfiConverterTypeKeyTeleportReceiveRoute.read(buf), + ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -39972,6 +40710,13 @@ public object FfiConverterTypeNewWalletRoute : FfiConverterRustBuffer { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeKeyTeleportReceiveRoute.allocationSize(value.v1) + ) + } } override fun write(value: NewWalletRoute, buf: ByteBuffer) { @@ -39990,6 +40735,11 @@ public object FfiConverterTypeNewWalletRoute : FfiConverterRustBuffer { + buf.putInt(4) + FfiConverterTypeKeyTeleportReceiveRoute.write(value.v1, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } diff --git a/ios/Cove/CoveMainView.swift b/ios/Cove/CoveMainView.swift index d5ff79117..c37ea7071 100644 --- a/ios/Cove/CoveMainView.swift +++ b/ios/Cove/CoveMainView.swift @@ -419,6 +419,8 @@ struct CoveMainView: View { ) case let .signedPsbt(psbt): handleSignedPsbt(psbt) + case .keyTeleportSenderPacket: + Log.warn("Key Teleport sender packet in file import — ignoring, use the receive flow") } } catch { switch error { @@ -485,6 +487,8 @@ struct CoveMainView: View { // when labels are imported, we need to get the transactions again with the updated labels Task { await manager.rust.getTransactions() } + case .keyTeleportSenderPacket: + app.alertState = TaggedItem(.invalidFormat(message: "Key Teleport receive is not yet available on iOS. Please use the Android app to receive via Key Teleport.")) } } catch { switch error { diff --git a/ios/Cove/Flows/KeyTeleportFlow/KeyTeleportReceiveUnavailableView.swift b/ios/Cove/Flows/KeyTeleportFlow/KeyTeleportReceiveUnavailableView.swift new file mode 100644 index 000000000..c2afda91e --- /dev/null +++ b/ios/Cove/Flows/KeyTeleportFlow/KeyTeleportReceiveUnavailableView.swift @@ -0,0 +1,38 @@ +// +// KeyTeleportReceiveUnavailableView.swift +// Cove +// + +import SwiftUI + +struct KeyTeleportReceiveUnavailableView: View { + @Environment(AppManager.self) private var app + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + + Text("Key Teleport") + .font(.title2) + .fontWeight(.semibold) + + Text("Key Teleport receive is not yet available on iOS. Please use the Android app to receive a wallet via Key Teleport.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Spacer() + + Button("Dismiss") { + app.popRoute() + } + .buttonStyle(.borderedProminent) + .padding(.bottom, 32) + } + } +} diff --git a/ios/Cove/Flows/NewWalletFlow/Container/NewWalletContainer.swift b/ios/Cove/Flows/NewWalletFlow/Container/NewWalletContainer.swift index 1328f4223..eac5d4fae 100644 --- a/ios/Cove/Flows/NewWalletFlow/Container/NewWalletContainer.swift +++ b/ios/Cove/Flows/NewWalletFlow/Container/NewWalletContainer.swift @@ -18,6 +18,8 @@ struct NewWalletContainer: View { NewHotWalletContainer(route: route) case .coldWallet(.qrCode): QrCodeImportScreen() + case .keyTeleportReceive: + KeyTeleportReceiveUnavailableView() } } } diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 16449b464..9dca8df1a 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -4935,6 +4935,151 @@ public func FfiConverterTypeHistoricalPricesResponse_lower(_ value: HistoricalPr +public protocol KeyTeleportReceiverSessionProtocol: AnyObject, Sendable { + + func decode(senderPacketBbqr: String, teleportPassword: String) throws -> KeyTeleportPayload + + func numericCodeDisplay() -> String + + func receiverPacketBbqr() -> String + +} +open class KeyTeleportReceiverSession: KeyTeleportReceiverSessionProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_cove_fn_clone_keyteleportreceiversession(self.handle, $0) } + } +public convenience init() { + let handle = + try! rustCall() { + uniffi_cove_fn_constructor_keyteleportreceiversession_new($0 + ) +} + self.init(unsafeFromHandle: handle) +} + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_cove_fn_free_keyteleportreceiversession(handle, $0) } + } + + + + +open func decode(senderPacketBbqr: String, teleportPassword: String)throws -> KeyTeleportPayload { + return try FfiConverterTypeKeyTeleportPayload_lift(try rustCallWithError(FfiConverterTypeKeyTeleportError_lift) { + uniffi_cove_fn_method_keyteleportreceiversession_decode( + self.uniffiCloneHandle(), + FfiConverterString.lower(senderPacketBbqr), + FfiConverterString.lower(teleportPassword),$0 + ) +}) +} + +open func numericCodeDisplay() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_cove_fn_method_keyteleportreceiversession_numeric_code_display( + self.uniffiCloneHandle(),$0 + ) +}) +} + +open func receiverPacketBbqr() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_cove_fn_method_keyteleportreceiversession_receiver_packet_bbqr( + self.uniffiCloneHandle(),$0 + ) +}) +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeKeyTeleportReceiverSession: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = KeyTeleportReceiverSession + + public static func lift(_ handle: UInt64) throws -> KeyTeleportReceiverSession { + return KeyTeleportReceiverSession(unsafeFromHandle: handle) + } + + public static func lower(_ value: KeyTeleportReceiverSession) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> KeyTeleportReceiverSession { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: KeyTeleportReceiverSession, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportReceiverSession_lift(_ handle: UInt64) throws -> KeyTeleportReceiverSession { + return try FfiConverterTypeKeyTeleportReceiverSession.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportReceiverSession_lower(_ value: KeyTeleportReceiverSession) -> UInt64 { + return FfiConverterTypeKeyTeleportReceiverSession.lower(value) +} + + + + + + public protocol LabelManagerProtocol: AnyObject, Sendable { func deleteLabelsForTxn(txId: TxId) throws @@ -6293,6 +6438,8 @@ public protocol RouteFactoryProtocol: AnyObject, Sendable { func isSameParentRoute(route: Route, routeToCheck: Route) -> Bool + func keyTeleportReceive() -> Route + func loadAndResetNestedTo(defaultRoute: Route, nestedRoutes: [Route]) -> Route func loadAndResetTo(resetTo: Route) -> Route @@ -6430,6 +6577,14 @@ open func isSameParentRoute(route: Route, routeToCheck: Route) -> Bool { }) } +open func keyTeleportReceive() -> Route { + return try! FfiConverterTypeRoute_lift(try! rustCall() { + uniffi_cove_fn_method_routefactory_key_teleport_receive( + self.uniffiCloneHandle(),$0 + ) +}) +} + open func loadAndResetNestedTo(defaultRoute: Route, nestedRoutes: [Route]) -> Route { return try! FfiConverterTypeRoute_lift(try! rustCall() { uniffi_cove_fn_method_routefactory_load_and_reset_nested_to( @@ -22614,6 +22769,338 @@ public func FfiConverterTypeInsertOrUpdate_lower(_ value: InsertOrUpdate) -> Rus +public +enum KeyTeleportError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + case InvalidSenderPacket(String + ) + case DecodeFailed(String + ) + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension KeyTeleportError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeKeyTeleportError: FfiConverterRustBuffer { + typealias SwiftType = KeyTeleportError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> KeyTeleportError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidSenderPacket( + try FfiConverterString.read(from: &buf) + ) + case 2: return .DecodeFailed( + try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: KeyTeleportError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .InvalidSenderPacket(v1): + writeInt(&buf, Int32(1)) + FfiConverterString.write(v1, into: &buf) + + + case let .DecodeFailed(v1): + writeInt(&buf, Int32(2)) + FfiConverterString.write(v1, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportError_lift(_ buf: RustBuffer) throws -> KeyTeleportError { + return try FfiConverterTypeKeyTeleportError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportError_lower(_ value: KeyTeleportError) -> RustBuffer { + return FfiConverterTypeKeyTeleportError.lower(value) +} + + + +public enum KeyTeleportPayload: Equatable, Hashable { + + /** + * A BIP-39 mnemonic — the word list as a space-separated string. + */ + case mnemonic(words: String + ) + /** + * A serialized XPRV (base58). + */ + case xprv(xprv: String + ) + + + + + +} + +#if compiler(>=6) +extension KeyTeleportPayload: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeKeyTeleportPayload: FfiConverterRustBuffer { + typealias SwiftType = KeyTeleportPayload + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> KeyTeleportPayload { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .mnemonic(words: try FfiConverterString.read(from: &buf) + ) + + case 2: return .xprv(xprv: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: KeyTeleportPayload, into buf: inout [UInt8]) { + switch value { + + + case let .mnemonic(words): + writeInt(&buf, Int32(1)) + FfiConverterString.write(words, into: &buf) + + + case let .xprv(xprv): + writeInt(&buf, Int32(2)) + FfiConverterString.write(xprv, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportPayload_lift(_ buf: RustBuffer) throws -> KeyTeleportPayload { + return try FfiConverterTypeKeyTeleportPayload.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportPayload_lower(_ value: KeyTeleportPayload) -> RustBuffer { + return FfiConverterTypeKeyTeleportPayload.lower(value) +} + + + +/** + * Payload type indicator — carried through the route so the import screen + * can display appropriate labels without re-decrypting. + */ + +public enum KeyTeleportPayloadKind: Equatable, Hashable { + + case mnemonic + case xprv + + + + + +} + +#if compiler(>=6) +extension KeyTeleportPayloadKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeKeyTeleportPayloadKind: FfiConverterRustBuffer { + typealias SwiftType = KeyTeleportPayloadKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> KeyTeleportPayloadKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .mnemonic + + case 2: return .xprv + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: KeyTeleportPayloadKind, into buf: inout [UInt8]) { + switch value { + + + case .mnemonic: + writeInt(&buf, Int32(1)) + + + case .xprv: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportPayloadKind_lift(_ buf: RustBuffer) throws -> KeyTeleportPayloadKind { + return try FfiConverterTypeKeyTeleportPayloadKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportPayloadKind_lower(_ value: KeyTeleportPayloadKind) -> RustBuffer { + return FfiConverterTypeKeyTeleportPayloadKind.lower(value) +} + + + + +public enum KeyTeleportReceiveRoute: Equatable, Hashable { + + /** + * Show the receiver QR / BBQr and numeric code. + */ + case showQr + /** + * Scan (or paste) the sender's BBQr packet. + */ + case scanSender + /** + * Enter the teleport password that the sender shared out-of-band. + */ + case enterPassword(senderPacketBbqr: String + ) + /** + * Review the decoded payload before importing as a wallet. + * The actual secret is held in ephemeral container state, not here. + */ + case reviewImport(payloadKind: KeyTeleportPayloadKind + ) + + + + + +} + +#if compiler(>=6) +extension KeyTeleportReceiveRoute: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeKeyTeleportReceiveRoute: FfiConverterRustBuffer { + typealias SwiftType = KeyTeleportReceiveRoute + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> KeyTeleportReceiveRoute { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .showQr + + case 2: return .scanSender + + case 3: return .enterPassword(senderPacketBbqr: try FfiConverterString.read(from: &buf) + ) + + case 4: return .reviewImport(payloadKind: try FfiConverterTypeKeyTeleportPayloadKind.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: KeyTeleportReceiveRoute, into buf: inout [UInt8]) { + switch value { + + + case .showQr: + writeInt(&buf, Int32(1)) + + + case .scanSender: + writeInt(&buf, Int32(2)) + + + case let .enterPassword(senderPacketBbqr): + writeInt(&buf, Int32(3)) + FfiConverterString.write(senderPacketBbqr, into: &buf) + + + case let .reviewImport(payloadKind): + writeInt(&buf, Int32(4)) + FfiConverterTypeKeyTeleportPayloadKind.write(payloadKind, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportReceiveRoute_lift(_ buf: RustBuffer) throws -> KeyTeleportReceiveRoute { + return try FfiConverterTypeKeyTeleportReceiveRoute.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeKeyTeleportReceiveRoute_lower(_ value: KeyTeleportReceiveRoute) -> RustBuffer { + return FfiConverterTypeKeyTeleportReceiveRoute.lower(value) +} + + + public enum LabelDbError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { @@ -23170,6 +23657,11 @@ public enum MultiFormat: Equatable { */ case signedPsbt(Psbt ) + /** + * A Key Teleport sender packet (BBQr `S` type) scanned by the receiver. + */ + case keyTeleportSenderPacket(String + ) @@ -23226,6 +23718,9 @@ public struct FfiConverterTypeMultiFormat: FfiConverterRustBuffer { case 8: return .signedPsbt(try FfiConverterTypePsbt.read(from: &buf) ) + case 9: return .keyTeleportSenderPacket(try FfiConverterString.read(from: &buf) + ) + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -23273,6 +23768,11 @@ public struct FfiConverterTypeMultiFormat: FfiConverterRustBuffer { writeInt(&buf, Int32(8)) FfiConverterTypePsbt.write(v1, into: &buf) + + case let .keyTeleportSenderPacket(v1): + writeInt(&buf, Int32(9)) + FfiConverterString.write(v1, into: &buf) + } } } @@ -23538,6 +24038,8 @@ public enum NewWalletRoute: Equatable, Hashable { ) case coldWallet(ColdWalletRoute ) + case keyTeleportReceive(KeyTeleportReceiveRoute + ) @@ -23567,6 +24069,9 @@ public struct FfiConverterTypeNewWalletRoute: FfiConverterRustBuffer { case 3: return .coldWallet(try FfiConverterTypeColdWalletRoute.read(from: &buf) ) + case 4: return .keyTeleportReceive(try FfiConverterTypeKeyTeleportReceiveRoute.read(from: &buf) + ) + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -23588,6 +24093,11 @@ public struct FfiConverterTypeNewWalletRoute: FfiConverterRustBuffer { writeInt(&buf, Int32(3)) FfiConverterTypeColdWalletRoute.write(v1, into: &buf) + + case let .keyTeleportReceive(v1): + writeInt(&buf, Int32(4)) + FfiConverterTypeKeyTeleportReceiveRoute.write(v1, into: &buf) + } } } @@ -36314,6 +36824,15 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_filehandler_read() != 12343) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_keyteleportreceiversession_decode() != 58164) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_keyteleportreceiversession_numeric_code_display() != 40430) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_cove_checksum_method_keyteleportreceiversession_receiver_packet_bbqr() != 48518) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_labelmanager_delete_labels_for_txn() != 50691) { return InitializationResult.apiChecksumMismatch } @@ -36818,6 +37337,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_routefactory_is_same_parent_route() != 8524) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_routefactory_key_teleport_receive() != 8004) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_routefactory_load_and_reset_nested_to() != 27109) { return InitializationResult.apiChecksumMismatch } @@ -37124,6 +37646,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_constructor_filehandler_new() != 14514) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_constructor_keyteleportreceiversession_new() != 14340) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_constructor_addressargs_new() != 7657) { return InitializationResult.apiChecksumMismatch } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 39c359c9b..8fcdfdb7b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -51,6 +51,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1044,6 +1055,7 @@ dependencies = [ "cove-cspp", "cove-device", "cove-http", + "cove-keyteleport", "cove-macros", "cove-nfc", "cove-tap-card", @@ -1185,6 +1197,25 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "cove-keyteleport" +version = "0.1.0" +dependencies = [ + "aes", + "bbqr", + "bip39", + "bitcoin", + "ctr", + "data-encoding", + "hex", + "hmac", + "pbkdf2", + "rand 0.10.0", + "sha2", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "cove-macros" version = "0.1.0" @@ -1402,6 +1433,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -2916,6 +2956,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b30ea2e21..9e6740356 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -126,6 +126,7 @@ cove-device = { path = "./crates/cove-device" } cove-bdk = { path = "./crates/cove-bdk" } cove-tokio = { path = "./crates/cove-tokio" } cove-http = { path = "./crates/cove-http" } +cove-keyteleport = { path = "./crates/cove-keyteleport" } # bitcoin bitcoin = { workspace = true } diff --git a/rust/crates/cove-keyteleport/Cargo.toml b/rust/crates/cove-keyteleport/Cargo.toml new file mode 100644 index 000000000..c65a2ea5e --- /dev/null +++ b/rust/crates/cove-keyteleport/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cove-keyteleport" +version = "0.1.0" +edition = "2024" + +[dependencies] +# bitcoin / secp256k1 +bitcoin = { workspace = true } +bip39 = { workspace = true } + +# crypto primitives +sha2 = { workspace = true } +hmac = { workspace = true } +rand = { workspace = true } +zeroize = { workspace = true, features = ["derive"] } + +# AES-256-CTR +aes = "0.8" +ctr = "0.9" + +# PBKDF2-SHA512 +pbkdf2 = "0.12" + +# encoding +data-encoding = { workspace = true } +bbqr = { workspace = true } + +# error handling +thiserror = { workspace = true } + +[dev-dependencies] +hex = { workspace = true } diff --git a/rust/crates/cove-keyteleport/src/bbqr.rs b/rust/crates/cove-keyteleport/src/bbqr.rs new file mode 100644 index 000000000..941f98f59 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/bbqr.rs @@ -0,0 +1,136 @@ +/// Minimal BBQr encoder/decoder for Key Teleport packet types (R and S). +/// +/// BBQr format: `B$` +/// For single-frame packets: num_parts=01, part_index=00. +/// Encoding byte `2` = Base32, no compression (as required by the COLDCARD spec). +use bbqr::encode::Encoding; +use data_encoding::BASE32_NOPAD; + +use crate::error::Error; + +/// Key Teleport BBQr file type codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyTeleportFileType { + /// `R` — receiver packet (encrypted pubkey) + Receiver, + /// `S` — sender packet (sender pubkey + encrypted body) + Sender, +} + +impl KeyTeleportFileType { + pub fn as_char(self) -> char { + match self { + KeyTeleportFileType::Receiver => 'R', + KeyTeleportFileType::Sender => 'S', + } + } + + fn from_char(c: char) -> Result { + match c { + 'R' => Ok(KeyTeleportFileType::Receiver), + 'S' => Ok(KeyTeleportFileType::Sender), + other => Err(Error::InvalidBbqr(format!("unknown Key Teleport type: '{other}'"))), + } + } +} + +/// Encode binary data as a single-frame BBQr string with the given Key Teleport file type. +pub fn encode(data: &[u8], file_type: KeyTeleportFileType) -> String { + let b32 = BASE32_NOPAD.encode(data); + // num_parts=01 (1 frame total), part_index=00 (first/only frame) + // Encoding::Base32 corresponds to byte '2' per the BBQr spec + format!("B${}{}0100{}", Encoding::Base32.as_byte() as char, file_type.as_char(), b32) +} + +/// Decode a single-frame BBQr string, returning the file type and binary payload. +/// Multi-frame packets are rejected — higher-level transport code handles reassembly. +pub fn decode(s: &str) -> Result<(KeyTeleportFileType, Vec), Error> { + let s = s.trim().to_uppercase(); + + let rest = + s.strip_prefix("B$").ok_or_else(|| Error::InvalidBbqr("missing 'B$' header".into()))?; + + if rest.len() < 6 { + return Err(Error::InvalidBbqr("too short to be a valid BBQr packet".into())); + } + + let mut chars = rest.chars(); + let encoding_char = chars.next().unwrap(); + let encoding = Encoding::from_byte(encoding_char as u8) + .ok_or_else(|| Error::InvalidBbqr(format!("unknown encoding '{encoding_char}'")))?; + if encoding != Encoding::Base32 { + return Err(Error::InvalidBbqr(format!( + "unsupported encoding '{encoding_char}' (only Base32/'2' is supported)" + ))); + } + + let file_type = KeyTeleportFileType::from_char(chars.next().unwrap())?; + + // num_parts and part_index are 2 uppercase hex chars each + let header_tail: String = chars.take(4).collect(); + if header_tail.len() != 4 { + return Err(Error::InvalidBbqr("truncated header".into())); + } + let num_parts = u8::from_str_radix(&header_tail[0..2], 16) + .map_err(|_| Error::InvalidBbqr("bad num_parts".into()))?; + let part_index = u8::from_str_radix(&header_tail[2..4], 16) + .map_err(|_| Error::InvalidBbqr("bad part_index".into()))?; + + if num_parts != 1 || part_index != 0 { + return Err(Error::InvalidBbqr(format!( + "multi-frame BBQr not supported here (num_parts={num_parts}, part_index={part_index})" + ))); + } + + let b32_data = &s[8..]; // "B$" + encoding + type + 4 header chars = 8 + let data = BASE32_NOPAD + .decode(b32_data.as_bytes()) + .map_err(|e| Error::InvalidBbqr(format!("Base32 decode failed: {e}")))?; + + Ok((file_type, data)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_decode_roundtrip_receiver() { + let data = vec![0xAAu8; 33]; + let encoded = encode(&data, KeyTeleportFileType::Receiver); + assert!(encoded.starts_with("B$2R0100")); + let (ft, decoded) = decode(&encoded).unwrap(); + assert_eq!(ft, KeyTeleportFileType::Receiver); + assert_eq!(decoded, data); + } + + #[test] + fn encode_decode_roundtrip_sender() { + let mut data = vec![0x01u8; 33]; + data.extend_from_slice(&[0xBBu8; 80]); + let encoded = encode(&data, KeyTeleportFileType::Sender); + assert!(encoded.starts_with("B$2S0100")); + let (ft, decoded) = decode(&encoded).unwrap(); + assert_eq!(ft, KeyTeleportFileType::Sender); + assert_eq!(decoded, data); + } + + #[test] + fn decode_known_example() { + // From keyteleport.com: B$2R0100VHT2AGUUH7KUZUUSTOWOIWHJX3XM7GA2N4BHQOXDFHXLVHVA7K6ZO + let s = "B$2R0100VHT2AGUUH7KUZUUSTOWOIWHJX3XM7GA2N4BHQOXDFHXLVHVA7K6ZO"; + let (ft, data) = decode(s).unwrap(); + assert_eq!(ft, KeyTeleportFileType::Receiver); + assert_eq!(data.len(), 33); + } + + #[test] + fn rejects_wrong_header() { + assert!(decode("QR2R0100AAAA").is_err()); + } + + #[test] + fn rejects_unknown_file_type() { + assert!(decode("B$2E0100AAAA").is_err()); + } +} diff --git a/rust/crates/cove-keyteleport/src/crypto.rs b/rust/crates/cove-keyteleport/src/crypto.rs new file mode 100644 index 000000000..30f74c6e1 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/crypto.rs @@ -0,0 +1,121 @@ +use aes::Aes256; +use aes::cipher::{KeyIvInit as _, StreamCipher as _}; +use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; +use ctr::Ctr128BE; +use pbkdf2::pbkdf2_hmac; +use sha2::{Digest as _, Sha256, Sha512}; + +/// Derive the shared session key using ECDH. +/// +/// Per the Key Teleport spec: SHA256(X || Y) of the shared point where X and Y +/// are the full uncompressed coordinates (64 bytes total). +pub(crate) fn session_key(local_privkey: &SecretKey, remote_pubkey: &PublicKey) -> [u8; 32] { + let secp = Secp256k1::new(); + let scalar = Scalar::from_be_bytes(local_privkey.secret_bytes()) + .expect("secret key bytes are always a valid scalar"); + let point = remote_pubkey.mul_tweak(&secp, &scalar).expect("valid EC multiplication"); + // serialize_uncompressed: 04 || X(32) || Y(32) — drop the 04 prefix + let uncompressed = point.serialize_uncompressed(); + Sha256::digest(&uncompressed[1..]).into() +} + +/// AES-256-CTR encrypt or decrypt (same operation — XOR keystream). +/// Zero IV as specified by the Key Teleport protocol. +pub(crate) fn aes256ctr(key: &[u8; 32], data: &[u8]) -> Vec { + let iv = [0u8; 16]; + let mut cipher = Ctr128BE::::new(key.into(), &iv.into()); + let mut out = data.to_vec(); + cipher.apply_keystream(&mut out); + out +} + +/// Derive the AES key used to encrypt/decrypt the receiver's pubkey in the R packet. +/// Key = SHA256(zero-padded 8-digit decimal string of the numeric code). +pub(crate) fn receiver_pubkey_key(numeric_code: u32) -> [u8; 32] { + let code_str = format!("{:08}", numeric_code); + Sha256::digest(code_str.as_bytes()).into() +} + +/// Stretch the teleport password using PBKDF2-SHA512. +/// Per spec: password = session_key, salt = teleport_pass, iter = 5000. +/// Returns the upper 256 bits (first 32 bytes) of the 512-bit output. +pub(crate) fn pbkdf2_stretch(session_key: &[u8; 32], teleport_pass: &[u8]) -> [u8; 32] { + let mut out = [0u8; 64]; + pbkdf2_hmac::(session_key, teleport_pass, 5000, &mut out); + out[..32].try_into().expect("32 bytes from 64-byte output") +} + +/// 2-byte checksum: last 2 bytes of SHA256(data). +pub(crate) fn checksum(data: &[u8]) -> [u8; 2] { + let hash = Sha256::digest(data); + [hash[30], hash[31]] +} + +/// Verify the 2-byte checksum appended to `data_with_checksum`. +/// Returns the payload bytes (without checksum) if valid. +pub(crate) fn verify_checksum(data_with_checksum: &[u8]) -> Option<&[u8]> { + if data_with_checksum.len() < 2 { + return None; + } + let (body, cs) = data_with_checksum.split_at(data_with_checksum.len() - 2); + let expected = checksum(body); + if cs == expected { Some(body) } else { None } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::Secp256k1; + + #[test] + fn session_key_is_symmetric() { + use rand::RngExt as _; + let secp = Secp256k1::new(); + let mut bytes_a = [0u8; 32]; + let mut bytes_b = [0u8; 32]; + rand::rng().fill(&mut bytes_a); + rand::rng().fill(&mut bytes_b); + let sk_a = SecretKey::from_slice(&bytes_a).unwrap(); + let sk_b = SecretKey::from_slice(&bytes_b).unwrap(); + let pk_a = sk_a.public_key(&secp); + let pk_b = sk_b.public_key(&secp); + + let ka = session_key(&sk_a, &pk_b); + let kb = session_key(&sk_b, &pk_a); + assert_eq!(ka, kb, "ECDH must be symmetric"); + } + + #[test] + fn aes256ctr_roundtrip() { + let key = [0x42u8; 32]; + let plain = b"hello key teleport"; + let cipher = aes256ctr(&key, plain); + let recovered = aes256ctr(&key, &cipher); + assert_eq!(recovered, plain); + } + + #[test] + fn checksum_verify_roundtrip() { + let data = b"some payload data"; + let cs = checksum(data); + let mut with_cs = data.to_vec(); + with_cs.extend_from_slice(&cs); + assert_eq!(verify_checksum(&with_cs), Some(data.as_slice())); + } + + #[test] + fn checksum_detects_corruption() { + let data = b"some payload data"; + let cs = checksum(data); + let mut with_cs = data.to_vec(); + with_cs.extend_from_slice(&cs); + with_cs[0] ^= 0xFF; + assert_eq!(verify_checksum(&with_cs), None); + } + + #[test] + fn receiver_pubkey_key_is_deterministic() { + assert_eq!(receiver_pubkey_key(12345678), receiver_pubkey_key(12345678)); + assert_ne!(receiver_pubkey_key(12345678), receiver_pubkey_key(99999999)); + } +} diff --git a/rust/crates/cove-keyteleport/src/error.rs b/rust/crates/cove-keyteleport/src/error.rs new file mode 100644 index 000000000..95edfba62 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/error.rs @@ -0,0 +1,27 @@ +/// All errors that can occur during Key Teleport operations. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("checksum mismatch — wrong key or corrupted data")] + ChecksumMismatch, + + #[error("invalid receiver packet: {0}")] + InvalidReceiverPacket(String), + + #[error("invalid sender packet: {0}")] + InvalidSenderPacket(String), + + #[error("invalid payload: {0}")] + InvalidPayload(String), + + #[error("invalid BBQr: {0}")] + InvalidBbqr(String), + + #[error("secp256k1 error: {0}")] + Secp(String), +} + +impl From for Error { + fn from(e: bitcoin::secp256k1::Error) -> Self { + Error::Secp(e.to_string()) + } +} diff --git a/rust/crates/cove-keyteleport/src/lib.rs b/rust/crates/cove-keyteleport/src/lib.rs new file mode 100644 index 000000000..d245ccf0b --- /dev/null +++ b/rust/crates/cove-keyteleport/src/lib.rs @@ -0,0 +1,139 @@ +//! `cove-keyteleport` — Rust implementation of the Key Teleport cryptographic protocol. +//! +//! Implements the non-multisig (mnemonic / xprv) participant flow: +//! - Receiver generates an R packet + numeric code and can decode incoming S packets. +//! - Sender parses an R packet + numeric code, picks a teleport password, and encrypts +//! a payload into an S packet. +//! +//! No UniFFI surface, no UI, no persistence — pure protocol primitives. +//! +//! # Example +//! ```rust +//! use cove_keyteleport::{ReceiverSession, SenderSession, Payload}; +//! use bip39::Mnemonic; +//! +//! // Receiver side +//! let receiver = ReceiverSession::generate(); +//! let r_packet = receiver.to_packet(); +//! let code = receiver.numeric_code(); +//! +//! // --- out-of-band: share r_packet.to_bbqr() and code --- +//! +//! // Sender side +//! let entropy = [0xABu8; 32]; // 32 bytes of entropy → 24-word mnemonic +//! let mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); +//! let payload = Payload::Mnemonic(mnemonic); +//! let sender = SenderSession::new(&r_packet, code).unwrap(); +//! let s_packet = sender.encrypt(&payload); +//! let teleport_pass = sender.teleport_password().to_string(); +//! +//! // --- out-of-band: share s_packet.to_bbqr() and teleport_pass --- +//! +//! // Receiver decodes +//! let decoded = receiver.decode(&s_packet, &teleport_pass).unwrap(); +//! ``` + +mod bbqr; +mod crypto; +mod error; +mod packet; +mod payload; +mod receiver; +mod sender; + +pub use error::Error; +pub use packet::{ReceiverPacket, SenderPacket}; +pub use payload::Payload; +pub use receiver::ReceiverSession; +pub use sender::SenderSession; + +#[cfg(test)] +mod tests { + use bip39::Mnemonic; + + use super::*; + + fn random_mnemonic(words: usize) -> Mnemonic { + use rand::RngExt as _; + let entropy_len = match words { + 12 => 16, + 18 => 24, + 24 => 32, + _ => panic!("unsupported word count"), + }; + let mut entropy = vec![0u8; entropy_len]; + rand::rng().fill(entropy.as_mut_slice()); + Mnemonic::from_entropy(&entropy).unwrap() + } + + fn roundtrip(payload: Payload) -> Payload { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + let code = receiver.numeric_code(); + + let sender = SenderSession::new(&r_pkt, code).unwrap(); + let s_pkt = sender.encrypt(&payload); + let pass = sender.teleport_password().to_string(); + + receiver.decode(&s_pkt, &pass).unwrap() + } + + #[test] + fn roundtrip_mnemonic_12_words() { + let m = random_mnemonic(12); + let original = m.to_string(); + let decoded = roundtrip(Payload::Mnemonic(m)); + match decoded { + Payload::Mnemonic(m2) => assert_eq!(m2.to_string(), original), + _ => panic!("expected mnemonic"), + } + } + + #[test] + fn roundtrip_mnemonic_24_words() { + let m = random_mnemonic(24); + let original = m.to_string(); + let decoded = roundtrip(Payload::Mnemonic(m)); + match decoded { + Payload::Mnemonic(m2) => assert_eq!(m2.to_string(), original), + _ => panic!("expected mnemonic"), + } + } + + #[test] + fn wrong_teleport_password_fails() { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + + let sender = SenderSession::new(&r_pkt, receiver.numeric_code()).unwrap(); + let m = random_mnemonic(24); + let s_pkt = sender.encrypt(&Payload::Mnemonic(m)); + + let result = receiver.decode(&s_pkt, "WRONGPAS"); + assert_eq!(result.unwrap_err(), Error::ChecksumMismatch); + } + + #[test] + fn bbqr_transport_roundtrip() { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + + // simulate transmission via BBQr strings + let r_bbqr = r_pkt.to_bbqr(); + let r_pkt_parsed = ReceiverPacket::from_bbqr(&r_bbqr).unwrap(); + + let sender = SenderSession::new(&r_pkt_parsed, receiver.numeric_code()).unwrap(); + let m = random_mnemonic(24); + let original = m.to_string(); + let s_pkt = sender.encrypt(&Payload::Mnemonic(m)); + + let s_bbqr = s_pkt.to_bbqr(); + let s_pkt_parsed = SenderPacket::from_bbqr(&s_bbqr).unwrap(); + + let decoded = receiver.decode(&s_pkt_parsed, sender.teleport_password()).unwrap(); + match decoded { + Payload::Mnemonic(m2) => assert_eq!(m2.to_string(), original), + _ => panic!("expected mnemonic"), + } + } +} diff --git a/rust/crates/cove-keyteleport/src/packet.rs b/rust/crates/cove-keyteleport/src/packet.rs new file mode 100644 index 000000000..478f79a52 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/packet.rs @@ -0,0 +1,140 @@ +use bitcoin::secp256k1::PublicKey; + +use crate::bbqr::{self, KeyTeleportFileType}; +use crate::error::Error; + +/// The `R` packet generated by the receiver. +/// Binary layout: 33 bytes = AES-256-CTR(SHA256(8-digit-code), compressed_pubkey). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReceiverPacket { + /// The AES-encrypted compressed public key (33 bytes). + encrypted_pubkey: [u8; 33], +} + +impl ReceiverPacket { + pub(crate) fn new(encrypted_pubkey: [u8; 33]) -> Self { + Self { encrypted_pubkey } + } + + pub(crate) fn encrypted_pubkey(&self) -> &[u8; 33] { + &self.encrypted_pubkey + } + + /// Encode as a single-frame BBQr `R` string. + pub fn to_bbqr(&self) -> String { + bbqr::encode(&self.encrypted_pubkey, KeyTeleportFileType::Receiver) + } + + /// Parse from a BBQr `R` string. + pub fn from_bbqr(s: &str) -> Result { + let (ft, data) = bbqr::decode(s)?; + if ft != KeyTeleportFileType::Receiver { + return Err(Error::InvalidReceiverPacket("expected R-type BBQr".into())); + } + let arr: [u8; 33] = data.try_into().map_err(|_| { + Error::InvalidReceiverPacket("encrypted pubkey must be 33 bytes".into()) + })?; + Ok(Self { encrypted_pubkey: arr }) + } + + /// Parse from raw bytes (33-byte encrypted pubkey). + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; 33] = bytes + .try_into() + .map_err(|_| Error::InvalidReceiverPacket("must be exactly 33 bytes".into()))?; + Ok(Self { encrypted_pubkey: arr }) + } +} + +/// The `S` packet generated by the sender. +/// Binary layout: sender_pubkey (33 bytes) || encrypted_body (variable). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SenderPacket { + sender_pubkey: PublicKey, + encrypted_body: Vec, +} + +impl SenderPacket { + pub(crate) fn new(sender_pubkey: PublicKey, encrypted_body: Vec) -> Self { + Self { sender_pubkey, encrypted_body } + } + + pub(crate) fn sender_pubkey(&self) -> &PublicKey { + &self.sender_pubkey + } + + pub(crate) fn encrypted_body(&self) -> &[u8] { + &self.encrypted_body + } + + /// Encode as a single-frame BBQr `S` string. + pub fn to_bbqr(&self) -> String { + let mut raw = self.sender_pubkey.serialize().to_vec(); + raw.extend_from_slice(&self.encrypted_body); + bbqr::encode(&raw, KeyTeleportFileType::Sender) + } + + /// Parse from a BBQr `S` string. + pub fn from_bbqr(s: &str) -> Result { + let (ft, data) = bbqr::decode(s)?; + if ft != KeyTeleportFileType::Sender { + return Err(Error::InvalidSenderPacket("expected S-type BBQr".into())); + } + Self::from_bytes(&data) + } + + /// Parse from raw bytes: sender_pubkey (33 bytes) || encrypted_body. + pub fn from_bytes(bytes: &[u8]) -> Result { + const COMPRESSED_PUBKEY_LEN: usize = 33; + const MIN_ENCRYPTED_BODY_LEN: usize = 5; // payload type byte + inner checksum + outer checksum + + if bytes.len() < COMPRESSED_PUBKEY_LEN + MIN_ENCRYPTED_BODY_LEN { + return Err(Error::InvalidSenderPacket( + "too short (need sender pubkey plus encrypted body)".into(), + )); + } + let sender_pubkey = PublicKey::from_slice(&bytes[..COMPRESSED_PUBKEY_LEN]) + .map_err(|e| Error::InvalidSenderPacket(format!("bad sender pubkey: {e}")))?; + let encrypted_body = bytes[COMPRESSED_PUBKEY_LEN..].to_vec(); + Ok(Self { sender_pubkey, encrypted_body }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::Secp256k1; + + #[test] + fn receiver_packet_bbqr_roundtrip() { + let encrypted = [0xABu8; 33]; + let pkt = ReceiverPacket::new(encrypted); + let bbqr = pkt.to_bbqr(); + assert!(bbqr.starts_with("B$2R")); + let recovered = ReceiverPacket::from_bbqr(&bbqr).unwrap(); + assert_eq!(pkt, recovered); + } + + #[test] + fn sender_packet_bbqr_roundtrip() { + use rand::RngExt as _; + let secp = Secp256k1::new(); + let mut bytes = [0u8; 32]; + rand::rng().fill(&mut bytes); + let sk = bitcoin::secp256k1::SecretKey::from_slice(&bytes).unwrap(); + let pk = sk.public_key(&secp); + let body = vec![0x11u8; 50]; + let pkt = SenderPacket::new(pk, body); + let bbqr = pkt.to_bbqr(); + assert!(bbqr.starts_with("B$2S")); + let recovered = SenderPacket::from_bbqr(&bbqr).unwrap(); + assert_eq!(pkt, recovered); + } + + #[test] + fn sender_packet_wrong_type_is_error() { + let data = [0xBBu8; 33]; + let r_bbqr = bbqr::encode(&data, KeyTeleportFileType::Receiver); + assert!(SenderPacket::from_bbqr(&r_bbqr).is_err()); + } +} diff --git a/rust/crates/cove-keyteleport/src/payload.rs b/rust/crates/cove-keyteleport/src/payload.rs new file mode 100644 index 000000000..6762d9b68 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/payload.rs @@ -0,0 +1,101 @@ +use bip39::Mnemonic; + +use crate::error::Error; + +/// Type byte prefixes as defined in the Key Teleport spec. +const TYPE_MNEMONIC: u8 = b's'; +const TYPE_XPRV: u8 = b'x'; + +/// A decrypted Key Teleport payload — the secret being transferred. +pub enum Payload { + /// A BIP-39 mnemonic (12 / 18 / 24 words). Type byte `s`. + Mnemonic(Mnemonic), + /// A base58-encoded XPRV (serialised `ExtendedPrivKey`). Type byte `x`. + Xprv(String), +} + +impl Payload { + /// Serialise the payload for encryption: `type_byte || secret_bytes`. + pub(crate) fn to_bytes(&self) -> Vec { + match self { + Payload::Mnemonic(m) => { + // Encode the mnemonic entropy as raw bytes, prefixed with the type byte + let entropy = m.to_entropy(); + let mut out = Vec::with_capacity(1 + entropy.len()); + out.push(TYPE_MNEMONIC); + out.extend_from_slice(&entropy); + out + } + Payload::Xprv(xprv) => { + // base58-decoded binary XPRV (78 bytes), prefixed with the type byte + let decoded = + bitcoin::base58::decode(xprv).unwrap_or_else(|_| xprv.as_bytes().to_vec()); + let mut out = Vec::with_capacity(1 + decoded.len()); + out.push(TYPE_XPRV); + out.extend_from_slice(&decoded); + out + } + } + } + + /// Deserialise from `type_byte || secret_bytes` after decryption. + pub(crate) fn from_bytes(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(Error::InvalidPayload("empty payload".into())); + } + match bytes[0] { + TYPE_MNEMONIC => { + let entropy = &bytes[1..]; + let m = Mnemonic::from_entropy(entropy) + .map_err(|e| Error::InvalidPayload(format!("invalid mnemonic entropy: {e}")))?; + Ok(Payload::Mnemonic(m)) + } + TYPE_XPRV => { + let bin = &bytes[1..]; + let xprv = bitcoin::base58::encode(bin); + Ok(Payload::Xprv(xprv)) + } + other => Err(Error::InvalidPayload(format!("unknown payload type byte 0x{other:02X}"))), + } + } +} + +impl core::fmt::Debug for Payload { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Payload::Mnemonic(_) => f.write_str("Payload::Mnemonic()"), + Payload::Xprv(_) => f.write_str("Payload::Xprv()"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mnemonic_roundtrip() { + let entropy = [0x11u8; 32]; // 32 bytes → 24-word mnemonic + let m = Mnemonic::from_entropy(&entropy).unwrap(); + let p = Payload::Mnemonic(m); + let bytes = p.to_bytes(); + assert_eq!(bytes[0], TYPE_MNEMONIC); + let recovered = Payload::from_bytes(&bytes).unwrap(); + match (p, recovered) { + (Payload::Mnemonic(a), Payload::Mnemonic(b)) => { + assert_eq!(a.to_string(), b.to_string()) + } + _ => panic!("type mismatch"), + } + } + + #[test] + fn unknown_type_byte_is_error() { + assert!(Payload::from_bytes(&[0xFF, 1, 2, 3]).is_err()); + } + + #[test] + fn empty_bytes_is_error() { + assert!(Payload::from_bytes(&[]).is_err()); + } +} diff --git a/rust/crates/cove-keyteleport/src/receiver.rs b/rust/crates/cove-keyteleport/src/receiver.rs new file mode 100644 index 000000000..76c3f5ab2 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/receiver.rs @@ -0,0 +1,126 @@ +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use rand::RngExt as _; +use zeroize::Zeroizing; + +use crate::crypto::{aes256ctr, pbkdf2_stretch, receiver_pubkey_key, session_key, verify_checksum}; +use crate::error::Error; +use crate::packet::{ReceiverPacket, SenderPacket}; +use crate::payload::Payload; + +/// Maximum value for the 8-digit numeric code (inclusive). +const MAX_NUMERIC_CODE: u32 = 99_999_999; + +/// A receiver session: holds the ephemeral EC private key bytes and numeric code. +/// The key bytes are zeroed on drop via `Zeroizing`. +#[derive(Debug)] +pub struct ReceiverSession { + /// Raw 32-byte secret key — kept as bytes so we can zero them on drop. + privkey_bytes: Zeroizing<[u8; 32]>, + /// 8-digit code shown to the receiver, shared out-of-band with the sender. + numeric_code: u32, +} + +impl ReceiverSession { + /// Generate a fresh receiver session (random keypair + random 8-digit code). + pub fn generate() -> Self { + let mut key_bytes = [0u8; 32]; + rand::rng().fill(&mut key_bytes); + // Retry if we somehow hit an invalid scalar (astronomically unlikely) + while SecretKey::from_slice(&key_bytes).is_err() { + rand::rng().fill(&mut key_bytes); + } + + // Rejection sampling to avoid modulo bias (u32::MAX+1 is not divisible by 100_000_000). + let numeric_code = loop { + let v = rand::random::(); + let limit = u32::MAX - (u32::MAX % (MAX_NUMERIC_CODE + 1)); + if v < limit { + break v % (MAX_NUMERIC_CODE + 1); + } + }; + Self { privkey_bytes: Zeroizing::new(key_bytes), numeric_code } + } + + fn privkey(&self) -> SecretKey { + SecretKey::from_slice(&self.privkey_bytes[..]).expect("stored key is always valid") + } + + /// The 8-digit numeric code (raw value). + pub fn numeric_code(&self) -> u32 { + self.numeric_code + } + + /// The numeric code formatted as a zero-padded 8-digit string for display. + pub fn numeric_code_display(&self) -> String { + format!("{:08}", self.numeric_code) + } + + /// Build the `R` packet to share with the sender (via QR / NFC / link). + /// + /// The receiver's compressed pubkey is AES-256-CTR encrypted using a key derived + /// from the numeric code. + pub fn to_packet(&self) -> ReceiverPacket { + let secp = Secp256k1::new(); + let pubkey = self.privkey().public_key(&secp); + let compressed = pubkey.serialize(); // 33 bytes + + let key = receiver_pubkey_key(self.numeric_code); + let encrypted = aes256ctr(&key, &compressed); + let arr: [u8; 33] = encrypted.try_into().expect("33 bytes in, 33 bytes out"); + ReceiverPacket::new(arr) + } + + /// Decode an incoming sender packet using this session's private key and + /// the teleport password supplied by the sender. + /// + /// Decryption flow (per spec): + /// 1. ECDH(privkey, sender_pubkey) → session key + /// 2. AES-CTR(session_key) decrypt → outer plaintext + /// 3. Verify 2-byte checksum on outer plaintext + /// 4. PBKDF2(session_key, teleport_pass) → inner key + /// 5. AES-CTR(inner_key) decrypt → inner plaintext + /// 6. Verify 2-byte checksum on inner plaintext + /// 7. Parse payload type byte + pub fn decode( + &self, + sender_pkt: &SenderPacket, + teleport_password: &str, + ) -> Result { + let sk = session_key(&self.privkey(), sender_pkt.sender_pubkey()); + + // Outer decryption + checksum + let outer_plain = aes256ctr(&sk, sender_pkt.encrypted_body()); + let intermediate = verify_checksum(&outer_plain).ok_or(Error::ChecksumMismatch)?; + + // Inner decryption + checksum + let inner_key = pbkdf2_stretch(&sk, teleport_password.as_bytes()); + let inner_plain = aes256ctr(&inner_key, intermediate); + let payload_bytes = verify_checksum(&inner_plain).ok_or(Error::ChecksumMismatch)?; + + Payload::from_bytes(payload_bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_produces_valid_packet() { + let session = ReceiverSession::generate(); + let pkt = session.to_packet(); + let bbqr = pkt.to_bbqr(); + assert!(bbqr.starts_with("B$2R")); + let recovered = ReceiverPacket::from_bbqr(&bbqr).unwrap(); + assert_eq!(recovered, pkt); + } + + #[test] + fn numeric_code_is_in_range() { + for _ in 0..20 { + let s = ReceiverSession::generate(); + assert!(s.numeric_code() <= MAX_NUMERIC_CODE); + assert_eq!(s.numeric_code_display().len(), 8); + } + } +} diff --git a/rust/crates/cove-keyteleport/src/sender.rs b/rust/crates/cove-keyteleport/src/sender.rs new file mode 100644 index 000000000..7e2058193 --- /dev/null +++ b/rust/crates/cove-keyteleport/src/sender.rs @@ -0,0 +1,127 @@ +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use data_encoding::BASE32_NOPAD; +use rand::RngExt as _; +use zeroize::Zeroizing; + +use crate::crypto::{aes256ctr, checksum, pbkdf2_stretch, receiver_pubkey_key, session_key}; +use crate::error::Error; +use crate::packet::{ReceiverPacket, SenderPacket}; +use crate::payload::Payload; + +/// A sender session: holds the ephemeral private key and derived session state. +/// Key bytes are zeroed on drop via `Zeroizing`. +#[derive(Debug)] +pub struct SenderSession { + privkey_bytes: Zeroizing<[u8; 32]>, + session_key: Zeroizing<[u8; 32]>, + /// 8-character Base32 teleport password shown to the sender, shared out-of-band. + teleport_password: String, +} + +impl SenderSession { + /// Create a sender session from a `ReceiverPacket` and the numeric code. + /// + /// Steps: + /// 1. Decrypt receiver pubkey from R packet using SHA256(numeric_code) + /// 2. Generate ephemeral sender keypair + /// 3. ECDH(sender_privkey, receiver_pubkey) → session key + /// 4. Generate random 5-byte teleport password → Base32 (8 chars) + pub fn new(r_packet: &ReceiverPacket, numeric_code: u32) -> Result { + // Decrypt the receiver's pubkey + let key = receiver_pubkey_key(numeric_code); + let pubkey_bytes = aes256ctr(&key, r_packet.encrypted_pubkey()); + let receiver_pubkey = PublicKey::from_slice(&pubkey_bytes).map_err(|_| { + Error::InvalidReceiverPacket( + "decrypted bytes are not a valid pubkey — wrong numeric code?".into(), + ) + })?; + + // Generate ephemeral keypair from random bytes + let mut key_bytes = [0u8; 32]; + rand::rng().fill(&mut key_bytes); + while SecretKey::from_slice(&key_bytes).is_err() { + rand::rng().fill(&mut key_bytes); + } + let privkey = SecretKey::from_slice(&key_bytes).expect("validated above"); + + // Derive session key + let sk = session_key(&privkey, &receiver_pubkey); + + // Generate teleport password: 5 random bytes → 8 Base32 chars + let mut raw = [0u8; 5]; + rand::rng().fill(&mut raw[..]); + let teleport_password = BASE32_NOPAD.encode(&raw); + + Ok(Self { + privkey_bytes: Zeroizing::new(key_bytes), + session_key: Zeroizing::new(sk), + teleport_password, + }) + } + + fn privkey(&self) -> SecretKey { + SecretKey::from_slice(&self.privkey_bytes[..]).expect("stored key is always valid") + } + + /// The 8-character Base32 teleport password to share with the receiver out-of-band. + pub fn teleport_password(&self) -> &str { + &self.teleport_password + } + + /// Encrypt the payload and produce a `SenderPacket`. + /// + /// Encryption flow (per spec): + /// 1. Serialize payload → inner_plain + /// 2. Append 2-byte checksum → inner_with_cs + /// 3. inner_key = PBKDF2(session_key, teleport_pass) + /// 4. layer2 = AES-CTR(inner_key, inner_with_cs) + /// 5. Append 2-byte checksum to layer2 → outer_with_cs + /// 6. body = AES-CTR(session_key, outer_with_cs) + /// 7. S packet = sender_pubkey (33 bytes) || body + pub fn encrypt(&self, payload: &Payload) -> SenderPacket { + let secp = Secp256k1::new(); + let sender_pubkey = self.privkey().public_key(&secp); + + let inner_plain = payload.to_bytes(); + let inner_cs = checksum(&inner_plain); + let mut inner_with_cs = inner_plain; + inner_with_cs.extend_from_slice(&inner_cs); + + let inner_key = pbkdf2_stretch(&self.session_key, self.teleport_password.as_bytes()); + let layer2 = aes256ctr(&inner_key, &inner_with_cs); + + let outer_cs = checksum(&layer2); + let mut outer_with_cs = layer2; + outer_with_cs.extend_from_slice(&outer_cs); + + let body = aes256ctr(&self.session_key, &outer_with_cs); + + SenderPacket::new(sender_pubkey, body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::receiver::ReceiverSession; + + #[test] + fn teleport_password_is_8_base32_chars() { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + let sender = SenderSession::new(&r_pkt, receiver.numeric_code()).unwrap(); + let pw = sender.teleport_password(); + assert_eq!(pw.len(), 8); + assert!(pw.chars().all(|c| c.is_ascii_alphanumeric())); + } + + #[test] + fn wrong_numeric_code_gives_error() { + let receiver = ReceiverSession::generate(); + let r_pkt = receiver.to_packet(); + let wrong_code = (receiver.numeric_code() + 1) % 100_000_000; + // With overwhelming probability, wrong key → invalid pubkey bytes → error + let result = SenderSession::new(&r_pkt, wrong_code); + assert!(result.is_err()); + } +} diff --git a/rust/src/key_teleport.rs b/rust/src/key_teleport.rs new file mode 100644 index 000000000..440b8b3e8 --- /dev/null +++ b/rust/src/key_teleport.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use cove_keyteleport::{Payload, ReceiverSession, SenderPacket}; + +#[derive(Debug, uniffi::Object)] +pub struct KeyTeleportReceiverSession(ReceiverSession); + +#[uniffi::export] +impl KeyTeleportReceiverSession { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self(ReceiverSession::generate())) + } + + pub fn numeric_code_display(&self) -> String { + self.0.numeric_code_display() + } + + pub fn receiver_packet_bbqr(&self) -> String { + self.0.to_packet().to_bbqr() + } + + pub fn decode( + &self, + sender_packet_bbqr: String, + teleport_password: String, + ) -> Result { + let pkt = SenderPacket::from_bbqr(&sender_packet_bbqr) + .map_err(|e| KeyTeleportError::InvalidSenderPacket(e.to_string()))?; + + let payload = self + .0 + .decode(&pkt, &teleport_password) + .map_err(|e| KeyTeleportError::DecodeFailed(e.to_string()))?; + + Ok(payload.into()) + } +} + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum KeyTeleportPayload { + /// A BIP-39 mnemonic — the word list as a space-separated string. + Mnemonic { words: String }, + /// A serialized XPRV (base58). + Xprv { xprv: String }, +} + +impl From for KeyTeleportPayload { + fn from(payload: Payload) -> Self { + match payload { + Payload::Mnemonic(m) => Self::Mnemonic { words: m.to_string() }, + Payload::Xprv(xprv) => Self::Xprv { xprv }, + } + } +} + +#[derive(Debug, Clone, uniffi::Error, thiserror::Error)] +pub enum KeyTeleportError { + #[error("Invalid sender packet: {0}")] + InvalidSenderPacket(String), + + #[error("Decryption failed — wrong password or code: {0}")] + DecodeFailed(String), +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index aac6c57bc..5f61447b8 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -46,6 +46,7 @@ mod fiat; mod file_handler; mod hardware_export; mod historical_price_service; +mod key_teleport; mod keys; mod label_manager; mod loading_popup; diff --git a/rust/src/multi_format.rs b/rust/src/multi_format.rs index 953e5cf07..5d145da76 100644 --- a/rust/src/multi_format.rs +++ b/rust/src/multi_format.rs @@ -41,6 +41,8 @@ pub enum MultiFormat { TapSignerUnused(Arc), /// A signed but un-finalized PSBT SignedPsbt(Arc), + /// A Key Teleport sender packet (BBQr `S` type) scanned by the receiver. + KeyTeleportSenderPacket(String), } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Error, thiserror::Error)] @@ -105,6 +107,12 @@ impl MultiFormat { return Self::try_from_ur_string(string); } + // Key Teleport sender packet (BBQr S-type): B$2S... + let upper = string.to_ascii_uppercase(); + if upper.starts_with("B$2S") { + return Ok(Self::KeyTeleportSenderPacket(string.to_string())); + } + // try to parse address match AddressWithNetwork::try_new(string) { Ok(address) => return Ok(Self::Address(address.into())), diff --git a/rust/src/router.rs b/rust/src/router.rs index f2a091bd7..1c1427bb6 100644 --- a/rust/src/router.rs +++ b/rust/src/router.rs @@ -35,6 +35,44 @@ pub enum NewWalletRoute { Select, HotWallet(HotWalletRoute), ColdWallet(ColdWalletRoute), + KeyTeleportReceive(KeyTeleportReceiveRoute), +} + +#[derive(Clone, Hash, Eq, PartialEq, Default, uniffi::Enum)] +pub enum KeyTeleportReceiveRoute { + /// Show the receiver QR / BBQr and numeric code. + #[default] + ShowQr, + /// Scan (or paste) the sender's BBQr packet. + ScanSender, + /// Enter the teleport password that the sender shared out-of-band. + EnterPassword { sender_packet_bbqr: String }, + /// Review the decoded payload before importing as a wallet. + /// The actual secret is held in ephemeral container state, not here. + ReviewImport { payload_kind: KeyTeleportPayloadKind }, +} + +impl std::fmt::Debug for KeyTeleportReceiveRoute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ShowQr => write!(f, "ShowQr"), + Self::ScanSender => write!(f, "ScanSender"), + Self::EnterPassword { .. } => { + write!(f, "EnterPassword {{ sender_packet_bbqr: }}") + } + Self::ReviewImport { payload_kind } => { + write!(f, "ReviewImport {{ payload_kind: {payload_kind:?} }}") + } + } + } +} + +/// Payload type indicator — carried through the route so the import screen +/// can display appropriate labels without re-decrypting. +#[derive(Debug, Clone, Hash, Eq, PartialEq, uniffi::Enum)] +pub enum KeyTeleportPayloadKind { + Mnemonic, + Xprv, } #[derive(Debug, Clone, Hash, Eq, PartialEq, Default, uniffi::Enum)] @@ -290,6 +328,10 @@ impl RouteFactory { route.into() } + pub fn key_teleport_receive(&self) -> Route { + Route::NewWallet(NewWalletRoute::KeyTeleportReceive(KeyTeleportReceiveRoute::default())) + } + pub fn qr_import(&self) -> Route { ColdWalletRoute::QrCode.into() }