diff --git a/SECURITY.md b/SECURITY.md index bb14333..73ab172 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -62,7 +62,7 @@ a sync, never a partial one, so no unverified data survives a restart. - The BIP39 mnemonic and derived private keys exist only in process memory after unlock - Private keys are never written to disk, logged, or sent over the network - The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only -- Watch-only wallets (`restore-xpub`) hold no private keys and cannot sign transactions +- Watch-only wallets (`restore-xpub`, or `restore-address` for pure address imports) hold no private keys and cannot sign transactions; `TransactionFactory` only calls `AddKeys`/signs when the account reports a private key, so an address-only import can never produce a signed transaction, only an exportable unsigned PSBT --- @@ -84,7 +84,7 @@ Connections to the indexing server use TOFU (Trust On First Use): the server's T ## Backup -The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets restored from xpub, the private keys must be kept in a separate cold storage device. +The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets (restored from xpub or from a plain address), the private keys must be kept in a separate cold storage device. --- diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs index b970291..ad08441 100644 --- a/src/App/Localization/Loc.cs +++ b/src/App/Localization/Loc.cs @@ -102,6 +102,7 @@ public sealed class Loc ["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"], ["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren", "导入 xpub / xprv"], ["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren", "导入 WIF 密钥"], + ["wiz.importaddress.btn"] = ["Importa indirizzo (sola lettura)", "Import address (watch-only)", "Importar dirección (solo lectura)", "Importer une adresse (lecture seule)", "Importar endereço (somente leitura)", "Adresse importieren (nur lesend)", "导入地址(仅观察)"], ["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen", "打开钱包"], ["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)", "文件密码(未设置则留空)"], ["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen", "打开"], @@ -172,12 +173,24 @@ public sealed class Loc "Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten.", "粘贴一个或多个 WIF 密钥(每行一个)。您可以导入多个密钥,用同一个钱包控制多个地址。"], ["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)", "K… / L… / 5…(每行一个密钥)"], + ["wiz.importaddress.title"] = ["Importa indirizzi in sola lettura", "Import watch-only addresses", "Importar direcciones de solo lectura", "Importer des adresses en lecture seule", "Importar endereços somente leitura", "Nur-Lese-Adressen importieren", "导入仅观察地址"], + ["wiz.importaddress.hint"] = [ + "Incolla uno o più indirizzi (uno per riga). Nessuna chiave privata è coinvolta: potrai vedere saldo e cronologia ma non potrai mai firmare o inviare transazioni da questo wallet.", + "Paste one or more addresses (one per line). No private key is involved: you'll be able to see the balance and history but you can never sign or send transactions from this wallet.", + "Pega una o más direcciones (una por línea). No hay ninguna clave privada involucrada: podrás ver el saldo y el historial, pero nunca podrás firmar ni enviar transacciones desde este wallet.", + "Collez une ou plusieurs adresses (une par ligne). Aucune clé privée n'est impliquée : vous pourrez voir le solde et l'historique, mais vous ne pourrez jamais signer ni envoyer de transactions depuis ce wallet.", + "Cole um ou mais endereços (um por linha). Nenhuma chave privada está envolvida: você poderá ver o saldo e o histórico, mas nunca poderá assinar ou enviar transações a partir desta carteira.", + "Fügen Sie eine oder mehrere Adressen ein (eine pro Zeile). Es ist kein privater Schlüssel beteiligt: Sie können den Kontostand und den Verlauf sehen, aber nie Transaktionen aus diesem Wallet signieren oder senden.", + "粘贴一个或多个地址(每行一个)。不涉及任何私钥:您可以查看余额和历史记录,但永远无法从此钱包签名或发送交易。"], + ["wiz.importaddress.placeholder"] = ["Indirizzo… (uno per riga)", "Address… (one per line)", "Dirección… (una por línea)", "Adresse… (une par ligne)", "Endereço… (um por linha)", "Adresse… (eine pro Zeile)", "地址…(每行一个)"], // Import error messages ["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante).", "请粘贴扩展密钥(xpub/xprv 或其变体)。"], ["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt.", "此网络无法识别该扩展密钥。"], ["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein.", "请至少粘贴一个 WIF 密钥。"], ["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig.", "该 WIF 密钥对此网络无效。"], + ["msg.address.required"] = ["Incolla almeno un indirizzo.", "Paste at least one address.", "Pega al menos una dirección.", "Collez au moins une adresse.", "Cole pelo menos um endereço.", "Fügen Sie mindestens eine Adresse ein.", "请至少粘贴一个地址。"], + ["msg.address.invalid"] = ["Indirizzo non valido per questa rete.", "Address not valid for this network.", "Dirección no válida para esta red.", "Adresse invalide pour ce réseau.", "Endereço inválido para esta rede.", "Adresse für dieses Netzwerk ungültig.", "该地址对此网络无效。"], // Wallet panel ["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"], @@ -262,6 +275,10 @@ public sealed class Loc ["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger", "收款人"], ["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr", "金额与手续费"], ["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung", "摘要"], + ["send.psbt.label"] = ["PSBT non firmata (base64) — da firmare altrove", "Unsigned PSBT (base64) — sign it elsewhere", "PSBT sin firmar (base64) — fírmala en otro lugar", "PSBT non signée (base64) — à signer ailleurs", "PSBT não assinada (base64) — assine em outro lugar", "Unsignierte PSBT (base64) — anderswo signieren", "未签名 PSBT(base64)— 请在别处签名"], + ["send.psbt.copy"] = ["Copia PSBT", "Copy PSBT", "Copiar PSBT", "Copier la PSBT", "Copiar PSBT", "PSBT kopieren", "复制 PSBT"], + ["send.watchonly.hint"] = ["Wallet in sola lettura: non può firmare. Esporta la PSBT e firmala con un wallet che possiede le chiavi.", "Watch-only wallet: it cannot sign. Export the PSBT and sign it with a wallet that holds the keys.", "Wallet de solo lectura: no puede firmar. Exporta la PSBT y fírmala con un wallet que tenga las claves.", "Wallet en lecture seule : il ne peut pas signer. Exportez la PSBT et signez-la avec un wallet possédant les clés.", "Carteira somente leitura: não pode assinar. Exporte a PSBT e assine-a com uma carteira que tenha as chaves.", "Nur-Lese-Wallet: kann nicht signieren. Exportieren Sie die PSBT und signieren Sie sie mit einem Wallet, das die Schlüssel besitzt.", "仅观察钱包:无法签名。请导出 PSBT 并用持有密钥的钱包签名。"], + ["psbt.copied"] = ["PSBT copiata negli appunti", "PSBT copied to clipboard", "PSBT copiada al portapapeles", "PSBT copiée dans le presse-papiers", "PSBT copiada para a área de transferência", "PSBT in die Zwischenablage kopiert", "PSBT 已复制到剪贴板"], ["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse", "您的地址"], // Wallet info overlay diff --git a/src/App/ViewModels/MainWindowViewModel.Send.cs b/src/App/ViewModels/MainWindowViewModel.Send.cs index 22dc582..40c7610 100644 --- a/src/App/ViewModels/MainWindowViewModel.Send.cs +++ b/src/App/ViewModels/MainWindowViewModel.Send.cs @@ -31,6 +31,14 @@ public partial class MainWindowViewModel [ObservableProperty] private bool hasPendingSend; + [ObservableProperty] + private string pendingPsbtBase64 = ""; + + /// True when the open account cannot sign — Send only prepares an unsigned PSBT to export. + public bool IsWatchOnlyAccount => _account?.IsWatchOnly ?? false; + + public void NotifyPsbtCopied() => StatusMessage = Loc.Tr("psbt.copied"); + [RelayCommand] private async Task ScanQr() { @@ -81,11 +89,13 @@ public partial class MainWindowViewModel $"({_pendingSend.Transaction.GetVirtualSize()} vB)" + (_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly")); HasPendingSend = _pendingSend.Signed; + PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64(); } catch (Exception ex) { _pendingSend = null; HasPendingSend = false; + PendingPsbtBase64 = ""; SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}"; } await Task.CompletedTask; @@ -103,6 +113,7 @@ public partial class MainWindowViewModel SendTo = SendAmount = ""; _pendingSend = null; HasPendingSend = false; + PendingPsbtBase64 = ""; await ConnectAndSync(); } catch (Exception ex) diff --git a/src/App/ViewModels/MainWindowViewModel.Wizard.cs b/src/App/ViewModels/MainWindowViewModel.Wizard.cs index 927c757..acbff27 100644 --- a/src/App/ViewModels/MainWindowViewModel.Wizard.cs +++ b/src/App/ViewModels/MainWindowViewModel.Wizard.cs @@ -27,9 +27,10 @@ public partial class MainWindowViewModel public const string StepScriptType = "script-type"; public const string StepImportXkey = "import-xkey"; public const string StepImportWif = "import-wif"; + public const string StepImportAddress = "import-address"; public const string StepPassword = "password"; - private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif } + private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif, ImportAddress } private WizardFlowKind _wizardFlow; [ObservableProperty] @@ -44,6 +45,7 @@ public partial class MainWindowViewModel [NotifyPropertyChangedFor(nameof(IsStepScriptType))] [NotifyPropertyChangedFor(nameof(IsStepImportXkey))] [NotifyPropertyChangedFor(nameof(IsStepImportWif))] + [NotifyPropertyChangedFor(nameof(IsStepImportAddress))] [NotifyPropertyChangedFor(nameof(IsStepPassword))] private string setupStep = StepStart; @@ -56,9 +58,10 @@ public partial class MainWindowViewModel public bool IsStepWords => SetupStep == StepWords; public bool IsStepPassphrase => SetupStep == StepPassphrase; public bool IsStepScriptType => SetupStep == StepScriptType; - public bool IsStepImportXkey => SetupStep == StepImportXkey; - public bool IsStepImportWif => SetupStep == StepImportWif; - public bool IsStepPassword => SetupStep == StepPassword; + public bool IsStepImportXkey => SetupStep == StepImportXkey; + public bool IsStepImportWif => SetupStep == StepImportWif; + public bool IsStepImportAddress => SetupStep == StepImportAddress; + public bool IsStepPassword => SetupStep == StepPassword; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsLegacySelected))] @@ -85,6 +88,9 @@ public partial class MainWindowViewModel [ObservableProperty] private string importWifInput = ""; + [ObservableProperty] + private string importAddressInput = ""; + // Script type detected during xkey decoding (to display to the user) [ObservableProperty] private string importXkeyDetectedKind = ""; @@ -214,6 +220,15 @@ public partial class MainWindowViewModel StatusMessage = ""; } + [RelayCommand] + private void WizardStartImportAddress() + { + _wizardFlow = WizardFlowKind.ImportAddress; + ImportAddressInput = ""; + SetupStep = StepImportAddress; + StatusMessage = ""; + } + [RelayCommand] private void WizardNextFromShowSeed() { @@ -312,6 +327,35 @@ public partial class MainWindowViewModel StatusMessage = ""; } + [RelayCommand] + private void WizardNextFromImportAddress() + { + var addresses = ImportAddressInput.Split('\n', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (addresses.Length == 0) + { + StatusMessage = Loc.Tr("msg.address.required"); + return; + } + var network = PalladiumNetworks.For(Net); + foreach (var a in addresses) + { + try + { + _ = NBitcoin.BitcoinAddress.Create(a, network); + } + catch + { + StatusMessage = Loc.Tr("msg.address.invalid"); + return; + } + } + PasswordInput = ConfirmPasswordInput = ""; + EncryptWallet = true; + SetupStep = StepPassword; + StatusMessage = ""; + } + [RelayCommand] private void WizardNextFromScriptType() { @@ -327,11 +371,16 @@ public partial class MainWindowViewModel SetupStep = SetupStep switch { StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart, - StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart, + StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif or StepImportAddress => StepStart, StepConfirmSeed => StepShowSeed, StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed, StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase, - StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType, + StepPassword => _wizardFlow switch + { + WizardFlowKind.ImportXkey => StepImportXkey, + WizardFlowKind.ImportAddress => StepImportAddress, + _ => StepScriptType, + }, _ => StepStart, }; if (SetupStep == StepStart) @@ -391,6 +440,14 @@ public partial class MainWindowViewModel (doc, account) = (d, a); break; } + case WizardFlowKind.ImportAddress: + { + var addressLines = ImportAddressInput.Split('\n', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var (d, a) = WalletLoader.NewFromAddresses(addressLines, Profile); + (doc, account) = (d, a); + break; + } default: { var (d, a) = WalletLoader.NewFromMnemonic( @@ -497,13 +554,14 @@ public partial class MainWindowViewModel _walletPath = path; _password = password; MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = ""; - ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = ""; + ImportXkeyInput = ImportWifInput = ImportAddressInput = ImportXkeyDetectedKind = ""; SetupStep = StepStart; + OnPropertyChanged(nameof(IsWatchOnlyAccount)); var walletKindTag = account switch { - ImportedKeyAccount => " · imported", { IsWatchOnly: true } => " · watch-only", + ImportedKeyAccount => " · imported", _ => "" }; var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : ""; diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs index 4665ff1..b538981 100644 --- a/src/App/ViewModels/MainWindowViewModel.cs +++ b/src/App/ViewModels/MainWindowViewModel.cs @@ -201,6 +201,8 @@ public partial class MainWindowViewModel : ViewModelBase _lastTransactions = null; _pendingSend = null; HasPendingSend = false; + PendingPsbtBase64 = ""; + OnPropertyChanged(nameof(IsWatchOnlyAccount)); History.Clear(); Contacts.Clear(); SelectedContactInList = null; diff --git a/src/App/Views/MainView.axaml b/src/App/Views/MainView.axaml index 9a0cada..bdd815b 100644 --- a/src/App/Views/MainView.axaml +++ b/src/App/Views/MainView.axaml @@ -99,6 +99,9 @@