From 91fe2ee2917eaaad7795e1b2bee1fc0557822934 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:02:14 -0700 Subject: [PATCH] Add printable recovery sheet PDFs Signed-off-by: Jordan Mecom --- desktop/src-tauri/Cargo.lock | 307 +++++++++++-- desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/src/commands/identity.rs | 59 +++ desktop/src-tauri/src/key_backup.rs | 37 +- desktop/src-tauri/src/key_backup_tests.rs | 17 + desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/recovery_sheet.rs | 425 ++++++++++++++++++ desktop/src-tauri/src/two_skd.rs | 11 +- .../settings/ui/TwoSkdRecoveryCreator.tsx | 67 ++- desktop/src/shared/api/tauriIdentity.ts | 13 + desktop/src/testing/e2eBridge.ts | 21 + .../tests/e2e/profile-backup-settings.spec.ts | 6 + 12 files changed, 924 insertions(+), 43 deletions(-) create mode 100644 desktop/src-tauri/src/recovery_sheet.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 8fb776ad84..f72e8efcaa 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -122,6 +122,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -143,6 +149,29 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "allsorts-azul" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab097a7be305dd66b2b6917f5efb6b723129ce4b2cd11aa1a0afcc293c7dc08e" +dependencies = [ + "bitflags 2.13.0", + "brotli-decompressor", + "encoding_rs", + "enumflags2", + "flate2", + "glyph-names", + "log", + "ouroboros", + "pathfinder_geometry", + "rustc-hash", + "tinyvec", + "ucd-trie", + "unicode-canonical-combining-class", + "unicode-general-category", + "unicode-joining-type", +] + [[package]] name = "alsa" version = "0.11.0" @@ -210,7 +239,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -221,7 +250,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -932,6 +961,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -1135,6 +1173,8 @@ dependencies = [ "plist", "png 0.18.1", "portable-pty", + "printpdf", + "qrcode", "regex", "reqwest 0.13.4", "rodio", @@ -1420,6 +1460,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.2.66" @@ -1644,7 +1693,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2311,7 +2360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -2332,8 +2381,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ "aes 0.8.4", - "block-padding", - "cbc", + "block-padding 0.3.3", + "cbc 0.1.2", "dbus", "fastrand", "hkdf", @@ -2489,7 +2538,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2634,6 +2683,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7984231f8b4c72eb3b88c70040dc1e4ff6803fa9169e93c0ac465942d74fa36a" +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -2770,7 +2828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3288,7 +3346,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -3369,7 +3427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", - "weezl", + "weezl 0.1.12", ] [[package]] @@ -3520,6 +3578,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glyph-names" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3531d702d6c1a3ba92a5fb55a404c7b8c476c8e7ca249951077afcbe4bc807f" + [[package]] name = "gobject-sys" version = "0.18.0" @@ -4300,7 +4364,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] @@ -4310,6 +4374,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding 0.4.2", "hybrid-array", ] @@ -4983,6 +5048,32 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lopdf" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2ec995d822e05cabc3f06d196ee43650af3fe4fe38012cacb35e0c3d113b68" +dependencies = [ + "aes 0.9.1", + "bitflags 2.13.0", + "cbc 0.2.1", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap 2.14.0", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "rand 0.10.2", + "rangemap", + "sha2 0.11.0", + "stringprep", + "thiserror 2.0.18", + "weezl 0.2.1", +] + [[package]] name = "lru" version = "0.18.1" @@ -5136,6 +5227,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if 1.0.4", + "digest 0.11.3", +] + [[package]] name = "md5" version = "0.8.1" @@ -5746,7 +5847,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5905,7 +6006,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6307,7 +6408,7 @@ dependencies = [ "bech32 0.11.1", "bip39", "bitcoin_hashes 0.14.101", - "cbc", + "cbc 0.1.2", "chacha20 0.9.1", "chacha20poly1305", "getrandom 0.2.17", @@ -6331,7 +6432,7 @@ dependencies = [ "bech32 0.12.0", "bip39", "bitcoin_hashes 1.2.0", - "cbc", + "cbc 0.1.2", "chacha20 0.9.1", "chacha20poly1305", "faster-hex", @@ -6418,7 +6519,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7122,7 +7223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7148,6 +7249,30 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.118", +] + [[package]] name = "palette" version = "0.7.6" @@ -7265,6 +7390,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -7765,6 +7909,28 @@ dependencies = [ "num-integer", ] +[[package]] +name = "printpdf" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75e1f6d16f80c8f34918a4cc68743d703e8ee5016fac9508e223eb8e60445a0" +dependencies = [ + "allsorts-azul", + "base64 0.22.1", + "flate2", + "getrandom 0.4.3", + "image", + "lopdf", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "time", + "wasm-bindgen", + "wasm-bindgen-futures", + "weezl 0.2.1", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -7827,6 +7993,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "version_check", + "yansi", +] + [[package]] name = "process-wrap" version = "9.1.0" @@ -8069,6 +8248,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" @@ -8148,7 +8333,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8276,6 +8461,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "ratatui" version = "0.30.2" @@ -8879,7 +9070,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8949,7 +9140,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9161,7 +9352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" dependencies = [ "aes 0.8.4", - "cbc", + "cbc 0.1.2", "futures-util", "generic-array", "hkdf", @@ -9216,7 +9407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9816,6 +10007,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket-pktinfo" @@ -9835,7 +10029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10005,6 +10199,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -10849,10 +11054,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10874,7 +11079,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10999,7 +11204,7 @@ dependencies = [ "flate2", "half", "quick-error", - "weezl", + "weezl 0.1.12", "zune-jpeg", ] @@ -11616,7 +11821,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11717,7 +11922,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11783,12 +11988,36 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-canonical-combining-class" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c99d5174052d02ce765418e826597a1be18f32c114e35d9e22f92390239561" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -11807,6 +12036,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -12455,6 +12690,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -12568,7 +12809,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13503,6 +13744,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index c25ca4d0da..0a109dd857 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -133,6 +133,8 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2.3.3" uuid = { version = "1", features = ["v4", "v5"] } png = "0.18" +printpdf = { version = "0.12.6", default-features = false, features = ["png"] } +qrcode = { version = "0.14.1", default-features = false } # wayland-data-control: without it arboard is X11-only on Linux, so copies made # in a Wayland session land in XWayland's clipboard where Wayland-native apps # never see them (set_text still returns Ok). The backing wl-clipboard-rs dep is diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 0bdf29276c..9ac6ca2ec0 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -414,6 +414,65 @@ pub async fn save_2skd_backup_copy( Ok(Some(dest.display().to_string())) } +/// Render and save the secret half of a recovery kit as a printable PDF. +#[tauri::command] +pub async fn save_2skd_recovery_sheet( + backup: String, + recovery_secret: String, + app_handle: tauri::AppHandle, +) -> Result, String> { + use chrono::Datelike; + + let recovery_secret = zeroize::Zeroizing::new(recovery_secret); + crate::two_skd::validate_recovery_secret(&recovery_secret)?; + let npub = crate::two_skd::backup_public_key(&backup)? + .to_bech32() + .map_err(|error| format!("encode recovery-sheet identity: {error}"))?; + + let date = chrono::Local::now().date_naive(); + let month = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ][date.month0() as usize]; + let created_date = format!("{month} {}, {}", date.day(), date.year()); + + let dest = match crate::commands::export_util::pick_save_path( + &app_handle, + crate::recovery_sheet::RECOVERY_SHEET_FILE_NAME, + "Buzz recovery sheet", + &["pdf"], + ) + .await? + { + Some(path) => path, + None => return Ok(None), + }; + + let dest_for_write = dest.clone(); + tokio::task::spawn_blocking(move || { + let pdf = zeroize::Zeroizing::new(crate::recovery_sheet::render_recovery_sheet( + &recovery_secret, + &npub, + &created_date, + )?); + crate::key_backup::write_portable_secret_file(&dest_for_write, &pdf) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))??; + + Ok(Some(dest.display().to_string())) +} + #[tauri::command] pub async fn import_identity( nsec: String, diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index b3d07a48bb..6aba046d43 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -188,6 +188,22 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), /// the destination already exists. After writing, the file is synced and its /// persisted bytes are reread before success is reported. pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { + write_portable_secret_file_with_label(path, ncryptsec.as_bytes(), "backup file") +} + +/// Write secret-bearing bytes to an exclusively created, owner-only file. +/// +/// This supports portable non-text artifacts such as recovery-sheet PDFs while +/// preserving the save-panel and reread guarantees used for key backups. +pub fn write_portable_secret_file(path: &std::path::Path, contents: &[u8]) -> Result<(), String> { + write_portable_secret_file_with_label(path, contents, "recovery sheet") +} + +fn write_portable_secret_file_with_label( + path: &std::path::Path, + contents: &[u8], + label: &str, +) -> Result<(), String> { use std::io::Write; let mut options = std::fs::OpenOptions::new(); @@ -200,23 +216,22 @@ pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Re let mut file = options.open(path).map_err(|error| { if error.kind() == std::io::ErrorKind::AlreadyExists { - "backup file already exists; choose a new filename so the existing backup stays safe" - .to_string() + format!("{label} already exists; choose a new filename so the existing copy stays safe") } else { - format!("create portable backup file: {error}") + format!("create portable {label}: {error}") } })?; let write_result = file - .write_all(ncryptsec.as_bytes()) - .map_err(|e| format!("write portable backup file: {e}")) + .write_all(contents) + .map_err(|e| format!("write portable {label}: {e}")) .and_then(|()| { file.sync_all() - .map_err(|e| format!("sync portable backup file: {e}")) + .map_err(|e| format!("sync portable {label}: {e}")) }); drop(file); - let result = write_result.and_then(|()| verify_backup_file(path, ncryptsec)); + let result = write_result.and_then(|()| verify_secret_file(path, contents, label)); if result.is_err() { // This function created the destination exclusively, so cleanup cannot // clobber a backup that existed before the save attempt. @@ -225,6 +240,14 @@ pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Re result } +fn verify_secret_file(path: &std::path::Path, contents: &[u8], label: &str) -> Result<(), String> { + let on_disk = std::fs::read(path).map_err(|e| format!("reread {label}: {e}"))?; + if on_disk != contents { + return Err(format!("{label} verification failed: on-disk bytes differ")); + } + Ok(()) +} + fn verify_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { // Reread and byte-compare: only report success for bytes that are // actually on disk. diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index 5140d65e65..3e39162a08 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -220,6 +220,23 @@ fn write_portable_backup_file_persists_0600_without_a_sibling() { } } +#[test] +fn write_portable_secret_file_persists_binary_bytes_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("recovery-sheet.pdf"); + let contents = b"%PDF-1.7\0binary recovery sheet"; + + write_portable_secret_file(&path, contents).unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), contents); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "recovery sheet must be owner-only"); + } +} + #[test] fn write_portable_backup_file_preserves_an_existing_backup() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4c2619f306..230ce7b40e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ mod observed_unread; mod persona_catalog; mod prevent_sleep; mod ptt_shortcut; +mod recovery_sheet; mod relay; mod relay_admission; mod reset; @@ -562,6 +563,7 @@ pub fn run() { verify_2skd_backup, save_ncryptsec_copy, save_2skd_backup_copy, + save_2skd_recovery_sheet, import_identity, persist_current_identity, get_profile, diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs new file mode 100644 index 0000000000..55073c81ab --- /dev/null +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -0,0 +1,425 @@ +//! Printable recovery-code sheet for a Buzz 2SKD recovery kit. +//! +//! The PDF deliberately contains only the recovery code and public identity. +//! It never contains the password, private key, or encrypted backup artifact. + +use printpdf::{ + BuiltinFont, Color, Mm, Op, PaintMode, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, + Point, Pt, RawImage, Rect, Rgb, TextItem, WindingOrder, XObjectTransform, +}; +use qrcode::{Color as QrColor, EcLevel, QrCode}; + +pub const RECOVERY_SHEET_FILE_NAME: &str = "buzz-recovery-sheet.pdf"; + +const PAGE_WIDTH_MM: f32 = 215.9; +const PAGE_HEIGHT_MM: f32 = 279.4; +const BUZZ_WORDMARK: &[u8] = include_bytes!("../../public/landing/buzz-wordmark.png"); + +fn charcoal() -> Color { + Color::Rgb(Rgb::new(35.0 / 255.0, 30.0 / 255.0, 30.0 / 255.0, None)) +} + +fn buzz_yellow() -> Color { + Color::Rgb(Rgb::new(215.0 / 255.0, 215.0 / 255.0, 46.0 / 255.0, None)) +} + +fn cream() -> Color { + Color::Rgb(Rgb::new(247.0 / 255.0, 247.0 / 255.0, 235.0 / 255.0, None)) +} + +fn muted() -> Color { + Color::Rgb(Rgb::new(100.0 / 255.0, 94.0 / 255.0, 83.0 / 255.0, None)) +} + +fn white() -> Color { + Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)) +} + +fn rect(x: f32, y: f32, width: f32, height: f32, mode: PaintMode) -> Rect { + Rect { + x: Mm(x).into(), + y: Mm(y).into(), + width: Mm(width).into(), + height: Mm(height).into(), + mode: Some(mode), + winding_order: Some(WindingOrder::NonZero), + } +} + +fn fill_rect(ops: &mut Vec, x: f32, y: f32, width: f32, height: f32, color: Color) { + ops.push(Op::SetFillColor { col: color }); + ops.push(Op::DrawRectangle { + rectangle: rect(x, y, width, height, PaintMode::Fill), + }); +} + +fn fill_stroke_rect( + ops: &mut Vec, + x: f32, + y: f32, + width: f32, + height: f32, + fill: Color, + stroke: Color, +) { + ops.push(Op::SetFillColor { col: fill }); + ops.push(Op::SetOutlineColor { col: stroke }); + ops.push(Op::SetOutlineThickness { pt: Pt(0.8) }); + ops.push(Op::DrawRectangle { + rectangle: rect(x, y, width, height, PaintMode::FillStroke), + }); +} + +fn text( + ops: &mut Vec, + x: f32, + y: f32, + size: f32, + font: BuiltinFont, + color: Color, + value: impl Into, +) { + ops.extend([ + Op::StartTextSection, + Op::SetFillColor { col: color }, + Op::SetTextCursor { + pos: Point::new(Mm(x), Mm(y)), + }, + Op::SetFont { + font: PdfFontHandle::Builtin(font), + size: Pt(size), + }, + Op::ShowText { + items: vec![TextItem::Text(value.into())], + }, + Op::EndTextSection, + ]); +} + +fn draw_dot_grid(ops: &mut Vec) { + let dot = Color::Rgb(Rgb::new(211.0 / 255.0, 209.0 / 255.0, 198.0 / 255.0, None)); + let mut y = 215.0; + let mut row = 0usize; + while y < 274.0 { + let offset = if row.is_multiple_of(2) { 0.0 } else { 3.5 }; + let mut x = 136.0 + offset; + while x < 211.0 { + fill_rect(ops, x, y, 0.4, 0.4, dot.clone()); + x += 7.0; + } + y += 7.0; + row += 1; + } +} + +fn draw_qr(ops: &mut Vec, recovery_secret: &str) -> Result<(), String> { + let code = QrCode::with_error_correction_level(recovery_secret.as_bytes(), EcLevel::Q) + .map_err(|error| format!("encode recovery-code QR: {error}"))?; + let quiet_modules = 4usize; + let total_modules = code.width() + quiet_modules * 2; + let qr_size_mm = 56.0f32; + let module_mm = qr_size_mm / total_modules as f32; + let qr_x = 136.0f32; + let qr_y = 105.0f32; + + fill_rect(ops, qr_x, qr_y, qr_size_mm, qr_size_mm, white()); + for y in 0..code.width() { + for x in 0..code.width() { + if code[(x, y)] == QrColor::Dark { + let module_x = qr_x + (x + quiet_modules) as f32 * module_mm; + let module_y = qr_y + (code.width() - 1 - y + quiet_modules) as f32 * module_mm; + fill_rect(ops, module_x, module_y, module_mm, module_mm, charcoal()); + } + } + } + Ok(()) +} + +fn split_npub(npub: &str) -> (&str, &str) { + let split_at = npub.len().min(32); + npub.split_at(split_at) +} + +/// Render a one-page, print-ready recovery sheet. +pub(crate) fn render_recovery_sheet( + recovery_secret: &str, + npub: &str, + created_date: &str, +) -> Result, String> { + crate::two_skd::validate_recovery_secret(recovery_secret)?; + if !npub.starts_with("npub1") || npub.len() < 20 { + return Err("invalid recovery-sheet identity".to_string()); + } + + let mut document = PdfDocument::new("Buzz recovery sheet"); + let mut ops = Vec::new(); + + fill_rect(&mut ops, 0.0, 0.0, PAGE_WIDTH_MM, PAGE_HEIGHT_MM, white()); + fill_rect(&mut ops, 0.0, 276.4, PAGE_WIDTH_MM, 3.0, buzz_yellow()); + draw_dot_grid(&mut ops); + + let mut warnings = Vec::new(); + let wordmark = RawImage::decode_from_bytes(BUZZ_WORDMARK, &mut warnings) + .map_err(|error| format!("decode Buzz wordmark: {error}"))?; + let wordmark_id = document.add_image(&wordmark); + ops.push(Op::UseXobject { + id: wordmark_id, + transform: XObjectTransform { + translate_x: Some(Mm(18.0).into()), + translate_y: Some(Mm(242.0).into()), + scale_x: Some(0.82), + scale_y: Some(0.82), + dpi: Some(300.0), + ..Default::default() + }, + }); + + text( + &mut ops, + 18.0, + 231.0, + 8.0, + BuiltinFont::HelveticaBold, + charcoal(), + "RECOVERY SHEET / KEEP OFFLINE", + ); + text( + &mut ops, + 18.0, + 211.0, + 27.0, + BuiltinFont::HelveticaBold, + charcoal(), + "Keep this separate.", + ); + text( + &mut ops, + 18.0, + 200.5, + 11.0, + BuiltinFont::Helvetica, + charcoal(), + "This paper is one of three things required to recover your identity.", + ); + fill_rect(&mut ops, 18.0, 188.0, 24.0, 2.0, buzz_yellow()); + + fill_stroke_rect(&mut ops, 18.0, 78.0, 179.9, 104.0, white(), charcoal()); + text( + &mut ops, + 28.0, + 165.0, + 8.0, + BuiltinFont::HelveticaBold, + muted(), + "YOUR RECOVERY CODE", + ); + text( + &mut ops, + 28.0, + 151.0, + 19.0, + BuiltinFont::HelveticaBold, + charcoal(), + "One code.", + ); + text( + &mut ops, + 28.0, + 142.5, + 19.0, + BuiltinFont::HelveticaBold, + charcoal(), + "Store it offline.", + ); + fill_stroke_rect(&mut ops, 28.0, 122.0, 96.0, 11.0, cream(), muted()); + fill_rect(&mut ops, 28.0, 122.0, 2.0, 11.0, buzz_yellow()); + text( + &mut ops, + 32.0, + 126.0, + 8.0, + BuiltinFont::CourierBold, + charcoal(), + recovery_secret, + ); + + text( + &mut ops, + 28.0, + 111.0, + 7.0, + BuiltinFont::HelveticaBold, + muted(), + "BUZZ IDENTITY", + ); + let (npub_first, npub_second) = split_npub(npub); + text( + &mut ops, + 28.0, + 104.5, + 7.5, + BuiltinFont::Courier, + charcoal(), + npub_first, + ); + text( + &mut ops, + 28.0, + 99.5, + 7.5, + BuiltinFont::Courier, + charcoal(), + npub_second, + ); + text( + &mut ops, + 28.0, + 89.0, + 7.0, + BuiltinFont::HelveticaBold, + muted(), + format!("CREATED {created_date}"), + ); + + draw_qr(&mut ops, recovery_secret)?; + text( + &mut ops, + 143.0, + 96.0, + 7.0, + BuiltinFont::Helvetica, + muted(), + "QR CONTAINS THE RECOVERY CODE", + ); + + text( + &mut ops, + 18.0, + 67.5, + 8.0, + BuiltinFont::HelveticaBold, + muted(), + "RECOVERY NEEDS ALL THREE", + ); + let requirements = [ + ("1", "Encrypted backup", "identity.buzzbackup"), + ("2", "Your password", "Known only to you"), + ("3", "This sheet", "Stored separately"), + ]; + for (index, (number, title, detail)) in requirements.iter().enumerate() { + let x = 18.0 + index as f32 * 61.0; + fill_stroke_rect(&mut ops, x, 39.0, 57.9, 23.0, white(), charcoal()); + fill_rect(&mut ops, x, 54.0, 8.0, 8.0, buzz_yellow()); + text( + &mut ops, + x + 2.55, + 56.2, + 8.0, + BuiltinFont::HelveticaBold, + charcoal(), + *number, + ); + text( + &mut ops, + x + 4.0, + 48.0, + 9.0, + BuiltinFont::HelveticaBold, + charcoal(), + *title, + ); + text( + &mut ops, + x + 4.0, + 42.5, + 7.0, + BuiltinFont::Helvetica, + muted(), + *detail, + ); + } + + fill_stroke_rect(&mut ops, 18.0, 14.0, 179.9, 17.0, white(), charcoal()); + fill_rect(&mut ops, 18.0, 14.0, 3.0, 17.0, buzz_yellow()); + text( + &mut ops, + 24.0, + 24.0, + 9.0, + BuiltinFont::HelveticaBold, + charcoal(), + "DO NOT STORE THIS SHEET WITH YOUR ENCRYPTED BACKUP.", + ); + text( + &mut ops, + 24.0, + 18.5, + 8.0, + BuiltinFont::Helvetica, + muted(), + "Buzz cannot recreate this code or reset your recovery password.", + ); + text( + &mut ops, + 18.0, + 6.0, + 7.0, + BuiltinFont::HelveticaBold, + muted(), + "buzz.xyz / recovery sheet v1", + ); + + let page = PdfPage::new(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), ops); + let bytes = document + .with_pages(vec![page]) + .save(&PdfSaveOptions::default(), &mut warnings); + if !bytes.starts_with(b"%PDF-") { + return Err("render recovery sheet: invalid PDF output".to_string()); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use printpdf::{PdfDocument, PdfParseOptions}; + + const RECOVERY_CODE: &str = "buzz-recovery-v1-00112233445566778899aabbccddeeff"; + const NPUB: &str = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5hq3g5"; + + #[test] + fn renders_a_single_page_with_recovery_material_and_no_backup() { + let bytes = render_recovery_sheet(RECOVERY_CODE, NPUB, "August 21, 2026").unwrap(); + let mut warnings = Vec::new(); + let parsed = PdfDocument::parse( + &bytes, + &PdfParseOptions { + fail_on_error: true, + }, + &mut warnings, + ) + .unwrap(); + let text = parsed + .extract_text() + .into_iter() + .flatten() + .collect::(); + + assert_eq!(parsed.pages.len(), 1); + assert!(text.contains(RECOVERY_CODE)); + assert!(text.contains("Keep this separate.")); + assert!(text.contains(NPUB)); + assert!(!text.contains("buzz2skd1:")); + assert!(!text.contains("password=")); + + if let Ok(path) = std::env::var("BUZZ_RECOVERY_SHEET_PREVIEW") { + std::fs::write(path, bytes).unwrap(); + } + } + + #[test] + fn rejects_malformed_recovery_material() { + assert!(render_recovery_sheet("not-a-code", NPUB, "August 21, 2026").is_err()); + assert!(render_recovery_sheet(RECOVERY_CODE, "not-an-npub", "August 21, 2026").is_err()); + } +} diff --git a/desktop/src-tauri/src/two_skd.rs b/desktop/src-tauri/src/two_skd.rs index 9504f0562b..e660a55f8b 100644 --- a/desktop/src-tauri/src/two_skd.rs +++ b/desktop/src-tauri/src/two_skd.rs @@ -12,7 +12,7 @@ use base64::Engine; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use hkdf::Hkdf; -use nostr::{Keys, SecretKey}; +use nostr::{Keys, PublicKey, SecretKey}; use serde::{Deserialize, Serialize}; use sha2_legacy::Sha256; use unicode_normalization::UnicodeNormalization; @@ -240,6 +240,11 @@ pub(crate) fn validate_backup(input: &str) -> Result<(), String> { parse_backup(input).map(|_| ()) } +pub(crate) fn backup_public_key(input: &str) -> Result { + let payload = parse_backup(input)?; + PublicKey::parse(&payload.pubkey).map_err(|_| "invalid backup public key".to_string()) +} + fn validate_payload_shape(payload: &BackupPayload) -> Result<(), String> { validate_cost(payload.cost())?; decode_array::(&payload.salt, "backup salt")?; @@ -264,6 +269,10 @@ fn parse_recovery_secret(input: &str) -> Result Result<(), String> { + parse_recovery_secret(input).map(|_| ()) +} + fn decode_array(encoded: &str, label: &str) -> Result<[u8; N], String> { let bytes = URL_SAFE_NO_PAD .decode(encoded) diff --git a/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx b/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx index 66cecba42c..59821aa7fb 100644 --- a/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx +++ b/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx @@ -1,10 +1,11 @@ -import { Check, Copy, Eye, EyeOff, ShieldCheck } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, FileDown, ShieldCheck } from "lucide-react"; import * as React from "react"; import { createTwoSkdBackup, type CreatedTwoSkdBackup, saveTwoSkdBackupCopy, + saveTwoSkdRecoverySheet, } from "@/shared/api/tauriIdentity"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; @@ -35,6 +36,9 @@ export function TwoSkdRecoveryCreator({ null, ); const [savedPath, setSavedPath] = React.useState(null); + const [recoverySheetPath, setRecoverySheetPath] = React.useState< + string | null + >(null); const [pending, setPending] = React.useState(false); const [copied, setCopied] = React.useState(false); const [error, setError] = React.useState(null); @@ -47,6 +51,7 @@ export function TwoSkdRecoveryCreator({ setRevealed(false); setCreated(null); setSavedPath(null); + setRecoverySheetPath(null); setPending(false); setCopied(false); setError(null); @@ -118,6 +123,30 @@ export function TwoSkdRecoveryCreator({ } }, [created]); + const saveRecoverySheet = React.useCallback(async () => { + if (!created || pending) return; + const requestId = ++requestRef.current; + setPending(true); + setError(null); + try { + const path = await saveTwoSkdRecoverySheet( + created.backup, + created.recoverySecret, + ); + if (requestId !== requestRef.current) return; + if (path) setRecoverySheetPath(path); + } catch (cause) { + if (requestId !== requestRef.current) return; + setError( + cause instanceof Error + ? cause.message + : "Could not save the recovery sheet.", + ); + } finally { + if (requestId === requestRef.current) setPending(false); + } + }, [created, pending]); + const retrySave = React.useCallback(async () => { if (!created || pending) return; const requestId = ++requestRef.current; @@ -227,10 +256,19 @@ export function TwoSkdRecoveryCreator({

