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