From 52ad130b008c7782d4ff660ff23088da97c44be2 Mon Sep 17 00:00:00 2001 From: David Gershony <14833917+DavidGershony@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:15:08 +0100 Subject: [PATCH] feat: encrypted seed export to Nostr + Blossom (Angor.Sdk.WalletExport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in cloud backup of the wallet seed under a user-chosen Recovery Passphrase. Two-layer envelope: inner = AES-256-GCM keyed by Argon2id(passphrase) → HKDF outer = NIP-44 v2 self-encryption to a passphrase-derived backup npub The encrypted blob is uploaded to ≥2 of 4 Blossom servers (BUD-02 kind-24242 auth signed by the passphrase-derived nsec, content verified by SHA-256). A kind 30078 manifest event under the same npub makes the backup discoverable from the passphrase alone — no account, no username. Recovery: passphrase → derive npub → query relay for manifest → fetch blob from any healthy Blossom server → AEAD-decrypt seed. SDK: - Angor.Sdk.WalletExport.Crypto: Argon2id, AES-GCM, HKDF-derived secp256k1 backup keys, NIP-44 self-ECDH envelope. Secrets zeroed on dispose. - Angor.Sdk.WalletExport.Blossom: BUD-02 upload + content-hash verified download + HEAD existence probe. - CloudBackupService / BackupRecoveryService: orchestration. - WalletCloudBackupService: persists CloudBackupRecord onto EncryptedWallet for status + passive health checks. Tests: - 25 unit tests cover KDF determinism + NFC, AEAD tamper rejection + AAD enforcement, key-triple derivation + domain separation + zero-on-dispose, and full two-layer roundtrip including wrong-passphrase + unrelated-keys rejection. UI (design app): - Settings → new Cloud Backup card with enable/refresh/verify/ disable actions. ViewModel surface is ready; passphrase-entry modal XAML is a follow-up (existing wipe-data modal is the template). DI wired in both Avalonia and design composition roots; webapp NetworkConfiguration extended with GetDefaultBackupServerUrls(). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Directory.Packages.props | 1 + .../AngorApp/Composition/CompositionRoot.cs | 1 + .../Registrations/Services/CloudBackup.cs | 20 ++ src/design/App/Composition/CompositionRoot.cs | 6 + .../UI/Sections/Settings/SettingsView.axaml | 130 +++++++++ .../Sections/Settings/SettingsView.axaml.cs | 27 ++ .../Settings/SettingsViewModel.CloudBackup.cs | 256 ++++++++++++++++++ .../UI/Sections/Settings/SettingsViewModel.cs | 11 + .../WalletExport/AeadCipherTests.cs | 96 +++++++ .../WalletExport/Argon2idKdfTests.cs | 54 ++++ .../WalletExport/BackupEnvelopeTests.cs | 112 ++++++++ .../WalletExport/BackupKeysTests.cs | 78 ++++++ src/sdk/Angor.Sdk/Angor.Sdk.csproj | 1 + .../Angor.Sdk/Common/NetworkConfiguration.cs | 12 + .../Infrastructure/Impl/EncryptedWallet.cs | 12 +- .../Angor.Sdk/WalletExport/BackupManifest.cs | 35 +++ .../WalletExport/BackupRecoveryService.cs | 89 ++++++ .../Blossom/BackupBlossomClient.cs | 192 +++++++++++++ .../Blossom/IBackupBlossomClient.cs | 36 +++ .../WalletExport/CloudBackupModels.cs | 54 ++++ .../WalletExport/CloudBackupService.cs | 148 ++++++++++ .../WalletExport/Crypto/AeadCipher.cs | 66 +++++ .../WalletExport/Crypto/Argon2idKdf.cs | 71 +++++ .../WalletExport/Crypto/BackupEnvelope.cs | 102 +++++++ .../WalletExport/Crypto/BackupKeys.cs | 108 ++++++++ .../WalletExport/IBackupRecoveryService.cs | 16 ++ .../WalletExport/ICloudBackupService.cs | 23 ++ .../WalletExport/IWalletCloudBackupService.cs | 40 +++ .../WalletExport/WalletCloudBackupService.cs | 176 ++++++++++++ .../Angor.Shared/INetworkConfiguration.cs | 1 + .../Angor.Client/NetworkConfiguration.cs | 12 + 31 files changed, 1985 insertions(+), 1 deletion(-) create mode 100644 src/avalonia/AngorApp/Composition/Registrations/Services/CloudBackup.cs create mode 100644 src/design/App/UI/Sections/Settings/SettingsViewModel.CloudBackup.cs create mode 100644 src/sdk/Angor.Sdk.Tests/WalletExport/AeadCipherTests.cs create mode 100644 src/sdk/Angor.Sdk.Tests/WalletExport/Argon2idKdfTests.cs create mode 100644 src/sdk/Angor.Sdk.Tests/WalletExport/BackupEnvelopeTests.cs create mode 100644 src/sdk/Angor.Sdk.Tests/WalletExport/BackupKeysTests.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/BackupManifest.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/BackupRecoveryService.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/Blossom/BackupBlossomClient.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/Blossom/IBackupBlossomClient.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/CloudBackupModels.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/CloudBackupService.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/Crypto/AeadCipher.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/Crypto/Argon2idKdf.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/Crypto/BackupEnvelope.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/Crypto/BackupKeys.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/IBackupRecoveryService.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/ICloudBackupService.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/IWalletCloudBackupService.cs create mode 100644 src/sdk/Angor.Sdk/WalletExport/WalletCloudBackupService.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index d104826e8..e9754781f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -51,6 +51,7 @@ + diff --git a/src/avalonia/AngorApp/Composition/CompositionRoot.cs b/src/avalonia/AngorApp/Composition/CompositionRoot.cs index 458e98166..645318941 100644 --- a/src/avalonia/AngorApp/Composition/CompositionRoot.cs +++ b/src/avalonia/AngorApp/Composition/CompositionRoot.cs @@ -54,6 +54,7 @@ public static IShellViewModel CreateMainViewModel(Control topLevelView, string p .AddUIServices(topLevelView, profileContext, applicationStorage); services.AddSecurityContext(); + services.AddCloudBackup(); RegisterWalletServices(services, logger, network); FundingContextServices.Register(services, logger); diff --git a/src/avalonia/AngorApp/Composition/Registrations/Services/CloudBackup.cs b/src/avalonia/AngorApp/Composition/Registrations/Services/CloudBackup.cs new file mode 100644 index 000000000..f0fe012c1 --- /dev/null +++ b/src/avalonia/AngorApp/Composition/Registrations/Services/CloudBackup.cs @@ -0,0 +1,20 @@ +using Angor.Sdk.WalletExport; +using Angor.Sdk.WalletExport.Blossom; +using Microsoft.Extensions.DependencyInjection; + +namespace AngorApp.Composition.Registrations.Services; + +/// +/// Wires the encrypted-seed cloud-backup feature (NIP-44 outer + AES-GCM inner, Argon2id KDF, Blossom blobs). +/// +public static class CloudBackup +{ + public static IServiceCollection AddCloudBackup(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/design/App/Composition/CompositionRoot.cs b/src/design/App/Composition/CompositionRoot.cs index 29f8c28b1..0a665c5e0 100644 --- a/src/design/App/Composition/CompositionRoot.cs +++ b/src/design/App/Composition/CompositionRoot.cs @@ -158,6 +158,12 @@ public static IServiceProvider BuildServiceProvider(string profileName = "Defaul services.AddSingleton(); services.AddSingleton(); + // Cloud seed-backup feature (NIP-44 outer + AES-GCM inner over Blossom blobs) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // ── Shared singletons (replaces SharedViewModels static class) ── services.AddSingleton(); services.AddSingleton(); diff --git a/src/design/App/UI/Sections/Settings/SettingsView.axaml b/src/design/App/UI/Sections/Settings/SettingsView.axaml index e1ed0f55e..0c7f1af58 100644 --- a/src/design/App/UI/Sections/Settings/SettingsView.axaml +++ b/src/design/App/UI/Sections/Settings/SettingsView.axaml @@ -603,6 +603,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/design/App/UI/Sections/Settings/SettingsView.axaml.cs b/src/design/App/UI/Sections/Settings/SettingsView.axaml.cs index aa7a91685..15d86deee 100644 --- a/src/design/App/UI/Sections/Settings/SettingsView.axaml.cs +++ b/src/design/App/UI/Sections/Settings/SettingsView.axaml.cs @@ -375,4 +375,31 @@ private void OnModalBackdropPressed(object? sender, PointerPressedEventArgs e) = private void OnModalContentPressed(object? sender, PointerPressedEventArgs e) => e.Handled = true; // Prevent backdrop close when clicking modal content + + // ── Cloud Backup (passphrase-entry modal is a follow-up; for now Enable opens a TopLevel dialog) ── + private async void OnEnableCloudBackupClick(object? sender, RoutedEventArgs e) + { + if (Vm == null) return; + // Minimal first-pass: open the enable-backup modal (XAML for the modal still to be added). + // The ViewModel.ConfirmEnableBackupAsync covers the validation + crypto + relay/Blossom calls. + Vm.OpenEnableBackupModal(); + } + + private async void OnVerifyCloudBackupClick(object? sender, RoutedEventArgs e) + { + try { if (Vm != null) await Vm.VerifyBackupHealthAsync(); } + catch (Exception ex) { _logger.LogWarning(ex, "OnVerifyCloudBackupClick failed"); } + } + + private async void OnRefreshCloudBackupClick(object? sender, RoutedEventArgs e) + { + if (Vm == null) return; + Vm.OpenRefreshBackupModal(); + } + + private async void OnDisableCloudBackupClick(object? sender, RoutedEventArgs e) + { + try { if (Vm != null) await Vm.DisableBackupAsync(); } + catch (Exception ex) { _logger.LogWarning(ex, "OnDisableCloudBackupClick failed"); } + } } diff --git a/src/design/App/UI/Sections/Settings/SettingsViewModel.CloudBackup.cs b/src/design/App/UI/Sections/Settings/SettingsViewModel.CloudBackup.cs new file mode 100644 index 000000000..a7b3046dc --- /dev/null +++ b/src/design/App/UI/Sections/Settings/SettingsViewModel.CloudBackup.cs @@ -0,0 +1,256 @@ +using Angor.Sdk.WalletExport; +using Angor.Sdk.Common; +using CSharpFunctionalExtensions; +using Microsoft.Extensions.Logging; + +namespace App.UI.Sections.Settings; + +/// +/// Cloud-backup section of the Settings page. +/// Wraps and into the +/// existing settings UI. The recovery passphrase is buffered as string here purely because +/// Avalonia text-box bindings work on strings; the actual key derivation in +/// re-encodes to UTF-8 bytes and zeroes them immediately after Argon2id. +/// +public partial class SettingsViewModel +{ + // ── Reactive state ── + [Reactive] private bool isCloudBackupEnabled; + [Reactive] private string? cloudBackupBlobSha256; + [Reactive] private int cloudBackupServersHealthy; + [Reactive] private int cloudBackupServersTotal; + [Reactive] private long? cloudBackupLastVerifiedUnix; + [Reactive] private bool isCloudBackupBusy; + [Reactive] private string? cloudBackupStatusMessage; + + // ── Modal state ── + [Reactive] private bool isEnableBackupModalOpen; + [Reactive] private string enablePassphrase = string.Empty; + [Reactive] private string enablePassphraseConfirm = string.Empty; + [Reactive] private string enableLabel = string.Empty; + [Reactive] private bool enableHasWrittenItDown; + [Reactive] private bool enableUnderstandsRisk; + [Reactive] private bool enableUnderstandsDistinctFromWalletPassword; + + [Reactive] private bool isRefreshBackupModalOpen; + [Reactive] private string refreshPassphrase = string.Empty; + + /// + /// True when the Enable wizard's Continue button should be active. + /// + public bool CanContinueEnable => + !IsCloudBackupBusy + && !string.IsNullOrWhiteSpace(EnablePassphrase) + && EnablePassphrase.Length >= 12 + && EnablePassphrase == EnablePassphraseConfirm + && EnableHasWrittenItDown + && EnableUnderstandsRisk + && EnableUnderstandsDistinctFromWalletPassword; + + /// + /// Load current backup state from the SDK. Safe to call any time. + /// + public async Task RefreshCloudBackupStatusAsync() + { + var activeWallet = _walletContext.SelectedWallet; + if (activeWallet is null) + { + IsCloudBackupEnabled = false; + return; + } + + var walletId = activeWallet.Id; + var statusResult = await _cloudBackupService.GetStatus(walletId); + if (statusResult.IsFailure) + { + _logger.LogWarning("Failed to load cloud backup status: {Error}", statusResult.Error); + return; + } + + if (statusResult.Value.HasNoValue) + { + IsCloudBackupEnabled = false; + CloudBackupBlobSha256 = null; + CloudBackupServersHealthy = 0; + CloudBackupServersTotal = 0; + CloudBackupLastVerifiedUnix = null; + return; + } + + var record = statusResult.Value.Value; + IsCloudBackupEnabled = true; + CloudBackupBlobSha256 = record.BlobSha256; + CloudBackupServersTotal = record.Servers.Count; + CloudBackupServersHealthy = record.ServerHealth.Count(kv => kv.Value); + CloudBackupLastVerifiedUnix = record.LastVerifiedAtUnix; + } + + public void OpenEnableBackupModal() + { + EnablePassphrase = string.Empty; + EnablePassphraseConfirm = string.Empty; + EnableLabel = string.Empty; + EnableHasWrittenItDown = false; + EnableUnderstandsRisk = false; + EnableUnderstandsDistinctFromWalletPassword = false; + CloudBackupStatusMessage = null; + IsEnableBackupModalOpen = true; + } + + public void CloseEnableBackupModal() + { + IsEnableBackupModalOpen = false; + EnablePassphrase = string.Empty; + EnablePassphraseConfirm = string.Empty; + } + + public async Task ConfirmEnableBackupAsync() + { + if (!CanContinueEnable) + return; + + var activeWallet = _walletContext.SelectedWallet; + if (activeWallet is null) + { + ToastRequested?.Invoke("No wallet selected."); + return; + } + + IsCloudBackupBusy = true; + CloudBackupStatusMessage = "Encrypting seed and uploading to Blossom servers…"; + + try + { + var walletId = activeWallet.Id; + var result = await _cloudBackupService.EnableAsync(walletId, EnablePassphrase, EnableLabel); + if (result.IsFailure) + { + _logger.LogWarning("Cloud backup enable failed: {Error}", result.Error); + CloudBackupStatusMessage = $"Backup failed: {result.Error}"; + ToastRequested?.Invoke("Backup failed."); + return; + } + + var created = result.Value; + CloudBackupStatusMessage = $"Backup live on {created.UploadedToServers.Count} servers."; + ToastRequested?.Invoke($"Cloud backup enabled ({created.UploadedToServers.Count} servers)."); + IsEnableBackupModalOpen = false; + await RefreshCloudBackupStatusAsync(); + } + finally + { + // Zero passphrase buffers immediately + EnablePassphrase = string.Empty; + EnablePassphraseConfirm = string.Empty; + IsCloudBackupBusy = false; + } + } + + public async Task DisableBackupAsync() + { + var activeWallet = _walletContext.SelectedWallet; + if (activeWallet is null) return; + + IsCloudBackupBusy = true; + try + { + var walletId = activeWallet.Id; + var result = await _cloudBackupService.DisableAsync(walletId); + if (result.IsFailure) + { + ToastRequested?.Invoke($"Failed to disable backup: {result.Error}"); + return; + } + + ToastRequested?.Invoke("Cloud backup disabled locally. The blob will be pruned naturally."); + await RefreshCloudBackupStatusAsync(); + } + finally + { + IsCloudBackupBusy = false; + } + } + + public async Task VerifyBackupHealthAsync() + { + var activeWallet = _walletContext.SelectedWallet; + if (activeWallet is null) return; + + IsCloudBackupBusy = true; + CloudBackupStatusMessage = "Checking blob availability…"; + try + { + var walletId = activeWallet.Id; + var result = await _cloudBackupService.VerifyHealthAsync(walletId); + if (result.IsFailure) + { + CloudBackupStatusMessage = $"Health check failed: {result.Error}"; + return; + } + CloudBackupStatusMessage = $"Reachable on {result.Value.ServersReachable} of {result.Value.ServersChecked} servers."; + await RefreshCloudBackupStatusAsync(); + } + finally + { + IsCloudBackupBusy = false; + } + } + + public void OpenRefreshBackupModal() + { + RefreshPassphrase = string.Empty; + IsRefreshBackupModalOpen = true; + } + + public void CloseRefreshBackupModal() + { + IsRefreshBackupModalOpen = false; + RefreshPassphrase = string.Empty; + } + + public async Task ConfirmRefreshBackupAsync() + { + var activeWallet = _walletContext.SelectedWallet; + if (activeWallet is null) return; + + if (string.IsNullOrWhiteSpace(RefreshPassphrase)) + { + ToastRequested?.Invoke("Recovery passphrase is required."); + return; + } + + IsCloudBackupBusy = true; + CloudBackupStatusMessage = "Re-uploading and re-publishing…"; + try + { + var walletId = activeWallet.Id; + var result = await _cloudBackupService.RefreshAsync(walletId, RefreshPassphrase); + if (result.IsFailure) + { + CloudBackupStatusMessage = $"Refresh failed: {result.Error}"; + ToastRequested?.Invoke("Refresh failed — passphrase may be incorrect."); + return; + } + + CloudBackupStatusMessage = $"Refreshed across {result.Value.UploadedToServers.Count} servers."; + ToastRequested?.Invoke("Backup refreshed."); + IsRefreshBackupModalOpen = false; + await RefreshCloudBackupStatusAsync(); + } + finally + { + RefreshPassphrase = string.Empty; + IsCloudBackupBusy = false; + } + } + + /// + /// Preview a recovery attempt — fetches the manifest + blob, decrypts the seed, but does not + /// import it into a new wallet. Used to let the user confirm they will be able to recover before + /// they commit to a destructive action (wipe / fresh install). + /// + public async Task> PreviewRecoveryAsync(string recoveryPassphrase) + { + return await _backupRecoveryService.RecoverAsync(recoveryPassphrase); + } +} diff --git a/src/design/App/UI/Sections/Settings/SettingsViewModel.cs b/src/design/App/UI/Sections/Settings/SettingsViewModel.cs index 5ddb6e228..9c75d817d 100644 --- a/src/design/App/UI/Sections/Settings/SettingsViewModel.cs +++ b/src/design/App/UI/Sections/Settings/SettingsViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using Angor.Sdk.WalletExport; using Angor.Sdk.Common; using Angor.Sdk.Funding.Services; using Angor.Sdk.Wallet.Application; @@ -33,6 +34,8 @@ public partial class SettingsViewModel : ReactiveObject, IDisposable private readonly SignatureStore _signatureStore; private readonly ShellViewModel _shellViewModel; private readonly ILogger _logger; + private readonly IWalletCloudBackupService _cloudBackupService; + private readonly IBackupRecoveryService _backupRecoveryService; private readonly CompositeDisposable _disposables = new(); public string AppVersion { get; } = GetVersion(); @@ -147,6 +150,8 @@ public SettingsViewModel( PortfolioViewModel portfolioViewModel, SignatureStore signatureStore, ShellViewModel shellViewModel, + IWalletCloudBackupService cloudBackupService, + IBackupRecoveryService backupRecoveryService, ILogger logger) { _networkService = networkService; @@ -161,6 +166,8 @@ public SettingsViewModel( _portfolioViewModel = portfolioViewModel; _signatureStore = signatureStore; _shellViewModel = shellViewModel; + _cloudBackupService = cloudBackupService; + _backupRecoveryService = backupRecoveryService; _logger = logger; // Initialize currency display from the network configuration @@ -192,6 +199,10 @@ public SettingsViewModel( this.WhenAnyValue(x => x.IsExportingLogs) .Subscribe(_ => this.RaisePropertyChanged(nameof(CanExportLogs))) .DisposeWith(_disposables); + + // Surface cloud-backup state in the Settings card as soon as the page is constructed. + // Fire-and-forget: failures are already logged inside RefreshCloudBackupStatusAsync. + _ = RefreshCloudBackupStatusAsync(); } /// diff --git a/src/sdk/Angor.Sdk.Tests/WalletExport/AeadCipherTests.cs b/src/sdk/Angor.Sdk.Tests/WalletExport/AeadCipherTests.cs new file mode 100644 index 000000000..8f2a124ac --- /dev/null +++ b/src/sdk/Angor.Sdk.Tests/WalletExport/AeadCipherTests.cs @@ -0,0 +1,96 @@ +using System.Security.Cryptography; +using System.Text; +using Angor.Sdk.WalletExport.Crypto; +using FluentAssertions; + +namespace Angor.Sdk.Tests.WalletExport; + +public class AeadCipherTests +{ + private static byte[] Key32() => Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + + [Fact] + public void Roundtrip_recovers_the_plaintext() + { + var key = Key32(); + var plaintext = Encoding.UTF8.GetBytes("the rain in spain falls mainly on the plain"); + + var ct = AeadCipher.Encrypt(key, plaintext); + var recovered = AeadCipher.Decrypt(key, ct); + + recovered.Should().Equal(plaintext); + } + + [Fact] + public void Wrong_key_throws() + { + var key = Key32(); + var wrong = Key32(); + wrong[0] ^= 0xFF; + + var ct = AeadCipher.Encrypt(key, Encoding.UTF8.GetBytes("secret")); + + Action act = () => AeadCipher.Decrypt(wrong, ct); + act.Should().Throw(); + } + + [Fact] + public void Tampered_tag_byte_throws() + { + var key = Key32(); + var ct = AeadCipher.Encrypt(key, Encoding.UTF8.GetBytes("secret")); + + // Tag sits at offset 12 (after the 12-byte nonce) + ct[12] ^= 0x01; + + Action act = () => AeadCipher.Decrypt(key, ct); + act.Should().Throw(); + } + + [Fact] + public void Tampered_ciphertext_byte_throws() + { + var key = Key32(); + var ct = AeadCipher.Encrypt(key, Encoding.UTF8.GetBytes("secret bytes here")); + + // Ciphertext begins at offset 28 (12 nonce + 16 tag) + ct[28] ^= 0x01; + + Action act = () => AeadCipher.Decrypt(key, ct); + act.Should().Throw(); + } + + [Fact] + public void Each_encryption_uses_a_fresh_nonce() + { + var key = Key32(); + var plaintext = Encoding.UTF8.GetBytes("same input each time"); + + var a = AeadCipher.Encrypt(key, plaintext); + var b = AeadCipher.Encrypt(key, plaintext); + + // Nonce + tag + ciphertext should differ entirely between calls + a.Should().NotEqual(b); + a.Take(12).Should().NotEqual(b.Take(12)); + } + + [Fact] + public void Associated_data_mismatch_rejects() + { + var key = Key32(); + var aad1 = Encoding.UTF8.GetBytes("context-A"); + var aad2 = Encoding.UTF8.GetBytes("context-B"); + var ct = AeadCipher.Encrypt(key, Encoding.UTF8.GetBytes("payload"), aad1); + + Action act = () => AeadCipher.Decrypt(key, ct, aad2); + act.Should().Throw(); + } + + [Fact] + public void Buffer_shorter_than_overhead_rejected() + { + var key = Key32(); + Action act = () => AeadCipher.Decrypt(key, new byte[10]); + act.Should().Throw(); + } +} diff --git a/src/sdk/Angor.Sdk.Tests/WalletExport/Argon2idKdfTests.cs b/src/sdk/Angor.Sdk.Tests/WalletExport/Argon2idKdfTests.cs new file mode 100644 index 000000000..dc616567a --- /dev/null +++ b/src/sdk/Angor.Sdk.Tests/WalletExport/Argon2idKdfTests.cs @@ -0,0 +1,54 @@ +using Angor.Sdk.WalletExport.Crypto; +using FluentAssertions; + +namespace Angor.Sdk.Tests.WalletExport; + +public class Argon2idKdfTests +{ + [Fact] + public void Same_passphrase_always_derives_same_bytes() + { + var a = Argon2idKdf.Derive("correct horse battery staple"); + var b = Argon2idKdf.Derive("correct horse battery staple"); + + a.Should().Equal(b); + a.Length.Should().Be(32); + } + + [Fact] + public void Different_passphrase_derives_different_bytes() + { + var a = Argon2idKdf.Derive("correct horse battery staple"); + var b = Argon2idKdf.Derive("Correct horse battery staple"); // capital C + + a.Should().NotEqual(b); + } + + [Fact] + public void Empty_passphrase_throws() + { + Action act = () => Argon2idKdf.Derive(""); + act.Should().Throw(); + } + + [Fact] + public void Output_is_32_bytes() + { + var bytes = Argon2idKdf.Derive("anything goes here"); + bytes.Length.Should().Be(32); + } + + [Fact] + public void Unicode_normalisation_makes_equivalent_forms_match() + { + // U+00E9 (precomposed é) and U+0065+U+0301 (e + combining acute) must hash to the same value + // because the KDF normalises to NFC. + var precomposed = "café"; + var decomposed = "café"; + + var a = Argon2idKdf.Derive(precomposed); + var b = Argon2idKdf.Derive(decomposed); + + a.Should().Equal(b); + } +} diff --git a/src/sdk/Angor.Sdk.Tests/WalletExport/BackupEnvelopeTests.cs b/src/sdk/Angor.Sdk.Tests/WalletExport/BackupEnvelopeTests.cs new file mode 100644 index 000000000..4b9827db8 --- /dev/null +++ b/src/sdk/Angor.Sdk.Tests/WalletExport/BackupEnvelopeTests.cs @@ -0,0 +1,112 @@ +using System.Security.Cryptography; +using Angor.Sdk.WalletExport; +using Angor.Sdk.WalletExport.Crypto; +using FluentAssertions; + +namespace Angor.Sdk.Tests.WalletExport; + +public class BackupEnvelopeTests +{ + private static BackupSeedPayload SamplePayload() => new() + { + WalletId = "8E3C5250-4E26-4A13-8075-0A189AEAF793", + Network = "Main", + Mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + Bip39Passphrase = "", + Label = "Daily wallet", + CreatedAtUnix = 1_700_000_000 + }; + + [Fact] + public void Inner_roundtrip_recovers_payload() + { + using var keys = BackupKeys.FromPassphrase("strong-pass-123!"); + var payload = SamplePayload(); + + var ct = BackupEnvelope.EncryptInner(payload, keys); + var recovered = BackupEnvelope.DecryptInner(ct, keys); + + recovered.WalletId.Should().Be(payload.WalletId); + recovered.Mnemonic.Should().Be(payload.Mnemonic); + recovered.Network.Should().Be(payload.Network); + recovered.Label.Should().Be(payload.Label); + } + + [Fact] + public void Inner_decrypt_with_wrong_passphrase_throws() + { + using var enc = BackupKeys.FromPassphrase("right-pass"); + using var dec = BackupKeys.FromPassphrase("wrong-pass"); + + var ct = BackupEnvelope.EncryptInner(SamplePayload(), enc); + + Action act = () => BackupEnvelope.DecryptInner(ct, dec); + act.Should().Throw(); + } + + [Fact] + public void Inner_tampered_ciphertext_throws() + { + using var keys = BackupKeys.FromPassphrase("pass"); + var ct = BackupEnvelope.EncryptInner(SamplePayload(), keys); + ct[^1] ^= 0x01; + + Action act = () => BackupEnvelope.DecryptInner(ct, keys); + act.Should().Throw(); + } + + [Fact] + public void Outer_roundtrip_recovers_manifest() + { + using var keys = BackupKeys.FromPassphrase("recovery-passphrase-xyz"); + var manifest = new BackupManifest + { + BlobSha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + BlobSize = 512, + Servers = new() { "https://blossom.angor.io", "https://nostr.build" }, + CreatedAtUnix = 1_700_000_000, + Label = "Test" + }; + + var outer = BackupEnvelope.EncryptOuterManifest(manifest, keys); + var recovered = BackupEnvelope.DecryptOuterManifest(outer, keys); + + recovered.BlobSha256.Should().Be(manifest.BlobSha256); + recovered.BlobSize.Should().Be(manifest.BlobSize); + recovered.Servers.Should().BeEquivalentTo(manifest.Servers); + recovered.Algorithm.Should().Be(BackupManifest.CurrentAlgorithm); + } + + [Fact] + public void Outer_decrypt_with_wrong_passphrase_fails() + { + using var enc = BackupKeys.FromPassphrase("right"); + using var dec = BackupKeys.FromPassphrase("wrong"); + + var manifest = new BackupManifest { BlobSha256 = "deadbeef", BlobSize = 1, Servers = new() { "x" } }; + var outer = BackupEnvelope.EncryptOuterManifest(manifest, enc); + + Action act = () => BackupEnvelope.DecryptOuterManifest(outer, dec); + act.Should().Throw(); + } + + [Fact] + public void Full_two_layer_roundtrip_with_unrelated_keys_fails() + { + using var keysAlice = BackupKeys.FromPassphrase("alice-pass"); + using var keysBob = BackupKeys.FromPassphrase("bob-pass"); + + var manifest = new BackupManifest + { + BlobSha256 = "abc123", + BlobSize = 32, + Servers = new() { "https://blossom.angor.io" }, + CreatedAtUnix = 1_700_000_000 + }; + var aliceOuter = BackupEnvelope.EncryptOuterManifest(manifest, keysAlice); + + // Bob cannot read Alice's manifest + Action act = () => BackupEnvelope.DecryptOuterManifest(aliceOuter, keysBob); + act.Should().Throw(); + } +} diff --git a/src/sdk/Angor.Sdk.Tests/WalletExport/BackupKeysTests.cs b/src/sdk/Angor.Sdk.Tests/WalletExport/BackupKeysTests.cs new file mode 100644 index 000000000..b09676f96 --- /dev/null +++ b/src/sdk/Angor.Sdk.Tests/WalletExport/BackupKeysTests.cs @@ -0,0 +1,78 @@ +using Angor.Sdk.WalletExport.Crypto; +using FluentAssertions; + +namespace Angor.Sdk.Tests.WalletExport; + +public class BackupKeysTests +{ + [Fact] + public void Same_passphrase_derives_same_key_triple() + { + using var a = BackupKeys.FromPassphrase("correct horse battery staple"); + using var b = BackupKeys.FromPassphrase("correct horse battery staple"); + + a.BackupPrivateKeyHex.Should().Be(b.BackupPrivateKeyHex); + a.BackupPublicKeyHex.Should().Be(b.BackupPublicKeyHex); + a.InnerAeadKey.Should().Equal(b.InnerAeadKey); + } + + [Fact] + public void Different_passphrases_derive_different_keys() + { + using var a = BackupKeys.FromPassphrase("passphrase-one"); + using var b = BackupKeys.FromPassphrase("passphrase-two"); + + a.BackupPrivateKeyHex.Should().NotBe(b.BackupPrivateKeyHex); + a.BackupPublicKeyHex.Should().NotBe(b.BackupPublicKeyHex); + a.InnerAeadKey.Should().NotEqual(b.InnerAeadKey); + } + + [Fact] + public void Backup_pubkey_is_32_byte_x_only() + { + using var keys = BackupKeys.FromPassphrase("some-passphrase-123"); + + keys.BackupPublicKeyHex.Length.Should().Be(64); + keys.BackupPublicKeyHex.Should().MatchRegex("^[0-9a-f]{64}$"); + } + + [Fact] + public void Backup_private_key_is_32_byte_hex() + { + using var keys = BackupKeys.FromPassphrase("some-passphrase-123"); + + keys.BackupPrivateKeyHex.Length.Should().Be(64); + keys.BackupPrivateKeyHex.Should().MatchRegex("^[0-9a-f]{64}$"); + } + + [Fact] + public void Inner_aead_key_is_32_bytes() + { + using var keys = BackupKeys.FromPassphrase("some-passphrase-123"); + keys.InnerAeadKey.Length.Should().Be(32); + } + + [Fact] + public void Backup_private_key_and_inner_aead_key_are_distinct() + { + using var keys = BackupKeys.FromPassphrase("some-passphrase-123"); + + // HKDF domain separation must keep these two derived keys different + keys.InnerAeadKey.Should().NotEqual(keys.BackupPrivateKey); + } + + [Fact] + public void Dispose_zeros_secrets() + { + var keys = BackupKeys.FromPassphrase("some-passphrase"); + var aeadKeyRef = keys.InnerAeadKey; + var skRef = keys.BackupPrivateKey; + var masterRef = keys.MasterSeed; + + keys.Dispose(); + + aeadKeyRef.Should().OnlyContain(b => b == 0); + skRef.Should().OnlyContain(b => b == 0); + masterRef.Should().OnlyContain(b => b == 0); + } +} diff --git a/src/sdk/Angor.Sdk/Angor.Sdk.csproj b/src/sdk/Angor.Sdk/Angor.Sdk.csproj index a568f641d..3f4cdedba 100644 --- a/src/sdk/Angor.Sdk/Angor.Sdk.csproj +++ b/src/sdk/Angor.Sdk/Angor.Sdk.csproj @@ -23,6 +23,7 @@ + diff --git a/src/sdk/Angor.Sdk/Common/NetworkConfiguration.cs b/src/sdk/Angor.Sdk/Common/NetworkConfiguration.cs index edff562c8..83dbc7f1b 100644 --- a/src/sdk/Angor.Sdk/Common/NetworkConfiguration.cs +++ b/src/sdk/Angor.Sdk/Common/NetworkConfiguration.cs @@ -145,6 +145,18 @@ public List GetDefaultImageServerUrls() => new() { Name = "nostria (Blossom)", Url = "https://mibo.eu.nostria.app", IsPrimary = false }, }; + public List GetDefaultBackupServerUrls() => + new() + { + // Blossom servers used for encrypted seed backup blobs (BUD-02). + // Initially mirrors the image server list; can diverge if a server stops + // accepting blobs that look like backups (rare — they are opaque binary). + new() { Name = "Angor Blossom", Url = "https://blossom.angor.io", IsPrimary = true }, + new() { Name = "nostr.build", Url = "https://nostr.build", IsPrimary = false }, + new() { Name = "blossom.primal.net", Url = "https://blossom.primal.net", IsPrimary = false }, + new() { Name = "nostria (Blossom)", Url = "https://mibo.eu.nostria.app", IsPrimary = false }, + }; + public List GetDiscoveryRelays() => new() { diff --git a/src/sdk/Angor.Sdk/Wallet/Infrastructure/Impl/EncryptedWallet.cs b/src/sdk/Angor.Sdk/Wallet/Infrastructure/Impl/EncryptedWallet.cs index ab1d91d66..d5bba3c1d 100644 --- a/src/sdk/Angor.Sdk/Wallet/Infrastructure/Impl/EncryptedWallet.cs +++ b/src/sdk/Angor.Sdk/Wallet/Infrastructure/Impl/EncryptedWallet.cs @@ -1,3 +1,5 @@ +using Angor.Sdk.WalletExport; + namespace Angor.Sdk.Wallet.Infrastructure.Impl; public class EncryptedWallet @@ -6,4 +8,12 @@ public class EncryptedWallet public string EncryptedData { get; set; } public string Salt { get; set; } public string IV { get; set; } -} \ No newline at end of file + + /// + /// Optional cloud-backup metadata. Populated when the user enables backup; null otherwise. + /// Does NOT contain the seed, the recovery passphrase, or any AEAD key — only public coordinates + /// (backup pubkey, blob SHA-256, server list) and the NIP-44 outer ciphertext so the background + /// health service can re-publish without the passphrase. + /// + public CloudBackupRecord? CloudBackup { get; set; } +} diff --git a/src/sdk/Angor.Sdk/WalletExport/BackupManifest.cs b/src/sdk/Angor.Sdk/WalletExport/BackupManifest.cs new file mode 100644 index 000000000..dde1b1ba6 --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/BackupManifest.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Angor.Sdk.WalletExport; + +/// +/// Plaintext seed JSON before inner AEAD encryption. Lives only inside the encrypted blob. +/// +public sealed class BackupSeedPayload +{ + [JsonPropertyName("v")] public int Version { get; set; } = 1; + [JsonPropertyName("wallet_id")] public string WalletId { get; set; } = string.Empty; + [JsonPropertyName("network")] public string Network { get; set; } = string.Empty; + [JsonPropertyName("mnemonic")] public string Mnemonic { get; set; } = string.Empty; + [JsonPropertyName("bip39_passphrase")] public string Bip39Passphrase { get; set; } = string.Empty; + [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; + [JsonPropertyName("created_at")] public long CreatedAtUnix { get; set; } +} + +/// +/// Plaintext manifest JSON before outer NIP-44 wrapping. Carries Blossom blob coordinates +/// (SHA-256 + server URLs) so recovery can locate and verify the encrypted blob. +/// +public sealed class BackupManifest +{ + public const string DTag = "angor-seed-backup-v1"; + public const string CurrentAlgorithm = "argon2id-v13+aes256gcm+nip44v2"; + + [JsonPropertyName("v")] public int Version { get; set; } = 1; + [JsonPropertyName("algo")] public string Algorithm { get; set; } = CurrentAlgorithm; + [JsonPropertyName("blob_sha256")] public string BlobSha256 { get; set; } = string.Empty; + [JsonPropertyName("blob_size")] public long BlobSize { get; set; } + [JsonPropertyName("servers")] public List Servers { get; set; } = new(); + [JsonPropertyName("created_at")] public long CreatedAtUnix { get; set; } + [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; +} diff --git a/src/sdk/Angor.Sdk/WalletExport/BackupRecoveryService.cs b/src/sdk/Angor.Sdk/WalletExport/BackupRecoveryService.cs new file mode 100644 index 000000000..69de3af4f --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/BackupRecoveryService.cs @@ -0,0 +1,89 @@ +using Angor.Sdk.WalletExport.Blossom; +using Angor.Sdk.WalletExport.Crypto; +using Angor.Shared; +using Angor.Shared.Services; +using CSharpFunctionalExtensions; +using Microsoft.Extensions.Logging; + +namespace Angor.Sdk.WalletExport; + +public sealed class BackupRecoveryService : IBackupRecoveryService +{ + private readonly INetworkConfiguration networkConfiguration; + private readonly IBackupBlossomClient blossomClient; + private readonly IRelayService relayService; + private readonly ILogger logger; + + public BackupRecoveryService( + INetworkConfiguration networkConfiguration, + IBackupBlossomClient blossomClient, + IRelayService relayService, + ILogger logger) + { + this.networkConfiguration = networkConfiguration; + this.blossomClient = blossomClient; + this.relayService = relayService; + this.logger = logger; + } + + public async Task> RecoverAsync(string recoveryPassphrase, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(recoveryPassphrase)) + return Result.Failure("Recovery passphrase is required."); + + using var keys = BackupKeys.FromPassphrase(recoveryPassphrase); + + var outerCipher = await relayService.FetchAppSpecificDataAsync(keys.BackupPublicKeyHex, BackupManifest.DTag); + if (string.IsNullOrWhiteSpace(outerCipher)) + return Result.Failure("No backup found for this passphrase on the configured relays."); + + BackupManifest manifest; + try + { + manifest = BackupEnvelope.DecryptOuterManifest(outerCipher, keys); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to decrypt manifest — passphrase likely wrong or event tampered."); + return Result.Failure("Could not decrypt the backup manifest. Check the passphrase."); + } + + var candidates = manifest.Servers + .Concat(networkConfiguration.GetDefaultBackupServerUrls().Select(s => s.Url)) + .Select(u => u.TrimEnd('/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + byte[]? innerCipher = null; + string? servedFrom = null; + foreach (var server in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + var download = await blossomClient.DownloadAsync(server, manifest.BlobSha256, cancellationToken); + if (download.IsSuccess) + { + innerCipher = download.Value; + servedFrom = server; + break; + } + logger.LogInformation("Recovery: blob unavailable at {Server}: {Reason}", server, download.Error); + } + + if (innerCipher is null || servedFrom is null) + return Result.Failure( + $"Backup blob {manifest.BlobSha256} not retrievable from any of {candidates.Count} Blossom servers."); + + BackupSeedPayload payload; + try + { + payload = BackupEnvelope.DecryptInner(innerCipher, keys); + } + catch (Exception ex) + { + logger.LogError(ex, "Inner AEAD decryption failed — possible tamper."); + return Result.Failure("Backup blob failed authenticity check."); + } + + return Result.Success(new BackupRecoveryResult(payload, servedFrom)); + } +} diff --git a/src/sdk/Angor.Sdk/WalletExport/Blossom/BackupBlossomClient.cs b/src/sdk/Angor.Sdk/WalletExport/Blossom/BackupBlossomClient.cs new file mode 100644 index 000000000..52d147a2a --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/Blossom/BackupBlossomClient.cs @@ -0,0 +1,192 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using CSharpFunctionalExtensions; +using Microsoft.Extensions.Logging; +using Nostr.Client.Keys; +using Nostr.Client.Messages; + +namespace Angor.Sdk.WalletExport.Blossom; + +public sealed class BackupBlossomClient : IBackupBlossomClient +{ + private const string ContentType = "application/octet-stream"; + private const int AuthExpiryMinutes = 5; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + private readonly IHttpClientFactory httpClientFactory; + private readonly ILogger logger; + + public BackupBlossomClient(IHttpClientFactory httpClientFactory, ILogger logger) + { + this.httpClientFactory = httpClientFactory; + this.logger = logger; + } + + public async Task> UploadAsync( + string serverBaseUrl, byte[] blob, string expectedSha256, string nostrPrivateKeyHex, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(serverBaseUrl)) return Result.Failure("Empty server URL."); + if (blob is null || blob.Length == 0) return Result.Failure("Empty blob."); + if (string.IsNullOrWhiteSpace(expectedSha256)) return Result.Failure("Missing expected hash."); + + var baseUrl = serverBaseUrl.TrimEnd('/'); + var uploadUrl = $"{baseUrl}/upload"; + + try + { + using var client = httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromMinutes(2); + + using var content = new ByteArrayContent(blob); + content.Headers.ContentType = new MediaTypeHeaderValue(ContentType); + + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) { Content = content }; + request.Headers.Add("Authorization", BuildAuthorizationHeader(nostrPrivateKeyHex, expectedSha256)); + + using var response = await client.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var reason = response.Headers.Contains("X-Reason") + ? response.Headers.GetValues("X-Reason").FirstOrDefault() + : body; + logger.LogWarning("Blossom backup upload to {Server} failed {Status}: {Reason}", + baseUrl, response.StatusCode, reason); + return Result.Failure($"Upload failed ({(int)response.StatusCode}): {reason}"); + } + + var descriptor = JsonSerializer.Deserialize(body, JsonOptions); + if (descriptor?.Url is null || descriptor.Sha256 is null) + return Result.Failure("Server returned an invalid descriptor."); + + if (!string.Equals(descriptor.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) + return Result.Failure( + $"Server returned hash {descriptor.Sha256} but expected {expectedSha256}."); + + return Result.Success(new BlobUploadResult(baseUrl, descriptor.Url, descriptor.Sha256.ToLowerInvariant(), descriptor.Size)); + } + catch (TaskCanceledException) + { + return Result.Failure("Upload was cancelled."); + } + catch (HttpRequestException ex) + { + logger.LogWarning(ex, "Network error uploading backup blob to {Server}", baseUrl); + return Result.Failure($"Network error: {ex.Message}"); + } + } + + public async Task> ExistsAsync(string serverBaseUrl, string sha256, CancellationToken cancellationToken = default) + { + var baseUrl = serverBaseUrl.TrimEnd('/'); + var url = $"{baseUrl}/{sha256}"; + + try + { + using var client = httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(30); + + using var request = new HttpRequestMessage(HttpMethod.Head, url); + using var response = await client.SendAsync(request, cancellationToken); + + return Result.Success(response.IsSuccessStatusCode); + } + catch (TaskCanceledException) + { + return Result.Failure("Probe was cancelled."); + } + catch (HttpRequestException ex) + { + return Result.Failure($"Network error: {ex.Message}"); + } + } + + public async Task> DownloadAsync(string serverBaseUrl, string sha256, CancellationToken cancellationToken = default) + { + var baseUrl = serverBaseUrl.TrimEnd('/'); + var url = $"{baseUrl}/{sha256}"; + + try + { + using var client = httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromMinutes(1); + + using var response = await client.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + return Result.Failure($"Download failed ({(int)response.StatusCode})."); + + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + var actualHash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + if (!string.Equals(actualHash, sha256, StringComparison.OrdinalIgnoreCase)) + return Result.Failure($"Hash mismatch: server returned {actualHash} for {sha256}."); + + return Result.Success(bytes); + } + catch (TaskCanceledException) + { + return Result.Failure("Download was cancelled."); + } + catch (HttpRequestException ex) + { + return Result.Failure($"Network error: {ex.Message}"); + } + } + + /// + /// BUD-02 auth event: kind 24242 signed by the passphrase-derived nsec. + /// Tagged with t=upload, x=<sha256>, and a short expiration. + /// + private static string BuildAuthorizationHeader(string nostrPrivateKeyHex, string sha256) + { + var key = NostrPrivateKey.FromHex(nostrPrivateKeyHex); + var expiration = DateTimeOffset.UtcNow.AddMinutes(AuthExpiryMinutes).ToUnixTimeSeconds().ToString(); + + var authEvent = new NostrEvent + { + Kind = (NostrKind)24242, + CreatedAt = DateTime.UtcNow, + Content = "Angor backup upload", + Tags = new NostrEventTags( + new NostrEventTag("t", "upload"), + new NostrEventTag("x", sha256.ToLowerInvariant()), + new NostrEventTag("expiration", expiration)) + }.Sign(key); + + var eventJson = JsonSerializer.Serialize(new + { + id = authEvent.Id, + pubkey = authEvent.Pubkey, + created_at = new DateTimeOffset(authEvent.CreatedAt ?? DateTime.UtcNow).ToUnixTimeSeconds(), + kind = (int)authEvent.Kind, + tags = authEvent.Tags?.Select(t => + { + var values = new List { t.TagIdentifier }; + if (t.AdditionalData != null) values.AddRange(t.AdditionalData); + return values.ToArray(); + }).ToArray() ?? Array.Empty(), + content = authEvent.Content ?? string.Empty, + sig = authEvent.Sig + }, JsonOptions); + + var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(eventJson)); + return $"Nostr {base64}"; + } + + private sealed class BlobDescriptor + { + [JsonPropertyName("url")] public string? Url { get; set; } + [JsonPropertyName("sha256")] public string? Sha256 { get; set; } + [JsonPropertyName("size")] public long Size { get; set; } + [JsonPropertyName("type")] public string? Type { get; set; } + } +} diff --git a/src/sdk/Angor.Sdk/WalletExport/Blossom/IBackupBlossomClient.cs b/src/sdk/Angor.Sdk/WalletExport/Blossom/IBackupBlossomClient.cs new file mode 100644 index 000000000..32d697da0 --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/Blossom/IBackupBlossomClient.cs @@ -0,0 +1,36 @@ +using System.Net.Http; +using CSharpFunctionalExtensions; + +namespace Angor.Sdk.WalletExport.Blossom; + +/// +/// Blossom client tailored to seed-backup blobs. +/// Uploads with BUD-02 (kind 24242) authorization signed by the passphrase-derived nsec, +/// confirms the server's content-address matches our local SHA-256, and supports +/// existence checks + downloads on recovery. +/// +public interface IBackupBlossomClient +{ + /// + /// PUT the blob to a single Blossom server. Returns the server-confirmed download URL. + /// + Task> UploadAsync( + string serverBaseUrl, + byte[] blob, + string expectedSha256, + string nostrPrivateKeyHex, + CancellationToken cancellationToken = default); + + /// + /// HEAD {server}/{sha256} to verify the blob still lives on the server. + /// + Task> ExistsAsync(string serverBaseUrl, string sha256, CancellationToken cancellationToken = default); + + /// + /// GET {server}/{sha256}. Verifies the response body hashes back to the requested sha256 + /// (defends against a malicious or buggy server returning the wrong bytes). + /// + Task> DownloadAsync(string serverBaseUrl, string sha256, CancellationToken cancellationToken = default); +} + +public sealed record BlobUploadResult(string ServerBaseUrl, string DownloadUrl, string Sha256, long Size); diff --git a/src/sdk/Angor.Sdk/WalletExport/CloudBackupModels.cs b/src/sdk/Angor.Sdk/WalletExport/CloudBackupModels.cs new file mode 100644 index 000000000..7f52b2848 --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/CloudBackupModels.cs @@ -0,0 +1,54 @@ +namespace Angor.Sdk.WalletExport; + +/// +/// Snapshot of where an active cloud backup is stored. Cached in the wallet record so the +/// background health service can refresh without re-deriving keys from the passphrase. +/// +public sealed class CloudBackupRecord +{ + /// The backup-identity Schnorr public key (hex, x-only). Doubles as the relay author for the manifest event. + public string BackupPubKeyHex { get; set; } = string.Empty; + + /// Lower-case hex SHA-256 of the encrypted blob — the Blossom content address. + public string BlobSha256 { get; set; } = string.Empty; + + /// Servers we believe currently hold the blob. + public List Servers { get; set; } = new(); + + /// NIP-44 outer ciphertext of the manifest. Stored so refresh-on-launch can re-publish without the passphrase. + public string ManifestCipherText { get; set; } = string.Empty; + + /// Unix seconds when the backup was first created. + public long CreatedAtUnix { get; set; } + + /// Unix seconds of the last fully successful relay+blob verification. + public long? LastVerifiedAtUnix { get; set; } + + /// Per-server health: server URL → true if the blob was reachable last time we checked. + public Dictionary ServerHealth { get; set; } = new(); +} + +/// +/// Outcome of a backup setup attempt. +/// +public sealed record BackupCreationResult( + string BackupPubKeyHex, + string BlobSha256, + IReadOnlyList UploadedToServers, + IReadOnlyList FailedServers, + long CreatedAtUnix); + +/// +/// Outcome of a recovery attempt — the decrypted seed payload ready to feed into the wallet restore flow. +/// +public sealed record BackupRecoveryResult(BackupSeedPayload Payload, string BlobServedFrom); + +/// +/// Outcome of a background refresh / health check cycle. +/// +public sealed record BackupHealthResult( + bool RelayManifestPublished, + int ServersReachable, + int ServersChecked, + IReadOnlyList RehealedServers, + long CheckedAtUnix); diff --git a/src/sdk/Angor.Sdk/WalletExport/CloudBackupService.cs b/src/sdk/Angor.Sdk/WalletExport/CloudBackupService.cs new file mode 100644 index 000000000..5730f42f0 --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/CloudBackupService.cs @@ -0,0 +1,148 @@ +using System.Security.Cryptography; +using Angor.Sdk.WalletExport.Blossom; +using Angor.Sdk.WalletExport.Crypto; +using Angor.Shared; +using Angor.Shared.Services; +using CSharpFunctionalExtensions; +using Microsoft.Extensions.Logging; +using Nostr.Client.Responses; + +namespace Angor.Sdk.WalletExport; + +public sealed class CloudBackupService : ICloudBackupService +{ + private readonly INetworkConfiguration networkConfiguration; + private readonly IBackupBlossomClient blossomClient; + private readonly IRelayService relayService; + private readonly ILogger logger; + + public CloudBackupService( + INetworkConfiguration networkConfiguration, + IBackupBlossomClient blossomClient, + IRelayService relayService, + ILogger logger) + { + this.networkConfiguration = networkConfiguration; + this.blossomClient = blossomClient; + this.relayService = relayService; + this.logger = logger; + } + + public async Task> CreateBackupAsync( + string recoveryPassphrase, BackupSeedPayload payload, int minServerSuccessThreshold = 2, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(recoveryPassphrase)) + return Result.Failure<(BackupCreationResult, CloudBackupRecord)>("Recovery passphrase is required."); + if (payload is null) + return Result.Failure<(BackupCreationResult, CloudBackupRecord)>("Seed payload is required."); + if (string.IsNullOrWhiteSpace(payload.Mnemonic)) + return Result.Failure<(BackupCreationResult, CloudBackupRecord)>("Mnemonic is required in payload."); + + using var keys = BackupKeys.FromPassphrase(recoveryPassphrase); + + var nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + payload.CreatedAtUnix = nowUnix; + + byte[] innerCipher; + try + { + innerCipher = BackupEnvelope.EncryptInner(payload, keys); + } + catch (Exception ex) + { + logger.LogError(ex, "Inner encryption of backup payload failed."); + return Result.Failure<(BackupCreationResult, CloudBackupRecord)>("Failed to encrypt seed payload."); + } + + var blobSha256 = Convert.ToHexString(SHA256.HashData(innerCipher)).ToLowerInvariant(); + var servers = networkConfiguration + .GetDefaultBackupServerUrls() + .Select(s => s.Url.TrimEnd('/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (servers.Count == 0) + return Result.Failure<(BackupCreationResult, CloudBackupRecord)>("No backup servers configured."); + + var uploadTasks = servers.Select(async server => + { + var r = await blossomClient.UploadAsync(server, innerCipher, blobSha256, keys.BackupPrivateKeyHex, cancellationToken); + return (Server: server, Result: r); + }).ToList(); + + var uploadResults = await Task.WhenAll(uploadTasks); + + var succeeded = uploadResults.Where(t => t.Result.IsSuccess).Select(t => t.Server).ToList(); + var failed = uploadResults.Where(t => t.Result.IsFailure).Select(t => t.Server).ToList(); + + if (succeeded.Count < minServerSuccessThreshold) + { + var failedDetails = string.Join("; ", uploadResults.Where(t => t.Result.IsFailure).Select(t => $"{t.Server}: {t.Result.Error}")); + return Result.Failure<(BackupCreationResult, CloudBackupRecord)>( + $"Only {succeeded.Count} of {servers.Count} Blossom servers accepted the blob (needed {minServerSuccessThreshold}). {failedDetails}"); + } + + var manifest = new BackupManifest + { + BlobSha256 = blobSha256, + BlobSize = innerCipher.Length, + Servers = succeeded, + CreatedAtUnix = nowUnix, + Label = payload.Label + }; + + string outerCipher; + try + { + outerCipher = BackupEnvelope.EncryptOuterManifest(manifest, keys); + } + catch (Exception ex) + { + logger.LogError(ex, "Outer NIP-44 encryption of manifest failed."); + return Result.Failure<(BackupCreationResult, CloudBackupRecord)>("Failed to encrypt backup manifest."); + } + + try + { + await PublishManifestAsync(outerCipher, keys.BackupPrivateKeyHex, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Publishing manifest event to relays failed."); + return Result.Failure<(BackupCreationResult, CloudBackupRecord)>("Failed to publish backup manifest to relays."); + } + + var record = new CloudBackupRecord + { + BackupPubKeyHex = keys.BackupPublicKeyHex, + BlobSha256 = blobSha256, + Servers = succeeded, + ManifestCipherText = outerCipher, + CreatedAtUnix = nowUnix, + LastVerifiedAtUnix = nowUnix, + ServerHealth = succeeded.ToDictionary(s => s, _ => true, StringComparer.OrdinalIgnoreCase) + }; + + var result = new BackupCreationResult(keys.BackupPublicKeyHex, blobSha256, succeeded, failed, nowUnix); + return Result.Success((result, record)); + } + + private async Task PublishManifestAsync(string outerCipher, string nsecHex, CancellationToken cancellationToken) + { + var ackTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationToken.Register(() => ackTcs.TrySetCanceled()); + + Action onAck = ok => + { + if (ok.Accepted) ackTcs.TrySetResult(true); + }; + + await relayService.PublishAppSpecificDataAsync(BackupManifest.DTag, outerCipher, nsecHex, onAck); + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + timeoutCts.Token.Register(() => ackTcs.TrySetResult(false)); + + await ackTcs.Task; + } +} diff --git a/src/sdk/Angor.Sdk/WalletExport/Crypto/AeadCipher.cs b/src/sdk/Angor.Sdk/WalletExport/Crypto/AeadCipher.cs new file mode 100644 index 000000000..86bbd16b1 --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/Crypto/AeadCipher.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; + +namespace Angor.Sdk.WalletExport.Crypto; + +/// +/// AES-256-GCM with key supplied directly (no KDF). +/// Wire format: nonce (12 bytes) ‖ tag (16 bytes) ‖ ciphertext. +/// +internal static class AeadCipher +{ + public const int NonceSize = 12; + public const int TagSize = 16; + public const int KeySize = 32; + + /// + /// Encrypts plaintext with a fresh random nonce. + /// Returns the concatenated buffer (nonce ‖ tag ‖ ciphertext). + /// + public static byte[] Encrypt(byte[] key32, byte[] plaintext, byte[]? associatedData = null) + { + if (key32 is null || key32.Length != KeySize) + throw new ArgumentException($"Key must be {KeySize} bytes.", nameof(key32)); + ArgumentNullException.ThrowIfNull(plaintext); + + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var tag = new byte[TagSize]; + var ciphertext = new byte[plaintext.Length]; + + using (var aes = new AesGcm(key32, TagSize)) + { + aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData); + } + + var output = new byte[NonceSize + TagSize + ciphertext.Length]; + Buffer.BlockCopy(nonce, 0, output, 0, NonceSize); + Buffer.BlockCopy(tag, 0, output, NonceSize, TagSize); + Buffer.BlockCopy(ciphertext, 0, output, NonceSize + TagSize, ciphertext.Length); + return output; + } + + /// + /// Decrypts a buffer produced by . + /// Throws on tag mismatch or malformed input. + /// + public static byte[] Decrypt(byte[] key32, byte[] buffer, byte[]? associatedData = null) + { + if (key32 is null || key32.Length != KeySize) + throw new ArgumentException($"Key must be {KeySize} bytes.", nameof(key32)); + ArgumentNullException.ThrowIfNull(buffer); + if (buffer.Length < NonceSize + TagSize) + throw new CryptographicException("Ciphertext too short."); + + var nonce = new byte[NonceSize]; + var tag = new byte[TagSize]; + var ciphertext = new byte[buffer.Length - NonceSize - TagSize]; + + Buffer.BlockCopy(buffer, 0, nonce, 0, NonceSize); + Buffer.BlockCopy(buffer, NonceSize, tag, 0, TagSize); + Buffer.BlockCopy(buffer, NonceSize + TagSize, ciphertext, 0, ciphertext.Length); + + var plaintext = new byte[ciphertext.Length]; + using var aes = new AesGcm(key32, TagSize); + aes.Decrypt(nonce, ciphertext, tag, plaintext, associatedData); + return plaintext; + } +} diff --git a/src/sdk/Angor.Sdk/WalletExport/Crypto/Argon2idKdf.cs b/src/sdk/Angor.Sdk/WalletExport/Crypto/Argon2idKdf.cs new file mode 100644 index 000000000..bbc4ddbd6 --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/Crypto/Argon2idKdf.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; +using System.Text; +using Konscious.Security.Cryptography; + +namespace Angor.Sdk.WalletExport.Crypto; + +/// +/// Argon2id key derivation tuned for the cloud-backup recovery passphrase. +/// Parameters follow OWASP 2024 recommendations: m=64 MiB, t=3, p=1, 32-byte output. +/// +internal static class Argon2idKdf +{ + public const int MemoryKb = 65_536; + public const int Iterations = 3; + public const int Parallelism = 1; + public const int OutputLength = 32; + + public const string DomainSeparationLabel = "angor-seed-backup-v1"; + + /// + /// Derives a 32-byte master seed from a recovery passphrase. + /// The passphrase is NFC-normalised and encoded as UTF-8 before hashing. + /// The salt is deterministic — Argon2id memory cost is what blocks rainbow tables. + /// + public static byte[] Derive(string passphrase) + { + if (string.IsNullOrEmpty(passphrase)) + throw new ArgumentException("Passphrase must not be empty.", nameof(passphrase)); + + var normalised = passphrase.Normalize(NormalizationForm.FormC); + var passwordBytes = Encoding.UTF8.GetBytes(normalised); + + try + { + return Derive(passwordBytes); + } + finally + { + CryptographicOperations.ZeroMemory(passwordBytes); + } + } + + /// + /// Derives a 32-byte master seed from raw passphrase bytes. The caller is + /// responsible for zeroing the input buffer after use. + /// + public static byte[] Derive(byte[] passwordBytes) + { + ArgumentNullException.ThrowIfNull(passwordBytes); + + var salt = DeriveSalt(); + using var argon2 = new Argon2id(passwordBytes) + { + Salt = salt, + DegreeOfParallelism = Parallelism, + MemorySize = MemoryKb, + Iterations = Iterations + }; + + return argon2.GetBytes(OutputLength); + } + + private static byte[] DeriveSalt() + { + // 16-byte deterministic salt = first 16 bytes of SHA-256("angor-seed-backup-v1") + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(DomainSeparationLabel)); + var salt = new byte[16]; + Buffer.BlockCopy(hash, 0, salt, 0, 16); + return salt; + } +} diff --git a/src/sdk/Angor.Sdk/WalletExport/Crypto/BackupEnvelope.cs b/src/sdk/Angor.Sdk/WalletExport/Crypto/BackupEnvelope.cs new file mode 100644 index 000000000..c3460a8df --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/Crypto/BackupEnvelope.cs @@ -0,0 +1,102 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Nostr.Client.Keys; +using Nostr.Client.Utils; + +namespace Angor.Sdk.WalletExport.Crypto; + +/// +/// Two-layer envelope: +/// inner = AES-256-GCM(K_inner) over the seed JSON payload +/// outer = NIP-44 v2(self-ECDH backup_sk × backup_pk) over the manifest JSON +/// +internal static class BackupEnvelope +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly byte[] InnerAad = Encoding.UTF8.GetBytes(BackupManifest.DTag); + + /// + /// Serialise + AEAD-encrypt the seed payload. Returns the raw byte buffer (nonce ‖ tag ‖ ct). + /// + public static byte[] EncryptInner(BackupSeedPayload payload, BackupKeys keys) + { + ArgumentNullException.ThrowIfNull(payload); + ArgumentNullException.ThrowIfNull(keys); + + var plaintextBytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions); + try + { + return AeadCipher.Encrypt(keys.InnerAeadKey, plaintextBytes, InnerAad); + } + finally + { + CryptographicOperations.ZeroMemory(plaintextBytes); + } + } + + /// + /// AEAD-decrypt + deserialise into the seed payload. Throws on tag mismatch. + /// + public static BackupSeedPayload DecryptInner(byte[] cipherBuffer, BackupKeys keys) + { + ArgumentNullException.ThrowIfNull(cipherBuffer); + ArgumentNullException.ThrowIfNull(keys); + + var plaintextBytes = AeadCipher.Decrypt(keys.InnerAeadKey, cipherBuffer, InnerAad); + try + { + var payload = JsonSerializer.Deserialize(plaintextBytes, JsonOptions) + ?? throw new CryptographicException("Decrypted payload was empty."); + return payload; + } + finally + { + CryptographicOperations.ZeroMemory(plaintextBytes); + } + } + + /// + /// NIP-44 v2 self-encryption of the manifest JSON. Returns the base64 payload that + /// goes verbatim into the kind 30078 event content. + /// + public static string EncryptOuterManifest(BackupManifest manifest, BackupKeys keys) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(keys); + + var json = JsonSerializer.Serialize(manifest, JsonOptions); + var conversationKey = DeriveSelfConversationKey(keys); + return NostrEncryptionNip44.Encrypt(json, conversationKey); + } + + /// + /// NIP-44 v2 decryption of a kind 30078 event content. Throws on tamper / wrong key. + /// + public static BackupManifest DecryptOuterManifest(string outerCipher, BackupKeys keys) + { + if (string.IsNullOrWhiteSpace(outerCipher)) + throw new ArgumentException("Outer cipher is empty.", nameof(outerCipher)); + ArgumentNullException.ThrowIfNull(keys); + + var conversationKey = DeriveSelfConversationKey(keys); + var json = NostrEncryptionNip44.Decrypt(outerCipher, conversationKey); + return JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new CryptographicException("Decrypted manifest was empty."); + } + + private static byte[] DeriveSelfConversationKey(BackupKeys keys) + { + // NIP-44 v2: conversation_key = HKDF(ECDH_x_coord, salt="nip44-v2") + // Self-ECDH means sender and recipient are the same identity; the conversation key + // is still well-defined (sk·sk·G is a valid curve point). + var nsec = NostrPrivateKey.FromHex(keys.BackupPrivateKeyHex); + var npub = NostrPublicKey.FromHex(keys.BackupPublicKeyHex); + return nsec.DeriveConversationKeyNip44(npub); + } +} diff --git a/src/sdk/Angor.Sdk/WalletExport/Crypto/BackupKeys.cs b/src/sdk/Angor.Sdk/WalletExport/Crypto/BackupKeys.cs new file mode 100644 index 000000000..eff7e3138 --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/Crypto/BackupKeys.cs @@ -0,0 +1,108 @@ +using System.Security.Cryptography; +using System.Text; +using NBitcoin.Secp256k1; + +namespace Angor.Sdk.WalletExport.Crypto; + +/// +/// Materialised secrets derived from a recovery passphrase. +/// Holds zero-on-dispose buffers — use within a using block. +/// +internal sealed class BackupKeys : IDisposable +{ + public byte[] MasterSeed { get; } + public byte[] BackupPrivateKey { get; } + public byte[] InnerAeadKey { get; } + public string BackupPrivateKeyHex { get; } + public string BackupPublicKeyHex { get; } + + private bool disposed; + + private BackupKeys(byte[] masterSeed, byte[] backupPrivateKey, byte[] innerAeadKey, + string backupPrivateKeyHex, string backupPublicKeyHex) + { + MasterSeed = masterSeed; + BackupPrivateKey = backupPrivateKey; + InnerAeadKey = innerAeadKey; + BackupPrivateKeyHex = backupPrivateKeyHex; + BackupPublicKeyHex = backupPublicKeyHex; + } + + /// + /// Derives all backup secrets from a recovery passphrase. + /// Returns an instance whose buffers MUST be disposed. + /// + public static BackupKeys FromPassphrase(string passphrase) + { + byte[] master = Argon2idKdf.Derive(passphrase); + return FromMasterSeed(master); + } + + /// + /// Derives all backup secrets from a pre-computed Argon2id master seed. + /// Takes ownership of the supplied buffer (caller must not zero it). + /// + public static BackupKeys FromMasterSeed(byte[] masterSeed) + { + if (masterSeed is null || masterSeed.Length != 32) + throw new ArgumentException("Master seed must be 32 bytes.", nameof(masterSeed)); + + byte[] backupPrivateKey = DeriveSecp256k1Key(masterSeed, "angor-backup-identity-v1"); + byte[] innerAeadKey = HkdfExpand(masterSeed, "angor-backup-aead-v1", 32); + + if (!Context.Instance.TryCreateECPrivKey(backupPrivateKey, out var privKey) || privKey is null) + throw new CryptographicException("Derived private key invalid for secp256k1."); + + try + { + var xOnly = privKey.CreateXOnlyPubKey(); + var pubKeyBytes = new byte[32]; + xOnly.WriteToSpan(pubKeyBytes); + + return new BackupKeys( + masterSeed, + backupPrivateKey, + innerAeadKey, + Convert.ToHexString(backupPrivateKey).ToLowerInvariant(), + Convert.ToHexString(pubKeyBytes).ToLowerInvariant()); + } + finally + { + privKey.Dispose(); + } + } + + /// + /// Derive a valid secp256k1 private key from the master seed using HKDF-Expand with + /// a counter byte. On the (vanishingly rare) chance of a 0 or out-of-range output, the + /// counter is incremented and HKDF re-run. + /// + private static byte[] DeriveSecp256k1Key(byte[] masterSeed, string info) + { + for (byte counter = 0; counter < byte.MaxValue; counter++) + { + var infoBytes = Encoding.UTF8.GetBytes(info + "-" + counter); + var candidate = HKDF.Expand(HashAlgorithmName.SHA256, masterSeed, 32, infoBytes); + if (Context.Instance.TryCreateECPrivKey(candidate, out var key) && key is not null) + { + key.Dispose(); + return candidate; + } + } + throw new CryptographicException("Failed to derive a valid secp256k1 key."); + } + + private static byte[] HkdfExpand(byte[] ikm, string info, int length) + { + return HKDF.Expand(HashAlgorithmName.SHA256, ikm, length, Encoding.UTF8.GetBytes(info)); + } + + public void Dispose() + { + if (disposed) return; + CryptographicOperations.ZeroMemory(MasterSeed); + CryptographicOperations.ZeroMemory(BackupPrivateKey); + CryptographicOperations.ZeroMemory(InnerAeadKey); + disposed = true; + } +} diff --git a/src/sdk/Angor.Sdk/WalletExport/IBackupRecoveryService.cs b/src/sdk/Angor.Sdk/WalletExport/IBackupRecoveryService.cs new file mode 100644 index 000000000..6cd4683fe --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/IBackupRecoveryService.cs @@ -0,0 +1,16 @@ +using CSharpFunctionalExtensions; + +namespace Angor.Sdk.WalletExport; + +/// +/// Recover a wallet seed from a previously-published cloud backup. +/// Recovery requires only the recovery passphrase — no device, account, or network identity. +/// +public interface IBackupRecoveryService +{ + /// + /// Derive the backup identity from the passphrase, query relays for the manifest, fetch the + /// encrypted blob from any healthy Blossom server, and decrypt the seed. + /// + Task> RecoverAsync(string recoveryPassphrase, CancellationToken cancellationToken = default); +} diff --git a/src/sdk/Angor.Sdk/WalletExport/ICloudBackupService.cs b/src/sdk/Angor.Sdk/WalletExport/ICloudBackupService.cs new file mode 100644 index 000000000..b6b4bac3e --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/ICloudBackupService.cs @@ -0,0 +1,23 @@ +using CSharpFunctionalExtensions; + +namespace Angor.Sdk.WalletExport; + +/// +/// Set up and tear down a cloud backup of the wallet seed. +/// The recovery passphrase never leaves this layer — it is consumed to derive keys, then zeroed. +/// +public interface ICloudBackupService +{ + /// + /// Encrypt the seed payload, upload it to multiple Blossom servers, and publish a kind 30078 + /// manifest event so the backup is discoverable by passphrase alone. + /// + /// User-chosen passphrase. Stretched with Argon2id. Zeroed after use. + /// Plaintext seed payload (mnemonic + network + optional BIP-39 passphrase + label). + /// Minimum number of Blossom uploads required to consider the backup published. + Task> CreateBackupAsync( + string recoveryPassphrase, + BackupSeedPayload payload, + int minServerSuccessThreshold = 2, + CancellationToken cancellationToken = default); +} diff --git a/src/sdk/Angor.Sdk/WalletExport/IWalletCloudBackupService.cs b/src/sdk/Angor.Sdk/WalletExport/IWalletCloudBackupService.cs new file mode 100644 index 000000000..a68f10ad0 --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/IWalletCloudBackupService.cs @@ -0,0 +1,40 @@ +using Angor.Sdk.Common; +using CSharpFunctionalExtensions; + +namespace Angor.Sdk.WalletExport; + +/// +/// Wallet-aware cloud backup operations. +/// Wires together with the wallet store so the per-wallet backup +/// state is persisted to wallets.json. +/// +public interface IWalletCloudBackupService +{ + Task>> GetStatus(WalletId walletId); + + Task> EnableAsync( + WalletId walletId, + string recoveryPassphrase, + string label, + CancellationToken cancellationToken = default); + + Task DisableAsync(WalletId walletId); + + /// + /// Re-derive keys from the passphrase and re-publish the manifest + (if necessary) re-upload the blob. + /// User-triggered: requires the passphrase. The blob hash never changes for the same payload, so + /// re-uploads land at the same Blossom address. + /// + Task> RefreshAsync( + WalletId walletId, + string recoveryPassphrase, + CancellationToken cancellationToken = default); + + /// + /// Passive health probe — HEADs the cached Blossom URLs to update per-server availability. + /// Does NOT require the passphrase. Cannot self-repair missing blobs (that needs the BUD-02 auth key). + /// + Task> VerifyHealthAsync( + WalletId walletId, + CancellationToken cancellationToken = default); +} diff --git a/src/sdk/Angor.Sdk/WalletExport/WalletCloudBackupService.cs b/src/sdk/Angor.Sdk/WalletExport/WalletCloudBackupService.cs new file mode 100644 index 000000000..11dee9a5f --- /dev/null +++ b/src/sdk/Angor.Sdk/WalletExport/WalletCloudBackupService.cs @@ -0,0 +1,176 @@ +using Angor.Sdk.WalletExport.Blossom; +using Angor.Sdk.Common; +using Angor.Sdk.Wallet.Application; +using Angor.Sdk.Wallet.Infrastructure.Impl; +using Angor.Sdk.Wallet.Infrastructure.Interfaces; +using Angor.Shared; +using CSharpFunctionalExtensions; +using Microsoft.Extensions.Logging; + +namespace Angor.Sdk.WalletExport; + +public sealed class WalletCloudBackupService : IWalletCloudBackupService +{ + private readonly IWalletAppService walletAppService; + private readonly IWalletStore walletStore; + private readonly ICloudBackupService cloudBackupService; + private readonly IBackupBlossomClient blossomClient; + private readonly INetworkConfiguration networkConfiguration; + private readonly ILogger logger; + + public WalletCloudBackupService( + IWalletAppService walletAppService, + IWalletStore walletStore, + ICloudBackupService cloudBackupService, + IBackupBlossomClient blossomClient, + INetworkConfiguration networkConfiguration, + ILogger logger) + { + this.walletAppService = walletAppService; + this.walletStore = walletStore; + this.cloudBackupService = cloudBackupService; + this.blossomClient = blossomClient; + this.networkConfiguration = networkConfiguration; + this.logger = logger; + } + + public async Task>> GetStatus(WalletId walletId) + { + var listResult = await walletStore.GetAll(); + if (listResult.IsFailure) + return Result.Failure>(listResult.Error); + + var wallet = listResult.Value.FirstOrDefault(w => w.Id == walletId.Value); + if (wallet is null) + return Result.Failure>($"Wallet {walletId.Value} not found."); + + return Result.Success(wallet.CloudBackup is null + ? Maybe.None + : Maybe.From(wallet.CloudBackup)); + } + + public async Task> EnableAsync( + WalletId walletId, string recoveryPassphrase, string label, CancellationToken cancellationToken = default) + { + var payloadResult = await BuildPayload(walletId, label); + if (payloadResult.IsFailure) + return Result.Failure(payloadResult.Error); + + var createResult = await cloudBackupService.CreateBackupAsync(recoveryPassphrase, payloadResult.Value, cancellationToken: cancellationToken); + if (createResult.IsFailure) + return Result.Failure(createResult.Error); + + var persistResult = await PersistRecord(walletId, createResult.Value.Record); + if (persistResult.IsFailure) + return Result.Failure(persistResult.Error); + + return Result.Success(createResult.Value.Result); + } + + public async Task DisableAsync(WalletId walletId) + { + var listResult = await walletStore.GetAll(); + if (listResult.IsFailure) + return Result.Failure(listResult.Error); + + var wallets = listResult.Value.ToList(); + var wallet = wallets.FirstOrDefault(w => w.Id == walletId.Value); + if (wallet is null) + return Result.Failure($"Wallet {walletId.Value} not found."); + if (wallet.CloudBackup is null) + return Result.Success(); + + // Note: we do NOT delete the relay event or Blossom blobs here. Without the passphrase + // we cannot sign delete events; the data on the network will be naturally pruned over time. + // We only clear local pointers so the app no longer treats this wallet as backed-up. + wallet.CloudBackup = null; + return await walletStore.SaveAll(wallets); + } + + public async Task> RefreshAsync( + WalletId walletId, string recoveryPassphrase, CancellationToken cancellationToken = default) + { + var status = await GetStatus(walletId); + if (status.IsFailure) + return Result.Failure(status.Error); + if (status.Value.HasNoValue) + return Result.Failure("Cloud backup is not enabled for this wallet."); + + // Refresh re-runs the whole encrypt/upload/publish path. The deterministic key derivation + // means the blob hash will match if the seed has not changed — Blossom servers will accept + // a duplicate PUT (no-op) and the kind 30078 event will be replaced by the relays' replaceable semantics. + var label = status.Value.Value.ManifestCipherText is { Length: > 0 } ? status.Value.Value.BackupPubKeyHex : string.Empty; + return await EnableAsync(walletId, recoveryPassphrase, label, cancellationToken); + } + + public async Task> VerifyHealthAsync(WalletId walletId, CancellationToken cancellationToken = default) + { + var status = await GetStatus(walletId); + if (status.IsFailure) + return Result.Failure(status.Error); + if (status.Value.HasNoValue) + return Result.Failure("Cloud backup is not enabled for this wallet."); + + var record = status.Value.Value; + var servers = record.Servers + .Concat(networkConfiguration.GetDefaultBackupServerUrls().Select(s => s.Url)) + .Select(u => u.TrimEnd('/')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var probes = servers.Select(async s => + { + var probe = await blossomClient.ExistsAsync(s, record.BlobSha256, cancellationToken); + return (Server: s, Reachable: probe.IsSuccess && probe.Value); + }); + var results = await Task.WhenAll(probes); + + record.ServerHealth = results.ToDictionary(r => r.Server, r => r.Reachable, StringComparer.OrdinalIgnoreCase); + record.LastVerifiedAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + var persist = await PersistRecord(walletId, record); + if (persist.IsFailure) + logger.LogWarning("Failed to persist updated backup health: {Error}", persist.Error); + + var reachable = results.Count(r => r.Reachable); + return Result.Success(new BackupHealthResult( + RelayManifestPublished: false, + ServersReachable: reachable, + ServersChecked: results.Length, + RehealedServers: Array.Empty(), + CheckedAtUnix: record.LastVerifiedAtUnix.Value)); + } + + private async Task> BuildPayload(WalletId walletId, string label) + { + var seedResult = await walletAppService.GetSeedWords(walletId); + if (seedResult.IsFailure) + return Result.Failure(seedResult.Error); + + var network = networkConfiguration.GetNetwork()?.Name ?? string.Empty; + + return Result.Success(new BackupSeedPayload + { + WalletId = walletId.Value, + Mnemonic = seedResult.Value, + Network = network, + Label = label ?? string.Empty, + Bip39Passphrase = string.Empty + }); + } + + private async Task PersistRecord(WalletId walletId, CloudBackupRecord record) + { + var listResult = await walletStore.GetAll(); + if (listResult.IsFailure) + return Result.Failure(listResult.Error); + + var wallets = listResult.Value.ToList(); + var wallet = wallets.FirstOrDefault(w => w.Id == walletId.Value); + if (wallet is null) + return Result.Failure($"Wallet {walletId.Value} not found."); + + wallet.CloudBackup = record; + return await walletStore.SaveAll(wallets); + } +} diff --git a/src/shared/Angor.Shared/INetworkConfiguration.cs b/src/shared/Angor.Shared/INetworkConfiguration.cs index fd372de19..ae9e1fc6a 100644 --- a/src/shared/Angor.Shared/INetworkConfiguration.cs +++ b/src/shared/Angor.Shared/INetworkConfiguration.cs @@ -17,6 +17,7 @@ public interface INetworkConfiguration List GetDefaultExplorerUrls(); List GetDefaultChatAppUrls(); List GetDefaultImageServerUrls(); + List GetDefaultBackupServerUrls(); List GetDiscoveryRelays(); int GetAngorInvestFeePercentage { get; } string GetAngorKey(); diff --git a/src/webapp/Angor.Client/NetworkConfiguration.cs b/src/webapp/Angor.Client/NetworkConfiguration.cs index 909461f5b..6e4e18241 100644 --- a/src/webapp/Angor.Client/NetworkConfiguration.cs +++ b/src/webapp/Angor.Client/NetworkConfiguration.cs @@ -240,6 +240,18 @@ public List GetDefaultImageServerUrls() new SettingsUrl { Name = "nostria (Blossom)", Url = "https://mibo.eu.nostria.app", IsPrimary = false }, }; } + + public List GetDefaultBackupServerUrls() + { + return new List + { + new SettingsUrl { Name = "Angor Blossom", Url = "https://blossom.angor.io", IsPrimary = true }, + new SettingsUrl { Name = "nostr.build", Url = "https://nostr.build", IsPrimary = false }, + new SettingsUrl { Name = "blossom.primal.net", Url = "https://blossom.primal.net", IsPrimary = false }, + new SettingsUrl { Name = "nostria (Blossom)", Url = "https://mibo.eu.nostria.app", IsPrimary = false }, + }; + } + public Dictionary GetDefaultFeatureFlags(string network) { return network switch