The encrypted backup was saved to {savedPath}. Put this code in a - different place, such as your password manager or a printed QR. - Buzz cannot recover it later. + different place. The printable PDF includes a QR code and recovery + instructions, but never your password or encrypted backup. Buzz + cannot recreate it later.

-
+ {recoverySheetPath ? ( +

+ Recovery sheet saved to {recoverySheetPath} +

+ ) : null} +
- +
diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index 4b4f360727..7451f8d957 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -104,6 +104,19 @@ export async function saveTwoSkdBackupCopy( ); } +/** Save the recovery code and public identity as a printable PDF. */ +export async function saveTwoSkdRecoverySheet( + backup: string, + recoverySecret: string, +): Promise { + return ( + (await invokeTauri("save_2skd_recovery_sheet", { + backup, + recoverySecret, + })) ?? null + ); +} + /** Save a portable backup copy. Returns null when the native dialog is cancelled. */ export async function saveNcryptsecCopy( ncryptsec: string, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1005679baf..aa26b02da8 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -568,6 +568,8 @@ type E2eConfig = { backupEncryptionDelayMs?: number; /** Native paths returned by successive backup saves. */ backupSavePaths?: Array; + /** Native paths returned by successive recovery-sheet PDF saves. */ + recoverySheetSavePaths?: Array; /** * When set, `get_nsec` throws with this message instead of returning the * mock nsec string. Use `nsecErrors` for sequenced failure/success. @@ -8168,6 +8170,7 @@ let mockGlobalAgentConfig: { let nsecCallCount = 0; let backupVerificationCallCount = 0; let backupSaveCallCount = 0; +let recoverySheetSaveCallCount = 0; const MOCK_NCRYPTSEC = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; @@ -11769,6 +11772,24 @@ export function maybeInstallE2eTauriMocks() { backupSaveCallCount += 1; return paths[index]; } + case "save_2skd_recovery_sheet": { + const request = payload as { + backup?: string; + recoverySecret?: string; + } | null; + if ( + request?.backup !== MOCK_TWO_SKD_BACKUP || + request?.recoverySecret !== MOCK_RECOVERY_SECRET + ) { + throw new Error("invalid recovery sheet material"); + } + const paths = activeConfig?.mock?.recoverySheetSavePaths ?? [ + "/tmp/buzz-recovery-sheet.pdf", + ]; + const index = Math.min(recoverySheetSaveCallCount, paths.length - 1); + recoverySheetSaveCallCount += 1; + return paths[index]; + } case "verify_ncryptsec_backup": { const request = payload as { password?: string } | null; const configuredErrors = activeConfig?.mock?.backupVerificationErrors; diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts index a6972e8025..1fc05d20c8 100644 --- a/desktop/tests/e2e/profile-backup-settings.spec.ts +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -130,6 +130,7 @@ test("2SKD recovery kit keeps the recovery code separate from the saved backup", }) => { await openBackupSettings(page, { backupSavePaths: ["/Users/test/Downloads/identity.buzzbackup"], + recoverySheetSavePaths: ["/Users/test/Documents/buzz-recovery-sheet.pdf"], }); const dialog = await openCreateRecoveryKit(page); const password = dialog.getByLabel("Recovery password"); @@ -147,11 +148,16 @@ test("2SKD recovery kit keeps the recovery code separate from the saved backup", await expect(dialog).toContainText( "/Users/test/Downloads/identity.buzzbackup", ); + await dialog.getByTestId("two-skd-save-recovery-sheet").click(); + await expect(dialog.getByTestId("two-skd-recovery-sheet-path")).toContainText( + "/Users/test/Documents/buzz-recovery-sheet.pdf", + ); const commands = await page.evaluate( () => window.__BUZZ_E2E_COMMANDS__ ?? [], ); expect(commands).toContain("create_2skd_backup"); expect(commands).toContain("save_2skd_backup_copy"); + expect(commands).toContain("save_2skd_recovery_sheet"); }); test("2SKD recovery kit verification requires the code and password", async ({