diff --git a/README.md b/README.md index bb00927..23ea70a 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,29 @@ system's on Windows (`GetLastInputInfo`) and macOS; on Linux it is activity insi and fills). Screen lock: Windows input-desktop check, Linux logind `LockedHint`, macOS session dictionary. Sleep: a jump in the wall clock between polls. +## Sync and onboarding (docs/03, docs/10) + +* **First start** offers four ways in: **Use my phone** (default: type the server URL, scan the QR on screen + with a signed-in phone, compare the 6-digit code, then type only the master password), **Emergency Kit** + (PDF, or a PNG/JPEG photo of its QR), **Paste setup code**, and **New account** (server URL + the server's + `KV_SETUP_CODE`). +* **Device key** (`device_key.rs`): Windows TPM via CNG, else a DPAPI-sealed software key; Linux Secret Service, + else a 0600 file (with a warning in Settings); macOS dev runs use the keychain. `KESTROVE_DEVICE_KEY=file` + forces the file store. +* **Sync** (`sync.rs`) runs after unlock, after a save, every 15 minutes while unlocked, and on "Sync now". + Lock drops all keys (core), cancels a scan-to-join and revokes an "Add a device" ticket still on screen. + An integrity alarm shows a hard alert and a banner; syncing that vault stops. +* **Settings → Devices**: approve pending devices (compare fingerprints), remove devices, **Add a phone or + tablet** (10-minute single-use QR; Done/Cancel revokes it) and **Approve a computer** (paste the join code a + new computer shows under "No phone?"). +* **Two instances on one machine**: `KESTROVE_DATA_DIR=/tmp/kv-b Kestrove.app/Contents/MacOS/kestrove-desktop` + (the second one has no browser integration). `KESTROVE_DEVICE_NAME` overrides the device name. +* **Live test**: `tests/live_sync.rs` (ignored) drives three instances against a running server; see its header. + ## Roadmap (M3, kestrove-spec/docs/07) - [ ] Unlock with Windows Hello (KeyCredentialManager, TPM) and libsecret on Linux -- [x] Persistent vault (`VaultCore::open` in app data); sync UI still to do +- [x] Persistent vault (`VaultCore::open` in app data) +- [x] Sync, device approval and scan-to-join onboarding - [x] Auto-lock on idle, screen lock and suspend - [x] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel - [x] Native-messaging manifest installers (`native-messaging/install.sh`, `install.ps1`) diff --git a/index.html b/index.html index 54ba9c1..6720207 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,7 @@
+ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 342290a..e907fcc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -25,10 +25,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "aes-gcm" version = "0.10.3" @@ -36,8 +47,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -144,6 +155,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arboard" version = "3.7.0" @@ -223,6 +245,30 @@ dependencies = [ "nom", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compat" version = "0.2.6" @@ -236,6 +282,113 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atk" version = "0.18.2" @@ -355,7 +508,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -380,6 +533,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-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" @@ -389,6 +560,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "9.0.0" @@ -430,6 +614,20 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] [[package]] name = "byteorder" @@ -534,6 +732,15 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", ] +[[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.8.0" @@ -590,7 +797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -602,7 +809,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -652,11 +859,21 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.4", +] + [[package]] name = "clap" version = "4.6.7" @@ -706,6 +923,21 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -722,12 +954,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -784,6 +1031,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -855,6 +1108,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.37.0" @@ -890,7 +1152,16 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", ] [[package]] @@ -902,7 +1173,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rand_core", "rustc_version", @@ -1009,7 +1280,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1061,12 +1332,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "7.0.0" @@ -1192,7 +1475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1219,7 +1502,7 @@ dependencies = [ "ed25519", "rand_core", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -1232,11 +1515,11 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", - "hkdf", + "hkdf 0.12.4", "pem-rfc7468", "pkcs8", "rand_core", @@ -1265,6 +1548,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -1298,6 +1608,35 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1331,6 +1670,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4beca3cb2444e3304ac30843cc091f44ed58932353cd492ce740067bfce6b12" + +[[package]] +name = "fearless_simd" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" + [[package]] name = "ff" version = "0.13.1" @@ -1386,6 +1737,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1463,6 +1823,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1502,6 +1875,34 @@ dependencies = [ "slab", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "gdk" version = "0.18.2" @@ -1748,6 +2149,23 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glifo" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1218dedd5348c1b166ec3b8b3cbca49d2f527bd43b3fa072ea5123c523ed3f" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "skrifa 0.44.0", + "smallvec", + "vello_common", +] + [[package]] name = "glob" version = "0.3.4" @@ -1839,6 +2257,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" version = "0.4.20" @@ -1889,6 +2316,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "hashlink" @@ -1899,6 +2329,97 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "hayro" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4675ff5acd7ca41c0c34ccd2a3a154f5c86d8b5ec92bef0a114bfb194969e2af" +dependencies = [ + "bytemuck", + "fearless_simd 1.1.0", + "hayro-interpret", + "image", + "kurbo", + "pic-scale", + "rustc-hash", + "vello_cpu", +] + +[[package]] +name = "hayro-ccitt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms", + "phf", + "rustc-hash", + "siphasher 1.0.4", + "skrifa 0.46.2", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9" +dependencies = [ + "fearless_simd 1.1.0", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835" +dependencies = [ + "fearless_simd 1.1.0", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash", + "smallvec", + "zune-jpeg", +] + [[package]] name = "heck" version = "0.4.1" @@ -1911,6 +2432,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1923,7 +2450,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -1932,7 +2468,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -1944,13 +2489,13 @@ dependencies = [ "aead", "aes-gcm", "chacha20poly1305", - "digest", + "digest 0.10.7", "generic-array", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "p256", "rand_core", - "sha2", + "sha2 0.10.9", "subtle", "x25519-dalek", "zeroize", @@ -2005,6 +2550,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.12.0" @@ -2221,6 +2775,8 @@ dependencies = [ "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2264,6 +2820,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2445,15 +3011,22 @@ dependencies = [ "base64 0.22.1", "core-foundation", "getrandom 0.2.17", - "hkdf", + "hayro", + "hkdf 0.12.4", + "image", + "keyring", "kv-ffi", "libc", + "p256", + "qrcode", + "rqrr", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tauri", "tauri-build", "tempfile", + "tokio", "url", "uuid", "windows-sys 0.61.2", @@ -2471,6 +3044,39 @@ dependencies = [ "serde", ] +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" +dependencies = [ + "log", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "kv-crypto" version = "0.1.0" @@ -2478,11 +3084,11 @@ dependencies = [ "argon2", "chacha20poly1305", "ed25519-dalek", - "hkdf", + "hkdf 0.12.4", "hpke", "kv-proto", "rand_core", - "sha2", + "sha2 0.10.9", "thiserror 2.0.21", "unicode-normalization", "uuid", @@ -2493,7 +3099,8 @@ dependencies = [ name = "kv-ffi" version = "0.1.0" dependencies = [ - "hkdf", + "base64 0.22.1", + "hkdf 0.12.4", "kv-crypto", "kv-import", "kv-model", @@ -2502,7 +3109,7 @@ dependencies = [ "kv-sync", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 2.0.21", "tokio", "uniffi", @@ -2535,14 +3142,14 @@ name = "kv-model" version = "0.1.0" dependencies = [ "data-encoding", - "hmac", + "hmac 0.12.1", "p256", "psl", "rand", "serde", "serde_json", "sha1", - "sha2", + "sha2 0.10.9", "thiserror 2.0.21", "url", "uuid", @@ -2556,7 +3163,7 @@ dependencies = [ "base64 0.22.1", "opaque-ke", "serde", - "sha2", + "sha2 0.10.9", "uuid", ] @@ -2578,6 +3185,7 @@ dependencies = [ "base64 0.22.1", "blake3", "ciborium", + "js-sys", "kv-crypto", "kv-model", "kv-proto", @@ -2590,7 +3198,7 @@ dependencies = [ "rustls-webpki", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 2.0.21", "time", "tokio", @@ -2669,6 +3277,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2696,6 +3310,15 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -2864,12 +3487,75 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3124,13 +3810,13 @@ checksum = "fb31f7b2c5760d8ffbc39652043f5cff14029b6249a2280b28ae6f0cd61f5098" dependencies = [ "curve25519-dalek", "derive-where", - "digest", + "digest 0.10.7", "displaydoc", "elliptic-curve", "generic-array", "getrandom 0.2.17", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "rand", "serde", "subtle", @@ -3144,6 +3830,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -3153,7 +3849,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3181,6 +3877,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3230,6 +3932,19 @@ dependencies = [ "base64ct", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3289,12 +4004,33 @@ dependencies = [ "siphasher 1.0.4", ] +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3356,6 +4092,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3367,6 +4117,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3516,6 +4275,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" @@ -3588,6 +4353,28 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3733,7 +4520,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -3751,6 +4538,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rqrr" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b" +dependencies = [ + "g2p", + "lru", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3940,6 +4737,48 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes 0.9.3", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf 0.13.0", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.1", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.38.0" @@ -4148,7 +4987,7 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -4159,7 +4998,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -4168,13 +5018,23 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core", ] @@ -4196,6 +5056,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6" +dependencies = [ + "bytemuck", + "read-fonts 0.43.3", +] + [[package]] name = "slab" version = "0.4.12" @@ -4560,7 +5440,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.119", "tauri-utils", "thiserror 2.0.21", @@ -5023,9 +5903,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -5081,6 +5973,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicase" version = "2.10.0" @@ -5239,7 +6142,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -5304,6 +6207,35 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello_common" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c2fbe53548bcd25c8ff42f75a93770a8611a16b37bee56d7ef7b44e36027f1" +dependencies = [ + "bytemuck", + "fearless_simd 0.7.0", + "guillotiere", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1fec424fbba6acf6cbc78bb526447eb77590d95f40419d598870fe37535212" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -5324,13 +6256,13 @@ checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" dependencies = [ "curve25519-dalek", "derive-where", - "digest", + "digest 0.10.7", "displaydoc", "elliptic-curve", "generic-array", "rand_core", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -5721,6 +6653,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -5985,7 +6930,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.21", @@ -6072,6 +7017,87 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -6206,3 +7232,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 52fa1d2..3ee7131 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,12 +40,25 @@ base64 = "0.22" uuid = { version = "1", features = ["v4"] } url = "2" zeroize = "1" +# Device key (docs/03 "Device keys"): P-256 software key where no hardware key is available. +p256 = { version = "0.13", features = ["ecdsa"] } +# Onboarding (docs/10): render QR codes locally, read the Emergency Kit QR from an image or a PDF. +qrcode = { version = "0.14", default-features = false, features = ["svg"] } +rqrr = { version = "0.11", default-features = false } +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +hayro = { version = "0.8", default-features = false } +tokio = { version = "1", features = ["time", "sync", "macros"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +# macOS (dev) keychain / Linux Secret Service for the device key. Windows uses CNG (TPM) or DPAPI instead. +[target.'cfg(not(windows))'.dependencies] +keyring = "4" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ + "Win32_Security_Cryptography", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", @@ -63,6 +76,7 @@ core-foundation = "0.10" [dev-dependencies] tempfile = "3" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [profile.release] lto = true diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml index 9fc2f25..6aa153e 100644 --- a/src-tauri/deny.toml +++ b/src-tauri/deny.toml @@ -9,6 +9,11 @@ ignore = [ { id = "RUSTSEC-2025-0141", reason = "bincode 1.x via uniffi_macros 0.28 (build-time)" }, { id = "RUSTSEC-2024-0436", reason = "paste via uniffi 0.28 (proc-macro)" }, { id = "RUSTSEC-2024-0370", reason = "proc-macro-error via glib-macros 0.18 / Tauri gtk stack (proc-macro)" }, + # zbus 5 sub-crates folded into zbus 6 (no vulnerability). Linux only: keyring's Secret Service store + # (device key) still uses zbus 5; revisit when secret-service moves to zbus 6. + { id = "RUSTSEC-2026-0370", reason = "zvariant via zbus 5 (keyring Secret Service store, Linux)" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names via zbus 5 (keyring Secret Service store, Linux)" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive via zbus 5 (keyring Secret Service store, Linux)" }, ] [licenses] diff --git a/src-tauri/src/device_key.rs b/src-tauri/src/device_key.rs new file mode 100644 index 0000000..114c313 --- /dev/null +++ b/src-tauri/src/device_key.rs @@ -0,0 +1,545 @@ +//! This device's P-256 request-signing key (docs/03 "Device keys"), behind kv-ffi's `DeviceSigner`. +//! +//! Where the key lives, best first: +//! * Windows: a non-exportable key in the TPM (CNG "Microsoft Platform Crypto Provider"); without a TPM, a +//! software key sealed with DPAPI (current user) in the app-data folder. +//! * Linux: a software key in the Secret Service (GNOME Keyring, KWallet); without one, a 0600 file (with a warning). +//! * macOS (development only; the shipped Mac app is kestrove-apple): the login keychain. +//! +//! The store that created the key is recorded next to the device id. A later launch uses only that store: if it +//! is unavailable, signing fails instead of silently enrolling a new device. + +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; + +use kv_ffi::CoreError; +use p256::ecdsa::{signature::Signer as _, Signature, SigningKey}; +use zeroize::Zeroizing; + +use crate::private_file; + +const ID_FILE: &str = "device-id"; +const STORE_FILE: &str = "device-key-store"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyStorage { + /// Windows TPM via CNG; the private key never leaves the chip. + Tpm, + /// Windows software key sealed with DPAPI (current user). + Dpapi, + /// macOS keychain (development builds). + Keychain, + /// Linux Secret Service. + SecretService, + /// Owner-only file in the app-data folder (Linux without a Secret Service, tests). + File, +} + +impl KeyStorage { + fn tag(self) -> &'static str { + match self { + KeyStorage::Tpm => "tpm", + KeyStorage::Dpapi => "dpapi", + KeyStorage::Keychain => "keychain", + KeyStorage::SecretService => "secret-service", + KeyStorage::File => "file", + } + } + + fn from_tag(s: &str) -> Option { + [KeyStorage::Tpm, KeyStorage::Dpapi, KeyStorage::Keychain, KeyStorage::SecretService, KeyStorage::File] + .into_iter() + .find(|k| k.tag() == s.trim()) + } + + /// For the settings screen. + pub fn describe(self) -> &'static str { + match self { + KeyStorage::Tpm => "TPM (hardware)", + KeyStorage::Dpapi => "Windows DPAPI (software key)", + KeyStorage::Keychain => "macOS keychain (software key)", + KeyStorage::SecretService => "Secret Service keyring (software key)", + KeyStorage::File => "file in the app folder (software key)", + } + } +} + +enum Backend { + Software(SigningKey), + #[cfg(windows)] + Cng(cng::Key), +} + +pub struct DeviceKey { + id: String, + public: Vec, + backend: Backend, + storage: KeyStorage, +} + +impl fmt::Debug for DeviceKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DeviceKey").field("id", &self.id).field("storage", &self.storage).finish_non_exhaustive() + } +} + +impl DeviceKey { + /// Loads this device's key, creating it (and a new device id) on first use in the best store available. + pub fn load_or_create(data_dir: &Path) -> io::Result { + Self::load_or_create_with(data_dir, &platform_stores()) + } + + /// Same, but only in an owner-only file. For tests and for `KESTROVE_DEVICE_KEY=file` dev runs. + pub fn load_or_create_file(data_dir: &Path) -> io::Result { + Self::load_or_create_with(data_dir, &[KeyStorage::File]) + } + + fn load_or_create_with(data_dir: &Path, preferred: &[KeyStorage]) -> io::Result { + let id_path = data_dir.join(ID_FILE); + let store_path = data_dir.join(STORE_FILE); + let id = read_text(&id_path)?; + let recorded = read_text(&store_path)?.and_then(|s| KeyStorage::from_tag(&s)); + if let (Some(id), Some(storage)) = (id, recorded) { + if let Some(key) = load(data_dir, &id, storage)? { + return Ok(key); + } + // The id is known but its key is gone (keyring wiped, folder copied from another machine): + // this is a new device as far as the server is concerned. + eprintln!("kestrove: the device key in {} is missing; creating a new device key", storage.describe()); + } + let id = uuid::Uuid::new_v4().to_string(); + let mut last_err = io::Error::other("no key store available"); + for &storage in preferred { + match create(data_dir, &id, storage) { + Ok(key) => { + private_file::write(&id_path, id.as_bytes())?; + private_file::write(&store_path, storage.tag().as_bytes())?; + return Ok(key); + } + Err(e) => { + eprintln!("kestrove: device key store {} unavailable: {e}", storage.describe()); + last_err = e; + } + } + } + Err(last_err) + } + + pub fn storage(&self) -> KeyStorage { + self.storage + } + + /// Shown in settings when the key is less protected than it should be. + pub fn warning(&self) -> Option<&'static str> { + (self.storage == KeyStorage::File).then_some( + "No system keyring was found, so this device's sync key is stored in a file only your user can read.", + ) + } + + pub fn id(&self) -> &str { + &self.id + } + + fn sign_raw(&self, message: &[u8]) -> io::Result> { + match &self.backend { + Backend::Software(k) => { + let sig: Signature = k.sign(message); + Ok(sig.to_bytes().to_vec()) + } + #[cfg(windows)] + Backend::Cng(k) => k.sign(message), + } + } +} + +impl kv_ffi::DeviceSigner for DeviceKey { + fn device_id(&self) -> String { + self.id.clone() + } + + fn public_key(&self) -> Vec { + self.public.clone() + } + + fn sign(&self, message: Vec) -> Result, CoreError> { + self.sign_raw(&message).map_err(|e| CoreError::Internal { msg: format!("device key: {e}") }) + } +} + +fn read_text(path: &Path) -> io::Result> { + Ok(private_file::read(path)?.and_then(|b| String::from_utf8(b).ok()).map(|s| s.trim().to_string())) +} + +fn platform_stores() -> Vec { + if std::env::var_os("KESTROVE_DEVICE_KEY").is_some_and(|v| v == "file") { + return vec![KeyStorage::File]; + } + if cfg!(windows) { + vec![KeyStorage::Tpm, KeyStorage::Dpapi] + } else if cfg!(target_os = "macos") { + vec![KeyStorage::Keychain, KeyStorage::File] + } else { + vec![KeyStorage::SecretService, KeyStorage::File] + } +} + +fn software(id: &str, key: SigningKey, storage: KeyStorage) -> DeviceKey { + let public = key.verifying_key().to_encoded_point(false).as_bytes().to_vec(); + DeviceKey { id: id.to_string(), public, backend: Backend::Software(key), storage } +} + +fn parse_scalar(bytes: &[u8]) -> io::Result { + SigningKey::from_slice(bytes).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "corrupt device key")) +} + +fn new_scalar() -> Zeroizing> { + // `SigningKey::random` needs a rand_core RNG; draw a scalar from the OS and retry the (2^-128) invalid case. + loop { + let mut b = Zeroizing::new(vec![0u8; 32]); + getrandom::getrandom(&mut b).expect("OS random number generator"); + if SigningKey::from_slice(&b).is_ok() { + return b; + } + } +} + +fn key_file(data_dir: &Path) -> PathBuf { + data_dir.join("device-key") +} + +fn load(data_dir: &Path, id: &str, storage: KeyStorage) -> io::Result> { + let secret = match storage { + KeyStorage::File => private_file::read(&key_file(data_dir))?.map(Zeroizing::new), + #[cfg(not(windows))] + KeyStorage::Keychain | KeyStorage::SecretService => keyring_slot::get(id)?, + #[cfg(windows)] + KeyStorage::Dpapi => match private_file::read(&key_file(data_dir).with_extension("dpapi"))? { + Some(sealed) => Some(dpapi::unprotect(&sealed)?), + None => None, + }, + #[cfg(windows)] + KeyStorage::Tpm => { + return Ok(cng::Key::open(id)?.map(|k| DeviceKey { + id: id.to_string(), + public: k.public_key().unwrap_or_default(), + backend: Backend::Cng(k), + storage, + })) + } + #[allow(unreachable_patterns)] + _ => return Err(io::Error::other(format!("{} is not available on this platform", storage.describe()))), + }; + secret.map(|s| Ok(software(id, parse_scalar(&s)?, storage))).transpose() +} + +fn create(data_dir: &Path, id: &str, storage: KeyStorage) -> io::Result { + #[cfg(windows)] + if storage == KeyStorage::Tpm { + let k = cng::Key::create(id)?; + let public = k.public_key()?; + return Ok(DeviceKey { id: id.to_string(), public, backend: Backend::Cng(k), storage }); + } + let secret = new_scalar(); + match storage { + KeyStorage::File => private_file::write(&key_file(data_dir), &secret)?, + #[cfg(not(windows))] + KeyStorage::Keychain | KeyStorage::SecretService => keyring_slot::put(id, &secret)?, + #[cfg(windows)] + KeyStorage::Dpapi => { + private_file::write(&key_file(data_dir).with_extension("dpapi"), &dpapi::protect(&secret)?)? + } + #[allow(unreachable_patterns)] + _ => return Err(io::Error::other(format!("{} is not available on this platform", storage.describe()))), + } + Ok(software(id, parse_scalar(&secret)?, storage)) +} + +#[cfg(not(windows))] +mod keyring_slot { + use std::io; + + use zeroize::Zeroizing; + + const SERVICE: &str = "app.kestrove.desktop"; + + fn entry(id: &str) -> io::Result { + keyring::Entry::new(SERVICE, &format!("device-key-{id}")).map_err(io::Error::other) + } + + pub fn get(id: &str) -> io::Result>>> { + match entry(id)?.get_secret() { + Ok(s) => Ok(Some(Zeroizing::new(s))), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(io::Error::other(e)), + } + } + + pub fn put(id: &str, secret: &[u8]) -> io::Result<()> { + let e = entry(id)?; + e.set_secret(secret).map_err(io::Error::other)?; + // Read back: some Secret Service setups accept a write into a locked collection and lose it. + match e.get_secret() { + Ok(s) if s == secret => Ok(()), + _ => Err(io::Error::other("the keyring did not keep the device key")), + } + } +} + +#[cfg(windows)] +mod dpapi { + use std::io; + use std::ptr; + + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Cryptography::{ + CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, + }; + use zeroize::Zeroizing; + + const ENTROPY: &[u8] = b"kestrove/v1/desktop/device-key"; + + fn blob(b: &[u8]) -> CRYPT_INTEGER_BLOB { + CRYPT_INTEGER_BLOB { cbData: b.len() as u32, pbData: b.as_ptr() as *mut u8 } + } + + /// Copies DPAPI's output and frees it. + unsafe fn take(out: CRYPT_INTEGER_BLOB) -> Zeroizing> { + let v = Zeroizing::new(std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec()); + ptr::write_bytes(out.pbData, 0, out.cbData as usize); + LocalFree(out.pbData as _); + v + } + + pub fn protect(secret: &[u8]) -> io::Result> { + let (input, entropy) = (blob(secret), blob(ENTROPY)); + let mut out = CRYPT_INTEGER_BLOB { cbData: 0, pbData: ptr::null_mut() }; + // SAFETY: the input blobs point at live slices; on success DPAPI allocates `out`, which `take` frees. + let ok = unsafe { + CryptProtectData( + &input, + ptr::null(), + &entropy, + ptr::null(), + ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut out, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { take(out) }.to_vec()) + } + + pub fn unprotect(sealed: &[u8]) -> io::Result>> { + let (input, entropy) = (blob(sealed), blob(ENTROPY)); + let mut out = CRYPT_INTEGER_BLOB { cbData: 0, pbData: ptr::null_mut() }; + // SAFETY: as in `protect`. + let ok = unsafe { + CryptUnprotectData( + &input, + ptr::null_mut(), + &entropy, + ptr::null(), + ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut out, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { take(out) }) + } +} + +#[cfg(windows)] +mod cng { + use std::io; + use std::ptr; + + use sha2::{Digest, Sha256}; + use windows_sys::Win32::Security::Cryptography::{ + NCryptCreatePersistedKey, NCryptExportKey, NCryptFinalizeKey, NCryptFreeObject, NCryptOpenKey, + NCryptOpenStorageProvider, NCryptSignHash, BCRYPT_ECCKEY_BLOB, BCRYPT_ECCPUBLIC_BLOB, + BCRYPT_ECDSA_P256_ALGORITHM, BCRYPT_ECDSA_PUBLIC_P256_MAGIC, MS_PLATFORM_CRYPTO_PROVIDER, NCRYPT_KEY_HANDLE, + NCRYPT_PROV_HANDLE, NCRYPT_SILENT_FLAG, + }; + + const NTE_BAD_KEYSET: i32 = 0x8009_0016_u32 as i32; + + pub struct Key(NCRYPT_KEY_HANDLE); + + // SAFETY: NCrypt key handles may be used from any thread. + unsafe impl Send for Key {} + unsafe impl Sync for Key {} + + impl Drop for Key { + fn drop(&mut self) { + // SAFETY: the handle came from NCryptOpenKey/NCryptCreatePersistedKey and is freed once. + unsafe { NCryptFreeObject(self.0) }; + } + } + + fn check(hr: i32, what: &str) -> io::Result<()> { + if hr == 0 { + Ok(()) + } else { + Err(io::Error::other(format!("{what} failed: 0x{:08x}", hr as u32))) + } + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + fn key_name(id: &str) -> Vec { + wide(&format!("Kestrove device {id}")) + } + + struct Provider(NCRYPT_PROV_HANDLE); + + impl Drop for Provider { + fn drop(&mut self) { + // SAFETY: opened by NCryptOpenStorageProvider below. + unsafe { NCryptFreeObject(self.0) }; + } + } + + fn provider() -> io::Result { + let mut h: NCRYPT_PROV_HANDLE = 0; + // SAFETY: out-pointer to a local; the provider name is a static wide string. + check(unsafe { NCryptOpenStorageProvider(&mut h, MS_PLATFORM_CRYPTO_PROVIDER, 0) }, "opening the TPM")?; + Ok(Provider(h)) + } + + impl Key { + pub fn open(id: &str) -> io::Result> { + let p = provider()?; + let name = key_name(id); + let mut h: NCRYPT_KEY_HANDLE = 0; + // SAFETY: valid provider handle, NUL-terminated name, out-pointer to a local. + let hr = unsafe { NCryptOpenKey(p.0, &mut h, name.as_ptr(), 0, NCRYPT_SILENT_FLAG) }; + if hr == NTE_BAD_KEYSET { + return Ok(None); + } + check(hr, "opening the TPM key")?; + Ok(Some(Key(h))) + } + + pub fn create(id: &str) -> io::Result { + let p = provider()?; + let name = key_name(id); + let mut h: NCRYPT_KEY_HANDLE = 0; + // SAFETY: as in `open`; the key is non-exportable by default. + check( + unsafe { NCryptCreatePersistedKey(p.0, &mut h, BCRYPT_ECDSA_P256_ALGORITHM, name.as_ptr(), 0, 0) }, + "creating the TPM key", + )?; + let key = Key(h); + // SAFETY: a handle from NCryptCreatePersistedKey. + check(unsafe { NCryptFinalizeKey(key.0, NCRYPT_SILENT_FLAG) }, "finalizing the TPM key")?; + Ok(key) + } + + /// SEC1 uncompressed public key (0x04 || X || Y). + pub fn public_key(&self) -> io::Result> { + let mut buf = [0u8; 8 + 64]; + let mut len = 0u32; + // SAFETY: the output buffer is large enough for a P-256 BCRYPT_ECCPUBLIC_BLOB (8-byte header + X + Y). + check( + unsafe { + NCryptExportKey( + self.0, + 0, + BCRYPT_ECCPUBLIC_BLOB, + ptr::null(), + buf.as_mut_ptr(), + buf.len() as u32, + &mut len, + 0, + ) + }, + "exporting the TPM public key", + )?; + let header = std::mem::size_of::(); + let magic = u32::from_le_bytes(buf[0..4].try_into().expect("4 bytes")); + let cb = u32::from_le_bytes(buf[4..8].try_into().expect("4 bytes")) as usize; + if magic != BCRYPT_ECDSA_PUBLIC_P256_MAGIC || cb != 32 || len as usize != header + 64 { + return Err(io::Error::other("unexpected TPM public key format")); + } + let mut out = vec![0x04]; + out.extend_from_slice(&buf[header..header + 64]); + Ok(out) + } + + /// ECDSA P-256 over SHA-256(message); CNG returns the raw r || s form the core expects. + pub fn sign(&self, message: &[u8]) -> io::Result> { + let digest = Sha256::digest(message); + let mut sig = vec![0u8; 64]; + let mut len = 0u32; + // SAFETY: digest and signature buffers are live and sized as passed. + check( + unsafe { + NCryptSignHash( + self.0, + ptr::null(), + digest.as_ptr(), + digest.len() as u32, + sig.as_mut_ptr(), + sig.len() as u32, + &mut len, + NCRYPT_SILENT_FLAG, + ) + }, + "signing with the TPM key", + )?; + sig.truncate(len as usize); + Ok(sig) + } + } +} + +#[cfg(test)] +mod tests { + use kv_ffi::DeviceSigner; + use p256::ecdsa::{signature::Verifier, VerifyingKey}; + + use super::*; + + #[test] + fn file_key_is_stable_and_signs_raw_p256() { + let dir = tempfile::tempdir().unwrap(); + let a = DeviceKey::load_or_create_file(dir.path()).unwrap(); + let b = DeviceKey::load_or_create_file(dir.path()).unwrap(); + assert_eq!(a.device_id(), b.device_id()); + assert_eq!(a.public_key(), b.public_key()); + assert_eq!(a.public_key().len(), 65); + assert!(uuid::Uuid::parse_str(&a.device_id()).is_ok()); + + let sig = a.sign(b"hello".to_vec()).unwrap(); + assert_eq!(sig.len(), 64); + let vk = VerifyingKey::from_sec1_bytes(&a.public_key()).unwrap(); + vk.verify(b"hello", &Signature::from_slice(&sig).unwrap()).unwrap(); + } + + #[test] + fn lost_key_means_new_device_id() { + let dir = tempfile::tempdir().unwrap(); + let a = DeviceKey::load_or_create_file(dir.path()).unwrap(); + std::fs::remove_file(key_file(dir.path())).unwrap(); + let b = DeviceKey::load_or_create_file(dir.path()).unwrap(); + assert_ne!(a.device_id(), b.device_id()); + } + + #[test] + #[cfg(unix)] + fn key_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + DeviceKey::load_or_create_file(dir.path()).unwrap(); + let mode = std::fs::metadata(key_file(dir.path())).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a146395..7b7f810 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,10 @@ pub mod autolock; pub mod broker; pub mod channel; +pub mod device_key; pub mod framing; pub mod ipc; pub mod private_file; +pub mod qr; +pub mod sync; pub mod words; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f8ff8c5..026e94e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,11 @@ use std::{io, thread}; use kestrove_desktop::autolock::{self, Activity, Reason}; use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui}; +use kestrove_desktop::device_key::DeviceKey; +use kestrove_desktop::sync::{ + self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService, + SyncStatus, +}; use kestrove_desktop::{ipc, private_file}; use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore}; use serde::{Deserialize, Serialize}; @@ -22,6 +27,7 @@ const CLIPBOARD_CLEAR: Duration = Duration::from_secs(90); struct App { core: Arc, + sync: Arc, broker: Arc, activity: Arc, data_dir: PathBuf, @@ -32,9 +38,6 @@ struct App { } impl App { - fn secret_key_path(&self) -> PathBuf { - self.data_dir.join("secret-key") - } fn settings_path(&self) -> PathBuf { self.data_dir.join("settings.json") } @@ -137,36 +140,32 @@ impl Ui for TauriUi { } } -fn remember_secret_key(app: &App, secret_key: &str) { - if let Err(e) = private_file::write(&app.secret_key_path(), secret_key.trim().as_bytes()) { - eprintln!("kestrove: could not store the Secret Key: {e}"); - } -} - -fn stored_secret_key(app: &App) -> Option> { - let b = private_file::read(&app.secret_key_path()).ok()??; - String::from_utf8(b).ok().map(Zeroizing::new) -} - #[tauri::command] fn state(app: State<'_, App>) -> AppState { AppState { has_account: app.core.has_account(), locked: app.core.is_locked(), - remembers_secret_key: stored_secret_key(&app).is_some(), + remembers_secret_key: app.sync.stored_secret_key().is_some(), ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(), idle_minutes: app.idle_minutes.load(Ordering::Relaxed), } } // Argon2id takes ~0.5 s, so these run as async commands off the main thread. + +/// "Create a new account": a new vault here, registered with a fresh server using its setup code. #[tauri::command] -async fn create_account(app: State<'_, App>, master_password: String) -> CmdResult { +async fn create_account( + app: State<'_, App>, + server_url: String, + pins: String, + setup_code: String, + master_password: String, +) -> CmdResult { let master_password = Zeroizing::new(master_password); - let sk = app.core.create_account(master_password.to_string()).map_err(err)?; - remember_secret_key(&app, &sk); + let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, master_password).await; app.activity.touch(); - Ok(sk) + sk.map(|s| s.to_string()) } /// `secret_key` may be omitted once this device remembers it. @@ -174,18 +173,21 @@ async fn create_account(app: State<'_, App>, master_password: String) -> CmdResu async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option) -> CmdResult<()> { let master_password = Zeroizing::new(master_password); let typed = secret_key.map(Zeroizing::new).filter(|s| !s.trim().is_empty()); - let sk = typed.clone().or_else(|| stored_secret_key(&app)).ok_or("Enter your Secret Key.")?; + let sk = typed.clone().or_else(|| app.sync.stored_secret_key()).ok_or("Enter your Secret Key.")?; app.core.unlock(master_password.to_string(), sk.trim().to_string()).map_err(err)?; if let Some(sk) = typed { - remember_secret_key(&app, &sk); + app.sync.remember_secret_key(&sk); } app.activity.touch(); + app.sync.request_sync(); Ok(()) } #[tauri::command] -fn lock(app: State<'_, App>) { +async fn lock(app: State<'_, App>) -> CmdResult<()> { app.core.lock(); + app.sync.on_lock().await; + Ok(()) } /// UI input (throttled in JS): feeds the idle timer where the OS has no system idle time. @@ -251,11 +253,14 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult { modified_at: 0, }) .map_err(err) + .inspect(|_| app.sync.request_sync()) } #[tauri::command] fn delete_item(app: State<'_, App>, id: String) -> CmdResult<()> { - app.core.delete_item(id).map_err(err) + app.core.delete_item(id).map_err(err)?; + app.sync.request_sync(); + Ok(()) } #[tauri::command] @@ -341,7 +346,120 @@ fn pending_saves(app: State<'_, App>) -> Vec { #[tauri::command] fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> { - app.broker.resolve_save(&id, accept).map(|_| ()) + if app.broker.resolve_save(&id, accept)?.is_some() { + app.sync.request_sync(); + } + Ok(()) +} + +// ---------------------------------------------------------------- sync server (docs/03) + +#[tauri::command] +fn sync_status(app: State<'_, App>) -> SyncStatus { + app.sync.status() +} + +#[tauri::command] +async fn sync_now(app: State<'_, App>) -> CmdResult { + app.sync.sync_now().await +} + +/// Settings: server URL and optional pins for a vault created before sync (or to change them). +#[tauri::command] +fn configure_server(app: State<'_, App>, server_url: String, pins: String) -> CmdResult<()> { + app.sync.configure(&server_url, sync::parse_pins(&pins)) +} + +#[tauri::command] +async fn server_setup_open(app: State<'_, App>) -> CmdResult { + app.sync.setup_open().await +} + +#[tauri::command] +async fn register_server(app: State<'_, App>, setup_code: String) -> CmdResult<()> { + app.sync.register(&setup_code).await +} + +#[tauri::command] +async fn list_devices(app: State<'_, App>) -> CmdResult> { + app.sync.devices().await +} + +#[tauri::command] +async fn approve_device(app: State<'_, App>, device_id: String) -> CmdResult<()> { + app.sync.approve_device(&device_id).await +} + +#[tauri::command] +async fn revoke_device(app: State<'_, App>, device_id: String) -> CmdResult<()> { + app.sync.revoke_device(&device_id).await +} + +// ---------------------------------------------------------------- onboarding (docs/10) + +#[tauri::command] +async fn join_start(app: State<'_, App>, server_url: String) -> CmdResult { + app.sync.join_start(&server_url).await +} + +#[tauri::command] +async fn join_poll(app: State<'_, App>) -> CmdResult { + app.sync.join_poll().await +} + +#[tauri::command] +fn join_cancel(app: State<'_, App>) { + app.sync.join_cancel(); +} + +#[tauri::command] +fn setup_from_text(app: State<'_, App>, text: String) -> CmdResult { + app.sync.setup_from_text(&Zeroizing::new(text)) +} + +/// "Open Emergency Kit…": the file's bytes (raw IPC body), read by the web view's file picker. +#[tauri::command] +async fn setup_from_file(app: State<'_, App>, request: tauri::ipc::Request<'_>) -> CmdResult { + let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { return Err("Expected the file's bytes.".into()) }; + app.sync.setup_from_file(bytes) +} + +#[tauri::command] +fn clear_setup(app: State<'_, App>) { + app.sync.clear_setup(); +} + +#[tauri::command] +async fn finish_setup(app: State<'_, App>, master_password: String) -> CmdResult { + let r = app.sync.finish_setup(Zeroizing::new(master_password)).await; + app.activity.touch(); + r +} + +#[tauri::command] +async fn add_device(app: State<'_, App>) -> CmdResult { + app.sync.add_device().await +} + +#[tauri::command] +async fn cancel_add_device(app: State<'_, App>) -> CmdResult<()> { + app.sync.cancel_add_device().await; + Ok(()) +} + +#[tauri::command] +async fn approve_join(app: State<'_, App>, join_code: String) -> CmdResult { + app.sync.approve_join(&join_code).await +} + +#[tauri::command] +async fn send_join(app: State<'_, App>) -> CmdResult<()> { + app.sync.send_join().await +} + +#[tauri::command] +fn kit_qr(app: State<'_, App>) -> CmdResult { + app.sync.kit_qr() } // ---------------------------------------------------------------- startup @@ -365,13 +483,19 @@ fn start_ipc(broker: Arc) -> io::Result<()> { Ok(()) } -fn lock_and_notify(handle: &AppHandle, core: &VaultCore, reason: &str) { - core.lock(); +fn lock_and_notify(handle: &AppHandle, sync: &Arc, reason: &str) { + sync.core().lock(); + let sync = sync.clone(); + tauri::async_runtime::spawn(async move { sync.on_lock().await }); let _ = handle.emit("locked", reason); } fn setup(app: &mut tauri::App) -> Result<(), Box> { - let data_dir = app.path().app_data_dir()?; + // KESTROVE_DATA_DIR runs a second instance with its own vault (development and the two-device tests). + let data_dir = match std::env::var_os("KESTROVE_DATA_DIR") { + Some(d) => PathBuf::from(d), + None => app.path().app_data_dir()?, + }; std::fs::create_dir_all(&data_dir)?; #[cfg(unix)] { @@ -379,6 +503,23 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?; } let core = VaultCore::open(data_dir.join("vault.sqlite").to_string_lossy().into_owned())?; + let key = if std::env::var_os("KESTROVE_DEVICE_KEY").is_some_and(|v| v == "file") { + DeviceKey::load_or_create_file(&data_dir) + } else { + DeviceKey::load_or_create(&data_dir) + }; + let sync = { + let handle = app.handle().clone(); + SyncService::new( + core.clone(), + &data_dir, + key.map_err(|e| e.to_string()), + Box::new(move |s: &SyncStatus| { + let _ = handle.emit("sync-status", s); + }), + ) + }; + tauri::async_runtime::spawn(sync.clone().run()); let activity = Activity::new(); let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() }); let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?); @@ -400,7 +541,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { }); { - let (handle, core, minutes) = (app.handle().clone(), core.clone(), idle_minutes.clone()); + let (handle, sync, minutes) = (app.handle().clone(), sync.clone(), idle_minutes.clone()); let unlocked = core.clone(); autolock::spawn( activity.clone(), @@ -412,7 +553,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { Reason::ScreenLocked => "screen-locked", Reason::Sleep => "sleep", }; - lock_and_notify(&handle, &core, why); + lock_and_notify(&handle, &sync, why); }, ); } @@ -423,7 +564,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let menu = Menu::with_items(app, &[&open, &lock_item, &quit])?; let mut tray = TrayIconBuilder::with_id("main").menu(&menu).tooltip("Kestrove").on_menu_event({ - let core = core.clone(); + let sync = sync.clone(); move |handle, event| match event.id().as_ref() { "open" => { if let Some(w) = handle.get_webview_window("main") { @@ -431,9 +572,14 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { let _ = w.set_focus(); } } - "lock" => lock_and_notify(handle, &core, "manual"), + "lock" => lock_and_notify(handle, &sync, "manual"), "quit" => { - core.lock(); + sync.core().lock(); + // Revoke an "Add a device" ticket still on screen (bounded: never hang Quit on the network). + let s = sync.clone(); + let _ = tauri::async_runtime::block_on(async move { + tokio::time::timeout(Duration::from_secs(2), s.on_lock()).await + }); handle.exit(0); } _ => {} @@ -446,6 +592,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { app.manage(App { core, + sync, broker, activity, data_dir, @@ -485,7 +632,27 @@ fn main() { list_pairings, revoke_pairing, pending_saves, - resolve_save + resolve_save, + sync_status, + sync_now, + configure_server, + server_setup_open, + register_server, + list_devices, + approve_device, + revoke_device, + join_start, + join_poll, + join_cancel, + setup_from_text, + setup_from_file, + clear_setup, + finish_setup, + add_device, + cancel_add_device, + approve_join, + send_join, + kit_qr ]) .run(tauri::generate_context!()) .expect("error while running Kestrove"); diff --git a/src-tauri/src/qr.rs b/src-tauri/src/qr.rs new file mode 100644 index 0000000..093eb46 --- /dev/null +++ b/src-tauri/src/qr.rs @@ -0,0 +1,171 @@ +//! QR codes for onboarding (docs/10): rendered locally (no network), and read back from an Emergency Kit +//! image (PNG/JPEG) or PDF. + +use std::fmt::Write as _; + +use qrcode::{EcLevel, QrCode}; + +/// Largest file we try to decode (an Emergency Kit PDF or a phone photo). +pub const MAX_INPUT: usize = 25 * 1024 * 1024; +/// Rendering a page above this many pixels per side is pointless for a QR and slow. +const MAX_SIDE: u32 = 4000; + +/// An SVG `data:` URI for `text` (level M, as docs/10 sizes the codes). Dark modules on white with a quiet zone, +/// so it scans in dark mode too. +pub fn svg_data_uri(text: &str) -> Result { + let code = QrCode::with_error_correction_level(text.as_bytes(), EcLevel::M).map_err(|e| e.to_string())?; + let svg = code + .render::() + .quiet_zone(true) + .min_dimensions(240, 240) + .dark_color(qrcode::render::svg::Color("#000000")) + .light_color(qrcode::render::svg::Color("#ffffff")) + .build(); + let mut uri = String::from("data:image/svg+xml;charset=utf-8,"); + for b in svg.bytes() { + // Percent-encode everything outside a conservative safe set. + if b.is_ascii_alphanumeric() || b" -_.!~*'()/:=,;".contains(&b) { + uri.push(b as char); + } else { + let _ = write!(uri, "%{b:02X}"); + } + } + Ok(uri) +} + +/// Every QR text found in an image or PDF, in reading order. +pub fn decode_file(bytes: &[u8]) -> Result, String> { + if bytes.len() > MAX_INPUT { + return Err("The file is too large.".into()); + } + if bytes.starts_with(b"%PDF") { + decode_pdf(bytes) + } else { + let img = image::load_from_memory(bytes).map_err(|_| "This is not a PNG, JPEG or PDF file.".to_string())?; + Ok(decode_luma(&img.to_luma8())) + } +} + +fn decode_luma(img: &image::GrayImage) -> Vec { + let (w, h) = img.dimensions(); + let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(w as usize, h as usize, |x, y| { + img.get_pixel(x as u32, y as u32).0[0] + }); + prepared.detect_grids().into_iter().filter_map(|g| g.decode().ok().map(|(_, text)| text)).collect() +} + +fn decode_pdf(bytes: &[u8]) -> Result, String> { + use hayro::hayro_interpret::InterpreterSettings; + use hayro::hayro_syntax::Pdf; + use hayro::vello_cpu::color::palette::css::WHITE; + + let pdf = Pdf::new(bytes.to_vec()).map_err(|_| "This PDF could not be read (is it password-protected?).")?; + let cache = hayro::RenderCache::new(); + let settings = InterpreterSettings::default(); + let mut found = Vec::new(); + for page in pdf.pages().iter().take(10) { + let (w, h) = page.render_dimensions(); + // ~216 dpi: a 35 mm code becomes ~300 px, plenty for the detector. + let scale = (3.0_f32).min(MAX_SIDE as f32 / w.max(h).max(1.0)); + let pixmap = hayro::render( + page, + &cache, + &settings, + &hayro::RenderSettings::default(), + &hayro::PixmapSettings { x_scale: scale, y_scale: scale, bg_color: WHITE }, + ); + let (pw, ph) = (u32::from(pixmap.width()), u32::from(pixmap.height())); + let rgba = pixmap.data_as_u8_slice(); + let gray = image::GrayImage::from_fn(pw, ph, |x, y| { + let i = ((y * pw + x) * 4) as usize; + // Premultiplied over white: the background is opaque white, so treat the channels as plain RGB. + let (r, g, b) = (u32::from(rgba[i]), u32::from(rgba[i + 1]), u32::from(rgba[i + 2])); + image::Luma([((r * 299 + g * 587 + b * 114) / 1000) as u8]) + }); + found.extend(decode_luma(&gray)); + } + Ok(found) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PAYLOAD: &str = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW"; + + fn png_of(text: &str) -> Vec { + let code = QrCode::with_error_correction_level(text.as_bytes(), EcLevel::M).unwrap(); + let w = code.width(); + let scale = 6u32; + let side = (w as u32 + 8) * scale; + let colors = code.to_colors(); + let img = image::GrayImage::from_fn(side, side, |x, y| { + let (mx, my) = ((x / scale) as i64 - 4, (y / scale) as i64 - 4); + let dark = mx >= 0 + && my >= 0 + && (mx as usize) < w + && (my as usize) < w + && colors[my as usize * w + mx as usize] == qrcode::Color::Dark; + image::Luma([if dark { 0 } else { 255 }]) + }); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + } + + #[test] + fn png_round_trip() { + assert_eq!(decode_file(&png_of(PAYLOAD)).unwrap(), vec![PAYLOAD.to_string()]); + } + + #[test] + fn svg_is_a_data_uri() { + let uri = svg_data_uri(PAYLOAD).unwrap(); + assert!(uri.starts_with("data:image/svg+xml;charset=utf-8,%3C")); + assert!(!uri.contains('<') && !uri.contains('#') && !uri.contains('"')); + } + + #[test] + fn rejects_other_files() { + assert!(decode_file(b"hello").is_err()); + assert!(decode_file(b"%PDF-1.4 garbage").is_err()); + } + + /// A minimal one-page PDF that draws the QR modules as filled rectangles (vector, like a generated Kit). + #[test] + fn pdf_with_vector_qr() { + let code = QrCode::with_error_correction_level(PAYLOAD.as_bytes(), EcLevel::M).unwrap(); + let w = code.width(); + let colors = code.to_colors(); + let m = 4.0; // points per module + let mut content = String::from("0 g\n"); + for y in 0..w { + for x in 0..w { + if colors[y * w + x] == qrcode::Color::Dark { + let px = 100.0 + x as f32 * m; + let py = 600.0 - y as f32 * m; + content.push_str(&format!("{px} {py} {m} {m} re f\n")); + } + } + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + ]; + let mut pdf = String::from("%PDF-1.4\n"); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.push_str(&format!("{} 0 obj\n{o}\nendobj\n", i + 1)); + } + let xref = pdf.len(); + pdf.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1)); + for off in offsets { + pdf.push_str(&format!("{off:010} 00000 n \n")); + } + pdf.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1)); + assert_eq!(decode_file(pdf.as_bytes()).unwrap(), vec![PAYLOAD.to_string()]); + } +} diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs new file mode 100644 index 0000000..cffbc67 --- /dev/null +++ b/src-tauri/src/sync.rs @@ -0,0 +1,670 @@ +//! Sync server connection and onboarding (docs/03, docs/10) on top of kv-ffi, without Tauri types so the +//! integration tests can drive it. The UI layer (main.rs) wraps each method in a command. +//! +//! Secrets stay here: setup payloads (Secret Key, ticket) never go to the web view except as the QR image the +//! user asked to see. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use kv_ffi::{CoreError, DeviceDto, LoginResult, PlatformDto, SetupInfo, VaultCore}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::device_key::DeviceKey; +use crate::{private_file, qr}; + +/// Background sync while unlocked (docs/03 "Triggers"). +pub const SYNC_PERIOD: Duration = Duration::from_secs(15 * 60); +/// Saves in quick succession (or an unlock right after a save) become one sync. +const COALESCE: Duration = Duration::from_millis(800); + +/// What the UI shows about sync. Sent with every change (event `sync-status`). +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct SyncStatus { + pub server_url: Option, + pub pins: Vec, + pub logged_in: bool, + pub syncing: bool, + /// Unix seconds of the last successful sync. + pub last_sync_at: Option, + pub last_error: Option, + /// This device waits for approval on another device. + pub pending_approval: bool, + /// Other devices waiting for this user's approval. + pub pending_devices: u32, + /// The server failed verification; syncing that vault has stopped (docs/03 "Integrity"). + pub integrity_alarm: Option, + pub device_fingerprint: Option, + pub key_storage: Option, + pub key_warning: Option, + /// Items an edit here brought back after a delete elsewhere, from the last sync. + pub restored_items: u32, + /// Bumped whenever a sync changed local items, so the UI knows to refresh the list. + pub changes: u64, +} + +/// What we persist besides the core's own records (`sync.json`, 0600). +#[derive(Default, Serialize, Deserialize)] +struct Saved { + server_url: Option, + #[serde(default)] + pins: Vec, + last_sync_at: Option, + integrity_alarm: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DeviceView { + pub device_id: String, + pub name: String, + pub platform: String, + pub approved: bool, + pub revoked: bool, + pub current: bool, + pub created_at: String, + pub key_fingerprint: String, +} + +/// A setup payload waiting for the master password. The Secret Key stays in Rust. +#[derive(Debug, Clone, Serialize)] +pub struct SetupView { + pub server_url: String, + pub server_host: String, + pub account_id: String, + /// A ticket means no manual approval will be needed. + pub preapproved: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct JoinView { + /// QR image (`data:` URI) to scan with a signed-in phone. + pub qr_svg: String, + /// The same as text, for "Paste a computer's join code" on another computer. + pub qr_text: String, + pub expires_at: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct JoinPollView { + /// `"123 456"` once a phone scanned the QR. + pub code: Option, + /// Set when the bundle arrived: ask for the master password and call `finish_setup`. + pub setup: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AddDeviceView { + pub qr_svg: String, + pub expires_at: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct JoinConfirmView { + pub code: String, + pub device_name: String, + pub platform: String, + pub key_fingerprint: String, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SignIn { + Ok, + PendingApproval, +} + +type Notify = Box; + +pub struct SyncService { + core: Arc, + data_dir: PathBuf, + key: Result, String>, + status: Mutex, + /// From "Use my phone", the Emergency Kit or a pasted code, until `finish_setup` succeeds. + pending_setup: Mutex>, + /// The "Add a device" ticket on screen, revoked on cancel and on lock. + add_device_ticket: Mutex>, + wake: tokio::sync::Notify, + notify: Notify, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} + +/// User-facing text for a core error. +pub fn describe(e: &CoreError) -> String { + match e { + CoreError::Network { msg } => format!("Can't reach the server: {msg}"), + CoreError::PinMismatch => { + "The server's certificate doesn't match the pinned keys. Someone may be intercepting \ + the connection, so Kestrove won't talk to it." + .into() + } + CoreError::Server { status: 429, .. } => "Too many attempts. Wait a minute and try again.".into(), + CoreError::BadCredentials => "Wrong master password or Secret Key.".into(), + CoreError::PendingApproval => "This computer is waiting for approval on one of your other devices.".into(), + CoreError::NotConfigured => "No sync server is set up yet.".into(), + CoreError::NotAuthenticated => "Sign in to the sync server again (lock and unlock with your password).".into(), + CoreError::IntegrityAlarm { vault_id, reason } => format!( + "The server sent vault data that failed verification (vault {vault_id}: {reason}). Syncing stopped; \ + your local data is unchanged." + ), + CoreError::Invalid { msg } => msg.clone(), + other => other.to_string(), + } +} + +fn platform_name(p: PlatformDto) -> &'static str { + match p { + PlatformDto::Ios => "iOS", + PlatformDto::Macos => "macOS", + PlatformDto::Android => "Android", + PlatformDto::Windows => "Windows", + PlatformDto::Linux => "Linux", + } +} + +fn this_platform() -> PlatformDto { + if cfg!(windows) { + PlatformDto::Windows + } else if cfg!(target_os = "macos") { + PlatformDto::Macos + } else { + PlatformDto::Linux + } +} + +/// The computer's name for the device list ("Sami's PC"); `KESTROVE_DEVICE_NAME` overrides it. +pub fn device_name() -> String { + if let Ok(n) = std::env::var("KESTROVE_DEVICE_NAME") { + return n; + } + #[cfg(windows)] + let host = std::env::var("COMPUTERNAME").ok(); + #[cfg(unix)] + let host = { + let mut buf = [0u8; 256]; + // SAFETY: the buffer is valid for its length; gethostname NUL-terminates on success (we also cap it). + let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len() - 1) }; + (rc == 0).then(|| { + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..end]).trim_end_matches(".local").to_string() + }) + }; + host.filter(|h| !h.is_empty()).unwrap_or_else(|| format!("{} computer", platform_name(this_platform()))) +} + +fn host_of(url: &str) -> String { + url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)).unwrap_or_else(|| url.to_string()) +} + +fn setup_view(s: &SetupInfo) -> SetupView { + SetupView { + server_url: s.server_url.clone(), + server_host: host_of(&s.server_url), + account_id: s.account_id.clone(), + preapproved: s.ticket.is_some(), + } +} + +fn device_view(d: DeviceDto) -> DeviceView { + DeviceView { + device_id: d.device_id, + name: d.name, + platform: platform_name(d.platform).into(), + approved: d.approved, + revoked: d.revoked, + current: d.current, + created_at: d.created_at, + key_fingerprint: d.key_fingerprint, + } +} + +/// Splits "a, b c" pin lists typed by the user. +pub fn parse_pins(text: &str) -> Vec { + text.split(|c: char| c == ',' || c.is_whitespace()).filter(|s| !s.is_empty()).map(str::to_string).collect() +} + +impl SyncService { + /// `key` is this device's signing key (or why it could not be loaded; sync is then unavailable). + pub fn new(core: Arc, data_dir: &Path, key: Result, notify: Notify) -> Arc { + let saved: Saved = private_file::read(&data_dir.join("sync.json")) + .ok() + .flatten() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + let key = key.map(Arc::new); + let mut status = SyncStatus { + server_url: saved.server_url, + pins: saved.pins, + last_sync_at: saved.last_sync_at, + integrity_alarm: saved.integrity_alarm, + ..Default::default() + }; + match &key { + Ok(k) => { + status.key_storage = Some(k.storage().describe().into()); + status.key_warning = k.warning().map(str::to_string); + let signer: Arc = k.clone(); + if let Err(e) = core.set_device_signer(signer, device_name(), this_platform()) { + status.key_warning = Some(format!("Sync is unavailable: {}", describe(&e))); + } + status.device_fingerprint = core.device_key_fingerprint().ok(); + } + Err(e) => { + status.key_warning = Some(format!("Sync is unavailable: this device's key could not be loaded ({e}).")) + } + } + Arc::new(Self { + core, + data_dir: data_dir.to_path_buf(), + key, + status: Mutex::new(status), + pending_setup: Mutex::new(None), + add_device_ticket: Mutex::new(None), + wake: tokio::sync::Notify::new(), + notify, + }) + } + + pub fn core(&self) -> &Arc { + &self.core + } + + pub fn status(&self) -> SyncStatus { + let mut s = lock(&self.status).clone(); + s.logged_in = self.core.is_logged_in(); + s + } + + fn update(&self, f: impl FnOnce(&mut SyncStatus)) { + let snapshot = { + let mut s = lock(&self.status); + f(&mut s); + let saved = Saved { + server_url: s.server_url.clone(), + pins: s.pins.clone(), + last_sync_at: s.last_sync_at, + integrity_alarm: s.integrity_alarm.clone(), + }; + if let Ok(bytes) = serde_json::to_vec(&saved) { + if let Err(e) = private_file::write(&self.data_dir.join("sync.json"), &bytes) { + eprintln!("kestrove: could not save sync settings: {e}"); + } + } + s.clone() + }; + let mut snapshot = snapshot; + snapshot.logged_in = self.core.is_logged_in(); + (self.notify)(&snapshot); + } + + fn require_key(&self) -> Result<(), String> { + self.key.as_ref().map(|_| ()).map_err(|e| format!("Sync is unavailable: {e}")) + } + + // ------------------------------------------------------------ Secret Key on this device + + fn secret_key_path(&self) -> PathBuf { + self.data_dir.join("secret-key") + } + + pub fn remember_secret_key(&self, secret_key: &str) { + if let Err(e) = private_file::write(&self.secret_key_path(), secret_key.trim().as_bytes()) { + eprintln!("kestrove: could not store the Secret Key: {e}"); + } + } + + pub fn stored_secret_key(&self) -> Option> { + let b = private_file::read(&self.secret_key_path()).ok()??; + String::from_utf8(b).ok().map(Zeroizing::new) + } + + // ------------------------------------------------------------ server settings and sign-in + + /// Saves the server URL and optional SPKI pins (base64 SHA-256). + pub fn configure(&self, url: &str, pins: Vec) -> Result<(), String> { + self.require_key()?; + let url = url.trim().trim_end_matches('/').to_string(); + self.core.configure_server(url.clone(), pins.clone()).map_err(|e| describe(&e))?; + self.update(|s| { + s.server_url = Some(url); + s.pins = pins; + }); + Ok(()) + } + + /// "Create a new account": creates the vault here and registers it with a fresh server using the setup code + /// (`KV_SETUP_CODE`). Returns the Secret Key for the Emergency Kit. + pub async fn create_account( + &self, + server_url: &str, + pins: Vec, + setup_code: &str, + master_password: Zeroizing, + ) -> Result, String> { + if self.core.has_account() { + return Err("This computer already has a vault.".into()); + } + if setup_code.trim().is_empty() { + return Err("Enter the server's setup code.".into()); + } + self.configure(server_url, pins)?; + let info = self.core.server_info().await.map_err(|e| describe(&e))?; + if !info.setup_open { + return Err( + "This server already has an account. Choose “Use my phone” or “Paste setup code” instead.".into() + ); + } + let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?); + self.remember_secret_key(&sk); + if let Err(e) = self.core.register_account(setup_code.trim().to_string()).await { + return Err(format!( + "Your vault was created on this computer, but the server refused it: {}. Check the setup code in \ + Settings → Sync and try again.", + describe(&e) + )); + } + let _ = self.sync_now().await; + Ok(sk) + } + + /// For a vault created before sync was set up: registers it with a fresh server. + pub async fn register(&self, setup_code: &str) -> Result<(), String> { + self.core.register_account(setup_code.trim().to_string()).await.map_err(|e| describe(&e))?; + self.sync_now().await.map(|_| ()) + } + + /// Whether the configured server still accepts a setup code (no account yet). + pub async fn setup_open(&self) -> Result { + Ok(self.core.server_info().await.map_err(|e| describe(&e))?.setup_open) + } + + // ------------------------------------------------------------ sync + + /// Wakes the background loop (after unlock, after a save). + pub fn request_sync(&self) { + self.wake.notify_one(); + } + + fn can_sync(&self) -> bool { + self.key.is_ok() && self.core.has_account() && !self.core.is_locked() && lock(&self.status).server_url.is_some() + } + + /// Runs forever: syncs when woken and every 15 minutes while unlocked. + pub async fn run(self: Arc) { + loop { + let _ = tokio::time::timeout(SYNC_PERIOD, self.wake.notified()).await; + tokio::time::sleep(COALESCE).await; + if self.can_sync() { + let _ = self.sync_now().await; + } + } + } + + pub async fn sync_now(&self) -> Result { + self.require_key()?; + if self.core.is_locked() { + return Err("Unlock Kestrove first.".into()); + } + self.update(|s| s.syncing = true); + let result = self.core.sync_now().await; + let mut pending_devices = None; + if result.is_ok() { + pending_devices = self + .core + .list_devices() + .await + .ok() + .map(|ds| ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32); + } + let err = result.as_ref().err().map(describe); + self.update(|s| { + s.syncing = false; + match &result { + Ok(r) => { + s.last_sync_at = Some(now_secs()); + s.last_error = None; + s.pending_approval = false; + s.integrity_alarm = None; + s.restored_items = r.restored_item_ids.len() as u32; + if r.pulled + r.merged > 0 || !r.restored_item_ids.is_empty() { + s.changes += 1; + } + } + Err(CoreError::PendingApproval) => { + s.pending_approval = true; + s.last_error = None; + } + Err(e @ CoreError::IntegrityAlarm { .. }) => { + s.integrity_alarm = Some(describe(e)); + s.last_error = err.clone(); + // Other vaults may still have synced. + s.changes += 1; + } + Err(_) => s.last_error = err.clone(), + } + if let Some(n) = pending_devices { + s.pending_devices = n; + } + }); + match err { + Some(e) if !matches!(result, Err(CoreError::PendingApproval)) => Err(e), + _ => Ok(self.status()), + } + } + + // ------------------------------------------------------------ devices + + pub async fn devices(&self) -> Result, String> { + let ds = self.core.list_devices().await.map_err(|e| describe(&e))?; + let pending = ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32; + self.update(|s| s.pending_devices = pending); + Ok(ds.into_iter().map(device_view).collect()) + } + + /// After the user compared `key_fingerprint` with the one the other device shows. + pub async fn approve_device(&self, device_id: &str) -> Result<(), String> { + self.core.approve_device(device_id.to_string()).await.map_err(|e| describe(&e))?; + self.devices().await.map(|_| ()) + } + + pub async fn revoke_device(&self, device_id: &str) -> Result<(), String> { + self.core.revoke_device(device_id.to_string()).await.map_err(|e| describe(&e))?; + self.devices().await.map(|_| ()) + } + + // ------------------------------------------------------------ onboarding: a new computer + + /// "Use my phone": opens a rendezvous and returns the QR. Then poll `join_poll` every 2 seconds. + pub async fn join_start(&self, server_url: &str) -> Result { + self.require_key()?; + let url = server_url.trim().trim_end_matches('/').to_string(); + let session = self.core.join_start(url.clone()).await.map_err(|e| describe(&e))?; + self.update(|s| { + s.server_url = Some(url); + s.pins = vec![]; + }); + Ok(JoinView { qr_svg: qr::svg_data_uri(&session.qr)?, qr_text: session.qr, expires_at: session.expires_at }) + } + + pub async fn join_poll(&self) -> Result { + match self.core.join_poll().await { + Ok(p) => { + let setup = p.setup.map(|s| { + let view = setup_view(&s); + *lock(&self.pending_setup) = Some(s); + view + }); + Ok(JoinPollView { code: p.code, setup }) + } + Err(CoreError::NotFound) => Err("The code expired. Start again.".into()), + // The server allows one poll per second per rendezvous; a 429 just means "not yet". + Err(CoreError::Server { status: 429, .. }) => Ok(JoinPollView { code: None, setup: None }), + Err(e) => Err(describe(&e)), + } + } + + pub fn join_cancel(&self) { + self.core.join_cancel(); + } + + /// "Paste setup code". + pub fn setup_from_text(&self, text: &str) -> Result { + let info = kv_ffi::parse_setup_payload(text.trim().to_string()).map_err(|e| describe(&e))?; + let view = setup_view(&info); + *lock(&self.pending_setup) = Some(info); + Ok(view) + } + + /// "Open Emergency Kit…": a PNG/JPEG photo or the Kit PDF. + pub fn setup_from_file(&self, bytes: &[u8]) -> Result { + let texts = qr::decode_file(bytes)?; + let text = texts + .iter() + .find(|t| t.starts_with("kestrove://setup/")) + .ok_or("No Kestrove setup code was found in this file. Try a sharper photo, or paste the code.")?; + self.setup_from_text(text) + } + + pub fn clear_setup(&self) { + *lock(&self.pending_setup) = None; + } + + /// Signs in with the pending setup payload and the master password, then downloads the vault. + /// `PendingApproval` (a Kit code has no ticket): approve this computer elsewhere, then call again. + pub async fn finish_setup(&self, master_password: Zeroizing) -> Result { + self.require_key()?; + let setup = lock(&self.pending_setup).clone().ok_or("Scan or paste a setup code first.")?; + let result = + self.core.login_with_setup(setup.clone(), master_password.to_string()).await.map_err(|e| describe(&e))?; + self.update(|s| { + s.server_url = Some(setup.server_url.clone()); + s.pins = setup.spki_pins.clone(); + s.pending_approval = result == LoginResult::PendingApproval; + }); + if result == LoginResult::PendingApproval { + return Ok(SignIn::PendingApproval); + } + self.remember_secret_key(&setup.secret_key); + self.clear_setup(); + let _ = self.sync_now().await; + Ok(SignIn::Ok) + } + + // ------------------------------------------------------------ onboarding: from this (signed-in) computer + + /// "Add a device": a 10-minute, single-use QR. Cancel (or lock) revokes it. + pub async fn add_device(&self) -> Result { + self.cancel_add_device().await; + let p = self.core.add_device_payload().await.map_err(|e| describe(&e))?; + *lock(&self.add_device_ticket) = Some(p.ticket_id); + Ok(AddDeviceView { qr_svg: qr::svg_data_uri(&p.payload)?, expires_at: p.expires_at }) + } + + /// Revokes the "Add a device" ticket if one is on screen. A ticket already used or expired is fine. + pub async fn cancel_add_device(&self) { + let id = lock(&self.add_device_ticket).take(); + if let Some(id) = id { + if let Err(e) = self.core.revoke_enrollment_ticket(id).await { + if !matches!(e, CoreError::NotFound) { + eprintln!("kestrove: could not revoke the enrollment ticket: {e}"); + } + } + } + } + + /// "Approve a computer": the join code shown (as QR and text) on the new computer. + pub async fn approve_join(&self, join_code: &str) -> Result { + let c = self.core.join_approve(join_code.trim().to_string()).await.map_err(|e| match e { + CoreError::NotFound => "That join code expired or was already used.".to_string(), + e => describe(&e), + })?; + Ok(JoinConfirmView { + code: c.code, + device_name: c.device_name, + platform: platform_name(c.platform).into(), + key_fingerprint: c.key_fingerprint, + }) + } + + /// After the user saw the same code on both screens. + pub async fn send_join(&self) -> Result<(), String> { + self.core.join_send().await.map_err(|e| describe(&e)) + } + + /// Emergency Kit setup code (server, pins, account, Secret Key; no ticket) as a QR. + pub fn kit_qr(&self) -> Result { + qr::svg_data_uri(&self.core.setup_payload_for_kit().map_err(|e| describe(&e))?) + } + + /// Called whenever the vault locks: nothing secret stays live (the core drops its keys itself). + pub async fn on_lock(&self) { + self.core.join_cancel(); + self.cancel_add_device().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pins_split_on_commas_and_spaces() { + assert_eq!(parse_pins(" a=, b=\nc= "), vec!["a=", "b=", "c="]); + assert!(parse_pins(" ").is_empty()); + } + + #[test] + fn device_name_is_never_empty() { + assert!(!device_name().is_empty()); + } + + #[tokio::test] + async fn settings_persist_and_secrets_stay_out_of_views() { + let dir = tempfile::tempdir().unwrap(); + let core = VaultCore::new_in_memory(); + let key = DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()); + let svc = SyncService::new(core, dir.path(), key, Box::new(|_| {})); + svc.configure("http://127.0.0.1:9/", vec![]).unwrap(); + assert_eq!(svc.status().server_url.as_deref(), Some("http://127.0.0.1:9")); + assert!(svc.status().device_fingerprint.is_some()); + + let payload = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW"; + let view = svc.setup_from_text(payload).unwrap(); + assert_eq!(view.server_host, "kestrove.samialabri.com"); + assert!(!view.preapproved); + assert!(!serde_json::to_string(&view).unwrap().contains("K12TB9")); + assert!(svc.setup_from_text("kestrove://setup/v9?s=x").is_err()); + + // A second service over the same folder sees the saved server. + let again = SyncService::new( + VaultCore::new_in_memory(), + dir.path(), + DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()), + Box::new(|_| {}), + ); + assert_eq!(again.status().server_url.as_deref(), Some("http://127.0.0.1:9")); + assert_eq!(again.status().device_fingerprint, svc.status().device_fingerprint); + } + + #[tokio::test] + async fn sync_needs_unlock() { + let dir = tempfile::tempdir().unwrap(); + let svc = SyncService::new( + VaultCore::new_in_memory(), + dir.path(), + DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()), + Box::new(|_| {}), + ); + assert!(svc.sync_now().await.is_err()); + assert!(!svc.can_sync()); + } +} diff --git a/src-tauri/tests/live_sync.rs b/src-tauri/tests/live_sync.rs new file mode 100644 index 0000000..59ddf76 --- /dev/null +++ b/src-tauri/tests/live_sync.rs @@ -0,0 +1,156 @@ +//! End to end against a real kestrove-server (ignored by default): +//! +//! ```sh +//! KV_SETUP_CODE=… KV_BIND=127.0.0.1:18090 KV_PUBLIC_URL=http://127.0.0.1:18090 kestrove-server & +//! KV_TEST_SERVER=http://127.0.0.1:18090 KV_TEST_SETUP_CODE=… cargo test --test live_sync -- --ignored --nocapture +//! ``` +//! +//! Three "app instances" with separate data folders go through the same `SyncService` calls the UI makes: +//! A creates the account; B joins with "Use my phone", approved from A with the pasted join code; items sync both +//! ways; C uses the Emergency Kit code (no ticket), waits for approval, A approves it by fingerprint. + +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use kestrove_desktop::device_key::DeviceKey; +use kestrove_desktop::sync::{SignIn, SyncService}; +use kv_ffi::{ItemDto, ItemKindDto, VaultCore}; +use zeroize::Zeroizing; + +const PASSWORD: &str = "correct horse battery staple"; + +fn instance(dir: &Path) -> Arc { + let core = VaultCore::open(dir.join("vault.sqlite").to_string_lossy().into_owned()).unwrap(); + let key = DeviceKey::load_or_create_file(dir).map_err(|e| e.to_string()); + SyncService::new(core, dir, key, Box::new(|_| {})) +} + +fn login(title: &str) -> ItemDto { + ItemDto { + id: String::new(), + kind: ItemKindDto::Login, + title: title.into(), + username: Some(format!("{title}-user")), + password: Some("hunter2".into()), + urls: vec!["https://example.com".into()], + totp: None, + notes: None, + favorite: false, + tags: vec![], + modified_at: 0, + } +} + +fn titles(svc: &SyncService) -> Vec { + svc.core().list_items().unwrap().into_iter().map(|i| i.title).collect() +} + +/// The server allows 5 auth calls a minute per IP; three devices from one machine hit that. +async fn patiently(mut f: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let start = Instant::now(); + loop { + match f().await { + Err(e) if e.contains("Too many attempts") && start.elapsed() < Duration::from_secs(150) => { + eprintln!("rate limited; waiting"); + tokio::time::sleep(Duration::from_secs(15)).await; + } + other => return other, + } + } +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs a running kestrove-server (KV_TEST_SERVER, KV_TEST_SETUP_CODE)"] +async fn create_join_approve_and_sync() { + let server = std::env::var("KV_TEST_SERVER").expect("KV_TEST_SERVER"); + let code = std::env::var("KV_TEST_SETUP_CODE").expect("KV_TEST_SETUP_CODE"); + let (da, db, dc) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + + // A: "Create a new account". + let a = instance(da.path()); + let sk = a.create_account(&server, vec![], &code, Zeroizing::new(PASSWORD.into())).await.unwrap(); + assert!(sk.starts_with("K1")); + assert!(a.status().logged_in); + assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error); + a.core().save_item(login("from-a")).unwrap(); + a.sync_now().await.unwrap(); + + // A: "Add a device" QR, then Cancel revokes the ticket. + let add = a.add_device().await.unwrap(); + assert!(add.qr_svg.starts_with("data:image/svg+xml")); + a.cancel_add_device().await; + + // B: "Use my phone" → the QR (and its text for the paste fallback). + let b = instance(db.path()); + let join = b.join_start(&server).await.unwrap(); + assert!(join.qr_text.starts_with("kestrove://join/v1?")); + let first = b.join_poll().await.unwrap(); + assert!(first.code.is_none() && first.setup.is_none()); + + // A: "Approve a computer" with the pasted join code; both show the same code. + let confirm = a.approve_join(&join.qr_text).await.unwrap(); + assert_eq!(Some(confirm.key_fingerprint.clone()), b.status().device_fingerprint); + tokio::time::sleep(Duration::from_millis(1100)).await; + let seen = b.join_poll().await.unwrap(); + assert_eq!(seen.code.as_deref(), Some(confirm.code.as_str())); + a.send_join().await.unwrap(); + + // B: the bundle arrives; then only the master password. + let setup = loop { + tokio::time::sleep(Duration::from_millis(1100)).await; + if let Some(s) = b.join_poll().await.unwrap().setup { + break s; + } + }; + assert!(setup.preapproved); + assert_eq!(setup.server_url, server.trim_end_matches('/')); + let r = patiently(|| b.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap(); + assert_eq!(r, SignIn::Ok); + assert_eq!(titles(&b), vec!["from-a"]); + assert!(b.stored_secret_key().is_some(), "B remembers the Secret Key, so unlock needs only the password"); + + // An item from B reaches A. + b.core().save_item(login("from-b")).unwrap(); + b.sync_now().await.unwrap(); + a.sync_now().await.unwrap(); + let mut got = titles(&a); + got.sort(); + assert_eq!(got, vec!["from-a", "from-b"]); + + // Lock clears keys: no sync while locked, and unlocking with the password works again. + b.core().lock(); + b.on_lock().await; + assert!(b.sync_now().await.is_err()); + b.core().unlock(PASSWORD.into(), b.stored_secret_key().unwrap().to_string()).unwrap(); + b.sync_now().await.unwrap(); + + // C: Emergency Kit code (no ticket) → pending → A approves after comparing fingerprints. + let kit = a.core().setup_payload_for_kit().unwrap(); + let c = instance(dc.path()); + let view = c.setup_from_text(&kit).unwrap(); + assert!(!view.preapproved); + let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap(); + assert_eq!(r, SignIn::PendingApproval); + let devices = a.devices().await.unwrap(); + let pending: Vec<_> = devices.iter().filter(|d| !d.approved && !d.revoked).collect(); + assert_eq!(pending.len(), 1); + assert_eq!(Some(pending[0].key_fingerprint.clone()), c.status().device_fingerprint); + assert_eq!(a.status().pending_devices, 1); + a.approve_device(&pending[0].device_id).await.unwrap(); + let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap(); + assert_eq!(r, SignIn::Ok); + let mut got = titles(&c); + got.sort(); + assert_eq!(got, vec!["from-a", "from-b"]); + + // Revoke C from A; C can no longer sync. + let c_id = pending[0].device_id.clone(); + a.revoke_device(&c_id).await.unwrap(); + assert!(a.devices().await.unwrap().iter().any(|d| d.device_id == c_id && d.revoked)); + assert!(c.sync_now().await.is_err()); +} diff --git a/src/main.ts b/src/main.ts index 4b259f2..bbfc148 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,9 +8,21 @@ interface ItemDto { id: string; title: string; username: string | null; password interface PairingView { id: string; name: string; caller: string; words: string[] } interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number } interface SaveView { id: string; origin: string; username: string; update: boolean } +interface SyncStatus { + server_url: string | null; pins: string[]; logged_in: boolean; syncing: boolean; last_sync_at: number | null; last_error: string | null; + pending_approval: boolean; pending_devices: number; integrity_alarm: string | null; device_fingerprint: string | null; + key_storage: string | null; key_warning: string | null; restored_items: number; changes: number; +} +interface DeviceView { device_id: string; name: string; platform: string; approved: boolean; revoked: boolean; current: boolean; created_at: string; key_fingerprint: string } +interface SetupView { server_url: string; server_host: string; account_id: string; preapproved: boolean } +interface JoinView { qr_svg: string; qr_text: string; expires_at: number } +interface JoinPollView { code: string | null; setup: SetupView | null } +interface AddDeviceView { qr_svg: string; expires_at: number } +interface JoinConfirmView { code: string; device_name: string; platform: string; key_fingerprint: string } const app = document.getElementById("app")!; const overlay = document.getElementById("overlay")!; +const modal = document.getElementById("modal")!; function el(tag: K, props: Partial & Record = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] { const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props); @@ -19,43 +31,296 @@ function el(tag: K, props: Partial (typeof e === "string" ? e : e instanceof Error ? e.message : String(e)); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/// "ab12cd34…" → "ab12 cd34 …" so two screens are easy to compare. +const groups = (hex: string) => hex.replace(/(.{4})/g, "$1 ").trim(); + +function countdown(expiresAt: number, onExpire: () => void): HTMLElement { + const span = el("span", { className: "muted" }); + const tick = () => { + if (!span.isConnected && span.dataset.started) return; + span.dataset.started = "1"; + const left = Math.max(0, Math.round(expiresAt - Date.now() / 1000)); + span.textContent = `Expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`; + if (left === 0) onExpire(); + else setTimeout(tick, 1000); + }; + queueMicrotask(tick); + return span; +} + +function ago(unix: number): string { + const s = Math.max(0, Math.round(Date.now() / 1000 - unix)); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86400) return `${Math.floor(s / 3600)} h ago`; + return new Date(unix * 1000).toLocaleString(); +} + +/// Bumped on every full render so polling loops of a screen that is gone stop. +let screen = 0; +let lastChanges = -1; async function render() { + screen++; const state = await invoke("state"); app.replaceChildren(); - if (!state.has_account) renderCreate(); + if (!state.has_account) await renderOnboarding(); else if (state.locked) renderUnlock(state); else await renderVault(state); await renderPrompts(state); } -// ------------------------------------------------------------------ account +// ------------------------------------------------------------------ onboarding (docs/10) -function renderCreate() { - const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" }); +type Tab = "phone" | "kit" | "paste" | "create"; + +async function renderOnboarding(tab: Tab = "phone") { + const status = await invoke("sync_status"); + const body = el("div", { className: "grid" }); + const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New account"]]; + const nav = el("div", { className: "tabs" }, ...tabs.map(([t, label]) => el("button", { + textContent: label, + className: t === tab ? "tab sel" : "tab", + onclick: async () => { + await invoke("join_cancel"); + app.replaceChildren(); + screen++; + await renderOnboarding(t); + }, + }))); + const card = el("div", { className: "card wide" }, el("h2", { textContent: "Set up Kestrove on this computer" }), nav, body); + if (status.key_warning) card.append(el("p", { className: "warn", textContent: status.key_warning })); + app.append(el("div", { className: "center" }, card)); + if (tab === "phone") phonePanel(body, status); + else if (tab === "kit") kitPanel(body); + else if (tab === "paste") pastePanel(body); + else createPanel(body, status); +} + +function serverInput(status: SyncStatus): HTMLInputElement { + return el("input", { type: "url", placeholder: "https://kestrove.example.com", value: status.server_url ?? "", spellcheck: false }); +} + +function phonePanel(body: HTMLElement, status: SyncStatus) { + const url = serverInput(status); const err = el("div", { className: "err" }); - const btn = el("button", { textContent: "Create vault", className: "primary" }); - const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err); - btn.onclick = async () => { - btn.disabled = true; + const start = el("button", { textContent: "Show QR code", className: "primary" }); + body.append( + el("p", { className: "muted", textContent: "Your phone sends this computer everything it needs. You'll only type your master password." }), + el("label", { className: "grid" }, "Your Kestrove server", url), + start, err, + ); + start.onclick = async () => { + start.disabled = true; + err.textContent = ""; try { - const secretKey = await invoke("create_account", { masterPassword: pw.value }); - pw.value = ""; - card.replaceChildren( - el("h2", { textContent: "Your Secret Key" }), - el("p", { className: "mono", textContent: secretKey }), - el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices. This device remembers it." }), - el("button", { textContent: "Continue", className: "primary", onclick: render }), - ); + const join = await invoke("join_start", { serverUrl: url.value }); + showJoin(body, join); + } catch (e) { + err.textContent = errText(e); + start.disabled = false; + } + }; + url.onkeydown = (e) => e.key === "Enter" && start.click(); + if (!url.value) url.focus(); +} + +function showJoin(body: HTMLElement, join: JoinView) { + const mine = screen; + const codeBox = el("div", { className: "code muted", textContent: "Waiting for your phone…" }); + const err = el("div", { className: "err" }); + const text = el("textarea", { readOnly: true, className: "mono small", rows: 3, value: join.qr_text }); + const copy = el("button", { textContent: "Copy", onclick: () => copyText(text) }); + const cancel = el("button", { + textContent: "Cancel", + onclick: async () => { + await invoke("join_cancel"); + await render(); + }, + }); + body.replaceChildren( + el("ol", { className: "steps" }, + el("li", { textContent: "On your phone, open Kestrove → Settings → Add a computer." }), + el("li", { textContent: "Scan this code." }), + el("li", { textContent: "Check that both screens show the same 6 digits, then approve on the phone." })), + el("img", { src: join.qr_svg, alt: "QR code for your phone", className: "qr" }), + el("div", { className: "row" }, countdown(join.expires_at, () => { + if (mine === screen) err.textContent = "The code expired. Cancel and start again."; + })), + codeBox, + el("details", {}, + el("summary", { textContent: "No phone? Approve from another computer" }), + el("p", { className: "muted", textContent: "On a computer that is already signed in, open Settings → Devices → Approve a computer, and paste this join code:" }), + text, el("div", { className: "row" }, copy)), + err, + el("div", { className: "row" }, cancel), + ); + void (async () => { + while (mine === screen) { + await sleep(2000); + if (mine !== screen) return; + try { + const p = await invoke("join_poll"); + if (p.code) { + codeBox.className = "code"; + codeBox.replaceChildren(el("div", { className: "muted", textContent: "Your phone should show:" }), el("div", { className: "digits", textContent: p.code })); + } + if (p.setup) { + if (mine === screen) finishSetup(body, p.setup); + return; + } + } catch (e) { + err.textContent = errText(e); + return; + } + } + })(); +} + +function kitPanel(body: HTMLElement) { + const file = el("input", { type: "file", accept: ".png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf" }); + const err = el("div", { className: "err" }); + const open = el("button", { textContent: "Open Emergency Kit…", className: "primary", onclick: () => file.click() }); + file.hidden = true; + file.onchange = async () => { + const f = file.files?.[0]; + if (!f) return; + err.textContent = ""; + open.disabled = true; + open.textContent = "Reading…"; + try { + const bytes = new Uint8Array(await f.arrayBuffer()); + const view = await invoke("setup_from_file", bytes); + finishSetup(body, view); + } catch (e) { + err.textContent = errText(e); + } finally { + open.disabled = false; + open.textContent = "Open Emergency Kit…"; + file.value = ""; + } + }; + body.append( + el("p", { className: "muted", textContent: "Choose your Emergency Kit PDF, or a photo or screenshot of its QR code (PNG or JPEG). Another device will need to approve this computer." }), + file, open, err, + ); +} + +function pastePanel(body: HTMLElement) { + const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://setup/v1?…", spellcheck: false }); + const err = el("div", { className: "err" }); + const next = el("button", { textContent: "Continue", className: "primary" }); + next.onclick = async () => { + err.textContent = ""; + try { + const view = await invoke("setup_from_text", { text: text.value }); + text.value = ""; + finishSetup(body, view); + } catch (e) { + err.textContent = errText(e); + } + }; + body.append(el("p", { className: "muted", textContent: "Paste the setup code from your Emergency Kit or from “Add a device” on another device." }), text, next, err); + text.focus(); +} + +/// The last step of every way in: only the master password. +function finishSetup(body: HTMLElement, view: SetupView) { + const pw = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" }); + const err = el("div", { className: "err" }); + const btn = el("button", { textContent: "Sign in", className: "primary" }); + const waiting = el("div"); + const submit = async () => { + btn.disabled = true; + err.textContent = ""; + btn.textContent = "Signing in…"; + try { + const r = await invoke<"ok" | "pending_approval">("finish_setup", { masterPassword: pw.value }); + if (r === "ok") { + pw.value = ""; + await render(); + return; + } + const status = await invoke("sync_status"); + waiting.replaceChildren(el("div", { className: "notice" }, + el("p", { textContent: "Almost done: approve this computer on a device that is already signed in (Settings → Devices). Check that it shows this fingerprint:" }), + el("p", { className: "mono", textContent: groups(status.device_fingerprint ?? "") }), + el("p", { className: "muted", textContent: "Then come back and choose “Check again”." }))); + btn.textContent = "Check again"; + } catch (e) { + err.textContent = errText(e); + btn.textContent = "Sign in"; + } + btn.disabled = false; + }; + btn.onclick = submit; + pw.onkeydown = (e) => e.key === "Enter" && void submit(); + body.replaceChildren( + el("p", {}, "Signing in to ", el("strong", { textContent: view.server_host }), "."), + el("p", { className: "muted", textContent: view.preapproved ? "This computer is pre-approved." : "Another device will need to approve this computer." }), + pw, btn, waiting, err, + el("button", { textContent: "Start over", className: "link", onclick: async () => { await invoke("clear_setup"); await render(); } }), + ); + pw.focus(); +} + +function createPanel(body: HTMLElement, status: SyncStatus) { + const url = serverInput(status); + const code = el("input", { placeholder: "Setup code (KV_SETUP_CODE in the server's .env)", spellcheck: false }); + const pins = el("input", { placeholder: "SPKI pins, base64, comma-separated (optional)", className: "mono small", spellcheck: false }); + const pw = el("input", { type: "password", placeholder: "Master password (10+ characters)", autocomplete: "new-password" }); + const pw2 = el("input", { type: "password", placeholder: "Repeat master password", autocomplete: "new-password" }); + const err = el("div", { className: "err" }); + const btn = el("button", { textContent: "Create account", className: "primary" }); + btn.onclick = async () => { + err.textContent = ""; + if (pw.value !== pw2.value) { + err.textContent = "The passwords don't match."; + return; + } + btn.disabled = true; + btn.textContent = "Creating…"; + try { + const secretKey = await invoke("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, masterPassword: pw.value }); + pw.value = pw2.value = ""; + await showSecretKey(secretKey); } catch (e) { err.textContent = errText(e); btn.disabled = false; + btn.textContent = "Create account"; + // A vault created here whose server registration failed: continue to the vault (Settings → Sync retries). + if ((await invoke("state")).has_account) { + body.append(el("button", { textContent: "Continue to the vault", onclick: render })); + } } }; - app.append(el("div", { className: "center" }, card)); - pw.focus(); + body.append( + el("p", { className: "muted", textContent: "For the first device on a new server. You'll get a Secret Key: keep it with your Emergency Kit." }), + el("label", { className: "grid" }, "Server", url), + el("label", { className: "grid" }, "Setup code", code), + el("details", {}, el("summary", { textContent: "Certificate pins (advanced)" }), pins), + pw, pw2, btn, err, + ); } +async function showSecretKey(secretKey: string) { + const kit = await invoke("kit_qr").catch(() => null); + const card = el("div", { className: "card wide" }, + el("h2", { textContent: "Your Secret Key" }), + el("p", { className: "mono big", textContent: secretKey }), + el("p", { className: "muted", textContent: "Write it down, or print this screen, and keep it with your Emergency Kit. Every new device needs it with your master password. This computer remembers it." })); + if (kit) { + card.append( + el("p", { textContent: "Setup code for your Emergency Kit (server, account and Secret Key; keep it as safe as the Secret Key):" }), + el("img", { src: kit, alt: "Emergency Kit setup code", className: "qr" })); + } + card.append(el("button", { textContent: "Continue", className: "primary", onclick: render })); + app.replaceChildren(el("div", { className: "center" }, card)); +} + +// ------------------------------------------------------------------ unlock + function renderUnlock(state: AppState) { const pw = el("input", { type: "password", placeholder: "Master password" }); const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" }); @@ -84,26 +349,86 @@ function renderUnlock(state: AppState) { // ------------------------------------------------------------------ vault +let refreshList: (() => Promise) | null = null; +let syncBox: HTMLElement | null = null; + async function renderVault(state: AppState) { - const items = await invoke("list_items"); const detail = el("section"); const list = el("ul"); - for (const it of items) { - const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" })); - li.onclick = async () => { - list.querySelectorAll("li").forEach((n) => n.classList.remove("sel")); - li.classList.add("sel"); - await renderDetail(detail, it.id); - }; - list.append(li); - } - if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." })); + const fill = async () => { + const items = await invoke("list_items"); + const selected = list.querySelector("li.sel")?.getAttribute("data-id"); + list.replaceChildren(); + for (const it of items) { + const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" })); + li.dataset.id = it.id; + if (it.id === selected) li.classList.add("sel"); + li.onclick = async () => { + list.querySelectorAll("li").forEach((n) => n.classList.remove("sel")); + li.classList.add("sel"); + await renderDetail(detail, it.id); + }; + list.append(li); + } + if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." })); + }; + await fill(); + refreshList = fill; const add = el("button", { textContent: "+ New login", onclick: () => renderEditor(detail) }); const settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) }); const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } }); - const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list); - if (state.ipc_error) aside.append(el("p", { className: "err pad", textContent: state.ipc_error })); + syncBox = el("div", { className: "sync" }); + const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list, syncBox); + if (state.ipc_error) aside.insertBefore(el("p", { className: "err pad", textContent: state.ipc_error }), list); app.append(aside, detail); + const status = await invoke("sync_status"); + lastChanges = status.changes; + showSync(status, detail, state); +} + +/// Sync footer and banners in the sidebar; updated on every `sync-status` event. +function showSync(s: SyncStatus, detail?: HTMLElement, state?: AppState) { + if (!syncBox?.isConnected) return; + const openSettings = () => { + const d = detail ?? (document.querySelector("main section") as HTMLElement | null); + if (d && state) void renderSettings(d, state); + else if (d) void invoke("state").then((st) => renderSettings(d, st)); + }; + const parts: Node[] = []; + if (s.integrity_alarm) { + parts.push(el("div", { className: "alarm" }, + el("strong", { textContent: "Sync stopped: integrity alarm" }), + el("p", { textContent: s.integrity_alarm }), + el("p", { textContent: "The server (or someone controlling it) sent data that doesn't match what your devices signed. Don't trust this server until you've checked it." }))); + } + if (s.pending_devices > 0) { + parts.push(el("div", { className: "notice" }, `${s.pending_devices} device${s.pending_devices > 1 ? "s" : ""} waiting for approval. `, + el("button", { textContent: "Review", className: "link", onclick: openSettings }))); + } + if (s.pending_approval) { + parts.push(el("div", { className: "notice", textContent: `This computer waits for approval on another device. Fingerprint: ${groups(s.device_fingerprint ?? "")}` })); + } + if (s.restored_items > 0) { + parts.push(el("div", { className: "notice", textContent: `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} deleted on another device came back because they were edited here.` })); + } + let line: string; + if (!s.server_url) line = "Not connected to a sync server"; + else if (s.syncing) line = "Syncing…"; + else if (s.last_error) line = s.last_error; + else if (s.last_sync_at) line = `Synced ${ago(s.last_sync_at)}`; + else line = "Not synced yet"; + const now = el("button", { + textContent: "Sync now", + disabled: s.syncing || !s.server_url, + onclick: async () => { + now.disabled = true; + await invoke("sync_now").catch(() => undefined); + }, + }); + parts.push(el("div", { className: "row between" }, + el("span", { className: s.last_error && !s.syncing ? "err small" : "muted small", textContent: line, title: s.server_url ?? "" }), + s.server_url ? now : el("button", { textContent: "Set up", onclick: openSettings }))); + syncBox.replaceChildren(...parts); } async function renderDetail(target: HTMLElement, id: string) { @@ -166,6 +491,8 @@ function renderEditor(target: HTMLElement) { target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, el("div", { className: "row" }, pass, gen), url, totp, save, err)); } +// ------------------------------------------------------------------ settings + async function renderSettings(target: HTMLElement, state: AppState) { const pairings = await invoke("list_pairings"); const idle = el("select", {}, ...[1, 5, 10, 15, 30, 60, 120].map((m) => el("option", { value: String(m), textContent: `${m} min`, selected: m === state.idle_minutes }))); @@ -187,13 +514,234 @@ async function renderSettings(target: HTMLElement, state: AppState) { ); } if (!pairings.length) list.append(el("li", { className: "muted", textContent: "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”." })); + const syncSection = el("div"); + const devicesSection = el("div"); target.replaceChildren( el("h2", { textContent: "Settings" }), el("label", { className: "row" }, "Lock after idle for ", idle), el("p", { className: "muted", textContent: "Kestrove also locks on screen lock and sleep." }), + el("h3", { textContent: "Sync" }), + syncSection, + el("h3", { textContent: "Devices" }), + devicesSection, + el("h3", { textContent: "Emergency Kit" }), + kitSection(), el("h3", { textContent: "Paired browsers" }), list, ); + const status = await invoke("sync_status"); + renderSyncSettings(syncSection, status, () => renderSettings(target, state)); + if (status.server_url && status.logged_in) await renderDevices(devicesSection); + else devicesSection.append(el("p", { className: "muted", textContent: "Connect to a sync server first." })); +} + +function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promise) { + const facts = el("dl", { className: "facts" }, + el("dt", { textContent: "Server" }), el("dd", { className: "mono", textContent: s.server_url ?? "—" }), + el("dt", { textContent: "Pins" }), el("dd", { className: "mono small", textContent: s.pins.length ? s.pins.join(", ") : "none (system certificates)" }), + el("dt", { textContent: "Last sync" }), el("dd", { textContent: s.last_sync_at ? ago(s.last_sync_at) : "never" }), + el("dt", { textContent: "This device" }), el("dd", { className: "mono", textContent: groups(s.device_fingerprint ?? "—") }), + el("dt", { textContent: "Device key" }), el("dd", { textContent: s.key_storage ?? "unavailable" })); + box.replaceChildren(facts); + if (s.key_warning) box.append(el("p", { className: "warn", textContent: s.key_warning })); + if (s.last_error) box.append(el("p", { className: "err", textContent: s.last_error })); + if (s.server_url && s.logged_in) return; + + // A vault from before sync (or whose registration failed): connect it. + const url = el("input", { type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" }); + const pins = el("input", { placeholder: "SPKI pins (optional)", className: "mono small", value: s.pins.join(", ") }); + const code = el("input", { placeholder: "Setup code (only for a new server)" }); + const err = el("div", { className: "err" }); + const btn = el("button", { textContent: "Connect", className: "primary" }); + btn.onclick = async () => { + btn.disabled = true; + err.textContent = ""; + try { + await invoke("configure_server", { serverUrl: url.value, pins: pins.value }); + if (await invoke("server_setup_open")) { + if (!code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault."); + await invoke("register_server", { setupCode: code.value }); + } else { + await invoke("sync_now"); + } + await reload(); + } catch (e) { + err.textContent = errText(e); + btn.disabled = false; + } + }; + box.append(el("div", { className: "card" }, url, pins, code, btn, err)); +} + +async function renderDevices(box: HTMLElement) { + const err = el("div", { className: "err" }); + const list = el("ul", { className: "plain" }); + const reload = () => renderDevices(box); + let devices: DeviceView[] = []; + try { + devices = await invoke("list_devices"); + } catch (e) { + err.textContent = errText(e); + } + for (const d of devices.filter((d) => !d.revoked)) { + const badges = [d.current ? "this device" : "", d.approved ? "" : "waiting for approval"].filter(Boolean).join(" · "); + const li = el("li", {}, + el("div", {}, el("strong", { textContent: d.name }), ` ${d.platform}`, badges ? el("span", { className: d.approved ? "muted" : "warn", textContent: ` · ${badges}` }) : ""), + el("div", { className: "muted mono small", textContent: groups(d.key_fingerprint) }), + el("div", { className: "muted small", textContent: `Added ${new Date(d.created_at).toLocaleString()}` })); + const actions = el("div", { className: "row" }); + if (!d.approved) { + actions.append(el("button", { + textContent: "Approve…", + className: "primary", + onclick: () => confirmModal( + `Approve “${d.name}”?`, + [el("p", { textContent: "Only approve it if the new device shows exactly this fingerprint:" }), el("p", { className: "mono big", textContent: groups(d.key_fingerprint) })], + "The fingerprints match: approve", + async () => { await invoke("approve_device", { deviceId: d.device_id }); await reload(); }), + })); + } + if (!d.current) { + actions.append(el("button", { + textContent: d.approved ? "Remove" : "Reject", + className: "danger", + onclick: () => confirmModal(`Remove “${d.name}”?`, [el("p", { textContent: "It will be signed out and can't sync again. Its local copy stays on it." })], "Remove device", + async () => { await invoke("revoke_device", { deviceId: d.device_id }); await reload(); }), + })); + } + li.append(actions); + list.append(li); + } + box.replaceChildren( + el("div", { className: "row" }, + el("button", { textContent: "Add a phone or tablet…", onclick: addDeviceModal }), + el("button", { textContent: "Approve a computer…", onclick: approveComputerModal })), + list, err); +} + +function kitSection(): HTMLElement { + const box = el("div"); + const show = el("button", { + textContent: "Show Emergency Kit setup code", + onclick: async () => { + try { + const qr = await invoke("kit_qr"); + box.replaceChildren( + el("p", { className: "warn", textContent: "This code contains your Secret Key. Keep it as safe as your Emergency Kit." }), + el("img", { src: qr, alt: "Emergency Kit setup code", className: "qr" }), + el("button", { textContent: "Hide", onclick: () => box.replaceChildren(show) })); + } catch (e) { + box.append(el("p", { className: "err", textContent: errText(e) })); + } + }, + }); + box.append(show); + return box; +} + +// ------------------------------------------------------------------ dialogs + +function closeModal() { + modal.replaceChildren(); + modal.hidden = true; +} + +function openModal(...children: Node[]) { + modal.replaceChildren(el("div", { className: "prompt" }, ...children)); + modal.hidden = false; +} + +function confirmModal(title: string, body: Node[], yes: string, action: () => Promise) { + const err = el("div", { className: "err" }); + const ok = el("button", { textContent: yes, className: "primary" }); + ok.onclick = async () => { + ok.disabled = true; + try { + await action(); + closeModal(); + } catch (e) { + err.textContent = errText(e); + ok.disabled = false; + } + }; + openModal(el("h3", { textContent: title }), ...body, el("div", { className: "row" }, ok, el("button", { textContent: "Cancel", onclick: closeModal })), err); +} + +/// "Add a device": a single-use, 10-minute QR. Cancel or Done revokes the ticket if it wasn't used. +async function addDeviceModal() { + let view: AddDeviceView; + try { + view = await invoke("add_device"); + } catch (e) { + openModal(el("h3", { textContent: "Add a device" }), el("p", { className: "err", textContent: errText(e) }), el("button", { textContent: "Close", onclick: closeModal })); + return; + } + const done = async () => { + closeModal(); + await invoke("cancel_add_device"); + }; + openModal( + el("h3", { textContent: "Add a phone or tablet" }), + el("p", { textContent: "In Kestrove on the new device, choose “Scan setup code” and scan this. Then it only asks for your master password." }), + el("img", { src: view.qr_svg, alt: "Setup code for a new device", className: "qr" }), + el("p", { className: "warn small", textContent: "It contains your Secret Key and works once. Don't show it to anyone else." }), + el("div", { className: "row between" }, countdown(view.expires_at, () => { if (!modal.hidden) void done(); }), el("button", { textContent: "Done", className: "primary", onclick: done })), + ); +} + +/// Desktops usually have no camera: approve a new computer from its pasted join code. +function approveComputerModal() { + const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://join/v1?…", spellcheck: false }); + const err = el("div", { className: "err" }); + const next = el("button", { textContent: "Continue", className: "primary" }); + const cancel = async () => { + await invoke("join_cancel"); + closeModal(); + }; + next.onclick = async () => { + next.disabled = true; + err.textContent = ""; + try { + const c = await invoke("approve_join", { joinCode: text.value }); + const approve = el("button", { textContent: "The codes match: approve", className: "primary" }); + const err2 = el("div", { className: "err" }); + approve.onclick = async () => { + approve.disabled = true; + try { + await invoke("send_join"); + openModal(el("h3", { textContent: "Approved" }), el("p", { textContent: `${c.device_name} will finish setting up and ask for your master password.` }), el("button", { textContent: "Close", className: "primary", onclick: closeModal })); + } catch (e) { + err2.textContent = errText(e); + approve.disabled = false; + } + }; + openModal( + el("h3", { textContent: `Add “${c.device_name}” (${c.platform})?` }), + el("p", { textContent: "Check that the new computer shows exactly:" }), + el("div", { className: "digits", textContent: c.code }), + el("p", { className: "muted small", textContent: `Device key ${groups(c.key_fingerprint)}` }), + el("div", { className: "row" }, approve, el("button", { textContent: "Cancel", onclick: cancel })), + err2, + ); + } catch (e) { + err.textContent = errText(e); + next.disabled = false; + } + }; + openModal( + el("h3", { textContent: "Approve a computer" }), + el("p", { textContent: "On the new computer choose “Use my phone”, open “No phone? Approve from another computer” and copy the join code. Paste it here:" }), + text, + el("div", { className: "row" }, next, el("button", { textContent: "Cancel", onclick: cancel })), + err, + ); + text.focus(); +} + +function copyText(area: HTMLTextAreaElement) { + area.select(); + if (navigator.clipboard) void navigator.clipboard.writeText(area.value).catch(() => document.execCommand("copy")); + else document.execCommand("copy"); } // ------------------------------------------------------------------ browser prompts (pairing, save) @@ -260,7 +808,27 @@ function saveCard(s: SaveView): HTMLElement { void listen("pairing-request", () => render()); void listen("save-request", () => render()); -void listen("locked", () => render()); +void listen("locked", () => { + closeModal(); + void render(); +}); +let alarmShown = false; +void listen("sync-status", (e) => { + const s = e.payload; + showSync(s); + if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList(); + lastChanges = s.changes; + if (s.integrity_alarm && !alarmShown) { + alarmShown = true; + openModal(el("h3", { textContent: "Sync stopped: integrity alarm" }), el("p", { textContent: s.integrity_alarm }), + el("p", { textContent: "Your local data is unchanged and stays usable. Don't trust the server until you've checked it." }), + el("button", { textContent: "OK", className: "primary", onclick: closeModal })); + } +}); +// Keep "Synced 3 min ago" fresh. +setInterval(() => { + if (syncBox?.isConnected) void invoke("sync_status").then((s) => showSync(s)); +}, 30_000); // Activity for the idle timer (Linux has no system idle time without extra deps). let lastTouch = 0; diff --git a/src/style.css b/src/style.css index df85120..429f843 100644 --- a/src/style.css +++ b/src/style.css @@ -1,7 +1,7 @@ :root { color-scheme: light dark; font: 14px system-ui, sans-serif; } body { margin: 0; } -main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; } -.center { display: grid; place-items: center; height: 100vh; grid-column: 1 / -1; } +main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; overflow: auto; } +.center { display: grid; place-items: center; min-height: 100vh; padding: 16px 0; box-sizing: border-box; grid-column: 1 / -1; } .card { display: grid; gap: 8px; width: 340px; } input, button { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; } button { cursor: pointer; } @@ -27,3 +27,32 @@ ul.plain li { list-style: none; padding: 8px 0; border-bottom: 1px solid #8883; .prompt { background: Canvas; color: CanvasText; border-radius: 10px; padding: 16px 20px; width: 420px; box-shadow: 0 10px 40px #0005; } .words { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 12px; } .words span { font: 600 15px ui-monospace, monospace; padding: 4px 8px; border-radius: 6px; background: #8882; } +#modal { position: fixed; inset: 0; background: #0006; display: grid; place-content: center; z-index: 2; } +#modal[hidden] { display: none; } +#modal .prompt { display: grid; gap: 8px; max-height: 90vh; overflow: auto; } +aside { display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; } +main > section { height: 100vh; overflow: auto; box-sizing: border-box; } +aside > ul { flex: 1; } +.sync { border-top: 1px solid #8884; padding: 8px 12px; display: grid; gap: 8px; } +.between { justify-content: space-between; } +.small { font-size: 12px; } +.big { font-size: 18px; } +.grid { display: grid; gap: 8px; } +.card.wide { width: 440px; } +.tabs { display: flex; gap: 4px; flex-wrap: wrap; } +.tab { border-color: transparent; background: none; } +.tab.sel { background: #8883; font-weight: 600; } +textarea { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; resize: vertical; width: 100%; box-sizing: border-box; } +.qr { width: 240px; height: 240px; justify-self: center; image-rendering: pixelated; border-radius: 6px; } +.code { text-align: center; padding: 8px; border-radius: 8px; background: #8881; } +.digits { font: 700 32px ui-monospace, monospace; letter-spacing: 2px; text-align: center; } +.steps { margin: 0; padding-left: 20px; display: grid; gap: 4px; } +.notice { background: #2c6e8622; border-radius: 8px; padding: 8px 10px; } +.alarm { background: #d3333322; border: 1px solid #d33; border-radius: 8px; padding: 8px 10px; } +.alarm p { margin: 4px 0 0; } +.warn { color: #b26b00; } +.link { border: none; background: none; color: #2c6e86; padding: 0; text-decoration: underline; justify-self: start; } +.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0 0 8px; } +.facts dd { margin: 0; overflow-wrap: anywhere; } +details { display: grid; gap: 6px; } +summary { cursor: pointer; }