Add printable recovery sheet PDFs

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-08-21 20:02:14 -07:00
parent 230bf6fa7c
commit 91fe2ee291
12 changed files with 924 additions and 43 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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<Option<String>, 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,

View File

@@ -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.

View File

@@ -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();

View File

@@ -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,

View File

@@ -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<Op>, 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<Op>,
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<Op>,
x: f32,
y: f32,
size: f32,
font: BuiltinFont,
color: Color,
value: impl Into<String>,
) {
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<Op>) {
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<Op>, 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<Vec<u8>, 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::<String>();
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());
}
}

View File

@@ -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<PublicKey, String> {
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::<SALT_BYTES>(&payload.salt, "backup salt")?;
@@ -264,6 +269,10 @@ fn parse_recovery_secret(input: &str) -> Result<Zeroizing<[u8; RECOVERY_SECRET_B
Ok(Zeroizing::new(secret))
}
pub(crate) fn validate_recovery_secret(input: &str) -> Result<(), String> {
parse_recovery_secret(input).map(|_| ())
}
fn decode_array<const N: usize>(encoded: &str, label: &str) -> Result<[u8; N], String> {
let bytes = URL_SAFE_NO_PAD
.decode(encoded)

View File

@@ -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<string | null>(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<string | null>(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({
</div>
<p className="text-xs leading-5 text-muted-foreground">
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.
</p>
<div className="flex justify-end gap-2">
{recoverySheetPath ? (
<p
className="rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground"
data-testid="two-skd-recovery-sheet-path"
>
Recovery sheet saved to {recoverySheetPath}
</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
data-testid="two-skd-copy-code"
onClick={() => void copyRecoveryCode()}
@@ -244,7 +282,26 @@ export function TwoSkdRecoveryCreator({
)}
{copied ? "Copied" : "Copy code"}
</Button>
<Button onClick={() => handleOpenChange(false)} type="button">
<Button
data-testid="two-skd-save-recovery-sheet"
disabled={pending}
onClick={() => void saveRecoverySheet()}
type="button"
>
{pending ? (
<Spinner className="size-4 border-2" />
) : recoverySheetPath ? (
<Check aria-hidden="true" className="size-4" />
) : (
<FileDown aria-hidden="true" className="size-4" />
)}
{recoverySheetPath ? "Save another PDF" : "Save printable PDF"}
</Button>
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="ghost"
>
Done
</Button>
</div>

View File

@@ -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<string | null> {
return (
(await invokeTauri<string | null>("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,

View File

@@ -568,6 +568,8 @@ type E2eConfig = {
backupEncryptionDelayMs?: number;
/** Native paths returned by successive backup saves. */
backupSavePaths?: Array<string | null>;
/** Native paths returned by successive recovery-sheet PDF saves. */
recoverySheetSavePaths?: Array<string | null>;
/**
* 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;

View File

@@ -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 ({