From 31bd52915d35c2fe4da94b07cc620addda68dac0 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Tue, 6 Oct 2026 08:40:57 +0200 Subject: [PATCH 1/2] feat(app): keep the desktop signed in with an app password in the system keychain Signed-off-by: BryanFRD --- Cargo.lock | 544 +++++++++++++++++++++++++++++++- README.md | 9 + api/src/app_passwords.rs | 4 +- api/src/auth.rs | 6 +- api/src/dav/auth.rs | 2 +- api/src/error.rs | 3 + api/src/routes/app_passwords.rs | 13 +- api/src/routes/mod.rs | 4 + api/tests/app_passwords.rs | 2 +- api/tests/client_credentials.rs | 62 ++++ app/Cargo.toml | 1 + app/src/keychain.rs | 56 ++++ app/src/main.rs | 70 +++- app/src/sync.rs | 5 +- crates/client/src/remote.rs | 11 + web/src/app/app.html | 2 +- web/src/app/app.ts | 19 +- web/src/app/platform.ts | 6 + 18 files changed, 780 insertions(+), 39 deletions(-) create mode 100644 app/src/keychain.rs diff --git a/Cargo.lock b/Cargo.lock index 0b8a5940..52ea3026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -103,6 +114,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 = "arbitrary" version = "1.4.2" @@ -139,6 +161,126 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[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-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.6", +] + +[[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" @@ -784,6 +926,15 @@ 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" @@ -793,6 +944,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" @@ -934,6 +1098,15 @@ dependencies = [ "toml 1.1.6+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", +] + [[package]] name = "cc" version = "1.5.0" @@ -1010,6 +1183,16 @@ dependencies = [ "windows-link", ] +[[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", +] + [[package]] name = "clap" version = "4.6.7" @@ -1087,6 +1270,15 @@ 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" @@ -1165,6 +1357,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" @@ -1294,7 +1492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1509,7 +1707,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1703,6 +1901,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.2" @@ -1727,7 +1952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1750,6 +1975,16 @@ dependencies = [ "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 = "fastrand" version = "2.5.0" @@ -1951,6 +2186,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" @@ -2394,6 +2642,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" @@ -2855,6 +3109,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -3101,6 +3365,27 @@ 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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "kqueue" version = "1.2.1" @@ -3451,7 +3736,21 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +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]] @@ -3480,6 +3779,15 @@ dependencies = [ "zeroize", ] +[[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" @@ -3505,6 +3813,17 @@ dependencies = [ "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" @@ -3770,6 +4089,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 = "osakit" version = "0.3.1" @@ -3979,6 +4308,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[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 = "pkcs1" version = "0.7.5" @@ -4045,6 +4385,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 = "portable-atomic" version = "1.15.0" @@ -4195,7 +4549,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.45", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.21", "tokio", "tracing", @@ -4234,9 +4588,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4530,7 +4884,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4600,7 +4954,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4742,6 +5096,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf 0.13.0", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.0", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5424,6 +5797,7 @@ dependencies = [ name = "stashden-app" version = "0.33.1" dependencies = [ + "keyring", "serde", "serde_json", "stashden-client", @@ -6006,10 +6380,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6472,6 +6846,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.9.0" @@ -6887,7 +7272,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6985,6 +7370,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" @@ -7417,6 +7815,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.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +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.58" @@ -7549,3 +8028,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "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.6", + "winnow 1.0.4", +] diff --git a/README.md b/README.md index 26caa743..3afcd1da 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ from the system's file picker or from files dragged onto its window, streaming e rather than loading it into the window first. A dropped folder is refused with a note to sync it instead. +Signing in from the desktop app mints an app password named after the computer and keeps it in the +system keychain (Keychain on macOS, Credential Manager on Windows, the Secret Service on Linux), so +the app opens signed in and a folder keeps syncing past the twelve hours a session lasts. Signing +out revokes that app password on the server and removes it from the keychain. Revoking it from the +web app signs the desktop out on its next request. + ## Layout ``` @@ -239,6 +245,7 @@ GET /v1/thumbnails/{*path} a thumbnail of an image, made on demand GET /v1/app-passwords the credentials this account has minted POST /v1/app-passwords mint one, shown once DELETE /v1/app-passwords/{id} revoke one, taking effect immediately +DELETE /v1/app-passwords/current the one this request presents, to sign out PUT /v1/auth/password change your own, giving the current one GET /v1/users every account, with what each is using (admin) POST /v1/users create one (admin) @@ -284,6 +291,8 @@ POST /v1/version/{id}/{*path} restore it, keeping what it replaces as a ver Every `/v1` route except login and `/v1/public/*` takes `Authorization: Bearer `. The file, folder, trash, version and sharing routes also take an app password over Basic auth, the credential a long-running sync keeps, since a session token lapses after `SESSION_TTL_SECONDS`. +`DELETE /v1/app-passwords/current` revokes the app password that authenticates the request, which is +how a client signs out without holding a session. Deleting is reversible. `DELETE /v1/files/{*path}` marks the node and everything under it, credits the quota and leaves the bytes alone, so `GET /v1/trash` lists what was deleted and a restore puts it diff --git a/api/src/app_passwords.rs b/api/src/app_passwords.rs index 8ad685c1..e60fa84b 100644 --- a/api/src/app_passwords.rs +++ b/api/src/app_passwords.rs @@ -68,7 +68,7 @@ pub async fn revoke(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result<(), ApiErr /// Answers the account a `WebDAV` client is entitled to act as, or nothing at all. The presented /// secret is high entropy and server-generated, so it is fingerprinted rather than run through a /// password hash: clients send it on every request, and no dictionary reaches it. -pub async fn authenticate(pool: &PgPool, email: &Email, presented: &str) -> Option { +pub async fn authenticate(pool: &PgPool, email: &Email, presented: &str) -> Option<(User, Uuid)> { let user = crate::users::by_email(pool, email).await.ok().flatten()?; if user.disabled_at.is_some() { return None; @@ -94,7 +94,7 @@ pub async fn authenticate(pool: &PgPool, email: &Email, presented: &str) -> Opti .execute(pool) .await; - Some(user) + Some((user, id)) } fn secret() -> String { diff --git a/api/src/auth.rs b/api/src/auth.rs index 849a239b..3e274f00 100644 --- a/api/src/auth.rs +++ b/api/src/auth.rs @@ -71,7 +71,7 @@ impl Sessions { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Credential { Session, - AppPassword, + AppPassword(Uuid), } #[derive(Debug, Clone)] @@ -186,10 +186,10 @@ impl FromRequestParts for Caller { (user, Credential::Session) } else { let (email, secret) = basic_credentials(parts).ok_or(ApiError::Unauthenticated)?; - let user = app_passwords::authenticate(&state.db, &email, &secret) + let (user, id) = app_passwords::authenticate(&state.db, &email, &secret) .await .ok_or(ApiError::Unauthenticated)?; - (user, Credential::AppPassword) + (user, Credential::AppPassword(id)) }; if !user.is_active() { diff --git a/api/src/dav/auth.rs b/api/src/dav/auth.rs index e0165484..41ff822d 100644 --- a/api/src/dav/auth.rs +++ b/api/src/dav/auth.rs @@ -42,7 +42,7 @@ impl FromRequestParts for DavCaller { app_passwords::authenticate(&state.db, &email, &secret) .await - .map(Self) + .map(|(user, _)| Self(user)) .ok_or(Unauthenticated) } } diff --git a/api/src/error.rs b/api/src/error.rs index aa3f5686..6bf1f8aa 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -26,6 +26,8 @@ pub enum ApiError { Forbidden, #[error("an app password cannot manage the account, sign in with the account's password")] SessionRequired, + #[error("only an app password can revoke itself, a session revokes one by its id")] + AppPasswordRequired, #[error("{0} already exists")] Conflict(String), #[error("a directory cannot be moved inside itself")] @@ -96,6 +98,7 @@ impl ApiError { | Self::OffsetMismatch { .. } => StatusCode::CONFLICT, Self::Locked(_) => StatusCode::LOCKED, Self::Incomplete { .. } + | Self::AppPasswordRequired | Self::InvalidPath(_) | Self::InvalidEmail(_) | Self::WrongKind { .. } diff --git a/api/src/routes/app_passwords.rs b/api/src/routes/app_passwords.rs index 52391b3a..6c0becb1 100644 --- a/api/src/routes/app_passwords.rs +++ b/api/src/routes/app_passwords.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::app_passwords::{self, AppPassword, Minted}; -use crate::auth::SessionCaller; +use crate::auth::{Caller, Credential, SessionCaller}; use crate::error::ApiError; use crate::state::AppState; use crate::users; @@ -53,3 +53,14 @@ pub async fn revoke( app_passwords::revoke(&state.db, caller.user_id(), id).await?; Ok(StatusCode::NO_CONTENT) } + +pub async fn revoke_presented( + State(state): State, + caller: Caller, +) -> Result { + let Credential::AppPassword(id) = caller.via else { + return Err(ApiError::AppPasswordRequired); + }; + app_passwords::revoke(&state.db, caller.user.id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index f8603dc4..1a8e7006 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -33,6 +33,10 @@ pub fn router(state: AppState) -> Router { "/v1/app-passwords", get(app_passwords::list).post(app_passwords::mint), ) + .route( + "/v1/app-passwords/current", + delete(app_passwords::revoke_presented), + ) .route("/v1/app-passwords/{id}", delete(app_passwords::revoke)) .route("/v1/auth/password", put(users::change_password)) .route("/v1/users", get(users::list).post(users::create)) diff --git a/api/tests/app_passwords.rs b/api/tests/app_passwords.rs index 3b1c698d..40b2e05d 100644 --- a/api/tests/app_passwords.rs +++ b/api/tests/app_passwords.rs @@ -22,7 +22,7 @@ database_test!(a_minted_secret_authenticates_its_owner, harness, { let authenticated = authenticate(&harness.state.db, &email("dav@example.com"), &secret).await; assert_eq!( - authenticated.map(|user| user.id), + authenticated.map(|(user, _)| user.id), Some(owner.id), "the credential a client stores in plain text is not the account password, but it is the account" ); diff --git a/api/tests/client_credentials.rs b/api/tests/client_credentials.rs index 6b4dceea..f5fcdbcd 100644 --- a/api/tests/client_credentials.rs +++ b/api/tests/client_credentials.rs @@ -170,3 +170,65 @@ database_test!(a_session_still_manages_the_account, harness, { StatusCode::CREATED ); }); + +database_test!(an_app_password_revokes_itself, harness, { + let minted = app_password(&harness, "leaving@example.com", Role::Member).await; + + assert_eq!( + status( + &harness, + "DELETE", + "/v1/app-passwords/current", + &minted.basic, + "" + ) + .await, + StatusCode::NO_CONTENT + ); + assert_eq!( + status(&harness, "GET", "/v1/folders", &minted.basic, "").await, + StatusCode::UNAUTHORIZED, + "signing out leaves nothing behind that still opens the files" + ); +}); + +database_test!(revoking_itself_leaves_the_other_app_passwords, harness, { + let leaving = app_password(&harness, "two@example.com", Role::Member).await; + let mut tx = harness.state.db.begin().await.expect("begin"); + let staying = mint(&mut tx, leaving.owner, "the other machine") + .await + .expect("minting"); + tx.commit().await.expect("commit"); + let staying = basic("two@example.com", &staying.secret); + + status( + &harness, + "DELETE", + "/v1/app-passwords/current", + &leaving.basic, + "", + ) + .await; + + assert_eq!( + status(&harness, "GET", "/v1/folders", &staying, "").await, + StatusCode::OK + ); +}); + +database_test!(a_session_has_no_current_app_password_to_revoke, harness, { + let owner = harness.account("bearer@example.com", Role::Member).await; + let token = harness.state.sessions.issue(owner.id).expect("a token"); + + assert_eq!( + status( + &harness, + "DELETE", + "/v1/app-passwords/current", + &format!("Bearer {token}"), + "" + ) + .await, + StatusCode::BAD_REQUEST + ); +}); diff --git a/app/Cargo.toml b/app/Cargo.toml index 7940c519..0f9a85b9 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -22,6 +22,7 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } +keyring = "4" [lints] workspace = true diff --git a/app/src/keychain.rs b/app/src/keychain.rs new file mode 100644 index 00000000..c8b1965b --- /dev/null +++ b/app/src/keychain.rs @@ -0,0 +1,56 @@ +use keyring::{Entry, Error}; +use serde::{Deserialize, Serialize}; + +const SERVICE: &str = "Stashden"; +const ACCOUNT: &str = "desktop"; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Credentials { + pub server: String, + pub email: String, + pub secret: String, +} + +fn entry() -> Result { + Entry::new(SERVICE, ACCOUNT) + .map_err(|error| format!("the system keychain is unavailable: {error}")) +} + +pub async fn keep(credentials: Credentials) -> Result<(), String> { + blocking(move || { + let encoded = serde_json::to_string(&credentials).map_err(|error| error.to_string())?; + entry()? + .set_password(&encoded) + .map_err(|error| format!("the system keychain refused the app password: {error}")) + }) + .await +} + +pub async fn kept() -> Result, String> { + blocking(|| match entry()?.get_password() { + Ok(encoded) => serde_json::from_str(&encoded) + .map(Some) + .map_err(|error| format!("the keychain entry is not one this app wrote: {error}")), + Err(Error::NoEntry) => Ok(None), + Err(error) => Err(format!("the system keychain could not be read: {error}")), + }) + .await +} + +pub async fn forget() -> Result<(), String> { + blocking(|| match entry()?.delete_credential() { + Ok(()) | Err(Error::NoEntry) => Ok(()), + Err(error) => Err(format!( + "the system keychain kept the app password: {error}" + )), + }) + .await +} + +async fn blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(work) + .await + .map_err(|error| error.to_string())? +} diff --git a/app/src/main.rs b/app/src/main.rs index 69fd1dc3..a4e2ce6c 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -1,10 +1,11 @@ mod failure; +mod keychain; mod sync; use std::path::PathBuf; use stashden_client::sync::watch::Session as SyncSession; -use stashden_client::{Remote, free_path}; +use stashden_client::{Remote, RemoteError, free_path}; use stashden_core::grant::{Given, NewGrant, Received}; use stashden_core::node::{Node, Trashed}; use stashden_core::share::{Minted, NewShare, Share}; @@ -16,14 +17,9 @@ use tauri_plugin_updater::UpdaterExt; use tokio::sync::Mutex; use failure::Failure; +use keychain::Credentials; use uuid::Uuid; -#[derive(Clone)] -struct Credentials { - server: String, - token: String, -} - #[derive(Default)] struct Desktop { remote: Mutex>, @@ -40,18 +36,54 @@ async fn login( email: String, password: String, ) -> Result<(), String> { - let (remote, session) = Remote::login(&server, &email, &password) + let (session, _) = Remote::login(&server, &email, &password) + .await + .map_err(|error| error.to_string())?; + let minted = session + .mint_app_password(&format!("Stashden desktop on {}", this_computer())) .await .map_err(|error| error.to_string())?; - *desktop.remote.lock().await = Some(remote); - *desktop.credentials.lock().await = Some(Credentials { + let credentials = Credentials { server, - token: session.token, - }); + email, + secret: minted.secret, + }; + connect(&desktop, credentials.clone()).await?; + keychain::keep(credentials).await +} + +#[tauri::command] +async fn resume(desktop: State<'_, Desktop>) -> Result { + let Some(credentials) = keychain::kept().await? else { + return Ok(false); + }; + connect(&desktop, credentials).await?; + Ok(true) +} + +async fn connect(desktop: &Desktop, credentials: Credentials) -> Result<(), String> { + let remote = remote_for(&credentials).map_err(|error| error.to_string())?; + *desktop.remote.lock().await = Some(remote); + *desktop.credentials.lock().await = Some(credentials); Ok(()) } +fn remote_for(credentials: &Credentials) -> Result { + Remote::with_app_password( + &credentials.server, + credentials.email.as_str(), + credentials.secret.as_str(), + ) +} + +fn this_computer() -> String { + ["COMPUTERNAME", "HOSTNAME"] + .into_iter() + .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) + .unwrap_or_else(|| "this computer".to_owned()) +} + #[derive(serde::Serialize)] struct Available { version: String, @@ -106,9 +138,18 @@ async fn sign_out(desktop: State<'_, Desktop>) -> Result<(), String> { if let Some(session) = desktop.sync.lock().await.take() { session.stop().await; } - desktop.remote.lock().await.take(); desktop.credentials.lock().await.take(); - Ok(()) + let revoked = match desktop.remote.lock().await.take() { + Some(remote) => match remote.revoke_own_app_password().await { + Ok(()) | Err(RemoteError::Unauthenticated) => Ok(()), + Err(error) => Err(format!( + "the server did not revoke this computer's app password, revoke it from the web app: {error}" + )), + }, + None => Ok(()), + }; + keychain::forget().await?; + revoked } #[tauri::command] @@ -366,6 +407,7 @@ fn main() { .manage(Desktop::default()) .invoke_handler(tauri::generate_handler![ login, + resume, sign_out, list_folder, account, diff --git a/app/src/sync.rs b/app/src/sync.rs index 775e185b..29409b95 100644 --- a/app/src/sync.rs +++ b/app/src/sync.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use serde::Serialize; use stashden_client::sync::watch::{Command, DEFAULT_POLL, Status, watch}; -use stashden_client::{Debounce, Engine, Remote, Report}; +use stashden_client::{Debounce, Engine, Report}; use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_dialog::DialogExt; use tokio::sync::oneshot; @@ -64,8 +64,7 @@ pub async fn start_sync( .clone() .ok_or("not connected to a server")?; - let remote = - Remote::new(&credentials.server, credentials.token).map_err(|error| error.to_string())?; + let remote = crate::remote_for(&credentials).map_err(|error| error.to_string())?; let engine = Engine::open(folder.clone(), remote).map_err(|error| error.to_string())?; let session = watch(engine, Debounce::default(), DEFAULT_POLL).map_err(|error| error.to_string())?; diff --git a/crates/client/src/remote.rs b/crates/client/src/remote.rs index f87e58c9..8d565e28 100644 --- a/crates/client/src/remote.rs +++ b/crates/client/src/remote.rs @@ -175,6 +175,17 @@ impl Remote { Ok(()) } + pub async fn revoke_own_app_password(&self) -> Result<(), RemoteError> { + let response = self + .http + .delete(format!("{}/v1/app-passwords/current", self.base)) + .authorized(self) + .send() + .await?; + answered(response, "this app password").await?; + Ok(()) + } + pub(crate) fn base(&self) -> &str { &self.base } diff --git a/web/src/app/app.html b/web/src/app/app.html index 99821b81..fc605bb7 100644 --- a/web/src/app/app.html +++ b/web/src/app/app.html @@ -27,7 +27,7 @@ } - } @else { + } @else if (!resuming()) {