From 9f14c1f6510f245b3925e634f7507e33f52657c3 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 10 Sep 2026 12:40:47 +0300 Subject: [PATCH] Add Google OAuth, and let feed URLs come from a secret command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google requires OAuth for calendar access; app passwords stopped working for CalDAV, CardDAV and IMAP in March 2025, so there is no simpler path to offer. - Authorisation code flow over a loopback redirect, which is what Google supports for desktop clients now the copy-paste flow is gone, with PKCE so an intercepted code is useless without the verifier. Only the refresh token is persisted, 0600, in the state directory. - An expired grant is reported as itself: a consent screen still in Testing has its refresh tokens expired after 7 days, and "run calcalist google login" is more use than Google's bare invalid_grant. - doctor reports whether each Google endpoint is still authorised, since an installation that worked last week can stop with nothing having changed here. A webcal URL may now come from a command instead of the config. Google's secret iCal address grants read access to a whole calendar to anyone holding it, so writing it into a file described as portable and secret-free was a contradiction. Fixed a serious defect in the first draft of this module: random_token used fs::read on /dev/urandom, which reads to end of file. /dev/urandom has no end, so it allocated until the machine ran out of memory — it took the editor down with it. It now reads exactly 32 bytes, and a randomness failure is fatal rather than falling back to the clock, since a guessable state or PKCE verifier defeats the point of having them. Verified end to end against a live Posteo CalDAV calendar: pimsync validated the generated config against the real server, 58 events from a public feed were mirrored and pushed, and a second run was a no-op. 97 tests. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 + Cargo.lock | 755 ++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + TODO.md | 3 +- src/config.rs | 52 +++- src/doctor.rs | 38 ++- src/google/auth.rs | 605 ++++++++++++++++++++++++++++++++++++ src/google/mod.rs | 8 + src/main.rs | 31 +- src/pimsync.rs | 44 ++- 10 files changed, 1524 insertions(+), 18 deletions(-) create mode 100644 src/google/auth.rs create mode 100644 src/google/mod.rs diff --git a/.gitignore b/.gitignore index f8f5303..5321365 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /target /.devbox /result + +# OAuth client files and any other credentials must never be committed. +client_secret_*.json +*.credentials.json diff --git a/Cargo.lock b/Cargo.lock index 0f3ff06..c0f1e1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +44,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,6 +64,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" @@ -77,6 +89,21 @@ dependencies = [ "cpufeatures", ] +[[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 = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "calcalist" version = "0.1.0" @@ -85,9 +112,11 @@ dependencies = [ "clap", "serde", "serde_json", + "sha2", "tempfile", "thiserror", "toml", + "ureq", ] [[package]] @@ -137,7 +166,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -152,12 +181,47 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -167,6 +231,61 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[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 = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -180,7 +299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -195,6 +314,37 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -218,6 +368,135 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +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 = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -252,12 +531,46 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -270,6 +583,27 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -294,6 +628,20 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -304,7 +652,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -334,7 +717,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -359,18 +742,64 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.5" @@ -382,6 +811,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -389,10 +829,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -412,7 +852,47 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] @@ -454,24 +934,122 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -481,12 +1059,171 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 5303d43..ce6877c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,10 @@ blake3 = "1.8" clap = { version = "4.6", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.11" thiserror = "2.0" toml = "1.1" +ureq = { version = "3.4", default-features = false, features = ["json", "rustls", "gzip"] } [dev-dependencies] tempfile = "3" diff --git a/TODO.md b/TODO.md index 00141e0..720d555 100644 --- a/TODO.md +++ b/TODO.md @@ -30,7 +30,8 @@ Core modules: drive one-shot `pimsync sync` bracketing the reconcile step - [x] `doctor` asks `pimsync check` to validate the generated config, since pimsync's parser does not always match its documentation -- [ ] `google/auth.rs`, `google/api.rs`, `google/convert.rs` +- [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets +- [ ] `google/api.rs`, `google/convert.rs` - [x] Reintroduce `SchedulingSuppression` in `config.rs` (removed in M0 as dead code) Safety-critical behaviour: diff --git a/src/config.rs b/src/config.rs index c66b0be..779f039 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,7 +45,12 @@ pub enum EndpointKind { client_secret_command: Option, }, Webcal { - url: String, + /// The feed URL, when it is not sensitive. + url: Option, + /// A command printing the feed URL, for feeds whose address is itself a + /// credential — Google's "secret address in iCal format" grants read + /// access to the whole calendar to anyone holding it. + url_command: Option, }, } @@ -183,6 +188,26 @@ pub enum Problem { SinkNotWritable { aggregate: String, endpoint: String }, #[error("aggregate `{aggregate}` has max_delete_fraction {value}, which is outside 0.0..=1.0")] DeleteFractionOutOfRange { aggregate: String, value: f64 }, + #[error("endpoint `{endpoint}` has neither `url` nor `url_command`")] + NoUrlSource { endpoint: String }, + #[error("endpoint `{endpoint}` has both `url` and `url_command`; use one or the other")] + AmbiguousUrlSource { endpoint: String }, +} + +/// A webcal feed must name exactly one source for its URL. +fn check_endpoint(endpoint: &Endpoint, problems: &mut Vec) { + let EndpointKind::Webcal { url, url_command } = &endpoint.kind else { + return; + }; + match (url, url_command) { + (None, None) => problems.push(Problem::NoUrlSource { + endpoint: endpoint.id.clone(), + }), + (Some(_), Some(_)) => problems.push(Problem::AmbiguousUrlSource { + endpoint: endpoint.id.clone(), + }), + _ => {} + } } impl Config { @@ -217,6 +242,9 @@ impl Config { pub fn validate(&self) -> Result<(), ConfigError> { let mut problems = Vec::new(); self.check_unique_ids(&mut problems); + for endpoint in &self.endpoints { + check_endpoint(endpoint, &mut problems); + } for aggregate in &self.aggregates { self.check_aggregate(aggregate, &mut problems); } @@ -493,6 +521,28 @@ sources = ["missing"] ); } + #[test] + fn a_feed_must_name_exactly_one_url_source() { + let neither: Config = + toml::from_str("version = 1\n\n[[endpoint]]\nid = \"f\"\ntype = \"webcal\"\n") + .expect("parse"); + assert!(matches!( + neither.validate(), + Err(ConfigError::Invalid(problems)) + if problems == vec![Problem::NoUrlSource { endpoint: "f".into() }] + )); + + let both: Config = toml::from_str( + "version = 1\n\n[[endpoint]]\nid = \"f\"\ntype = \"webcal\"\nurl = \"https://e/f.ics\"\nurl_command = \"cmd\"\n", + ) + .expect("parse"); + assert!(matches!( + both.validate(), + Err(ConfigError::Invalid(problems)) + if problems == vec![Problem::AmbiguousUrlSource { endpoint: "f".into() }] + )); + } + #[test] fn rejects_an_out_of_range_delete_fraction() { let found = problems( diff --git a/src/doctor.rs b/src/doctor.rs index 9aa4112..228c4ea 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -4,7 +4,8 @@ use std::fmt; use std::fs; use std::path::Path; -use crate::config::{Config, ConfigError}; +use crate::config::{Config, ConfigError, EndpointKind}; +use crate::google::auth::{self, AuthError}; use crate::paths; use crate::pimsync::{self, PimsyncError}; @@ -55,6 +56,7 @@ pub fn run(config_path: Option<&Path>) -> Vec { let mut checks = vec![check_pimsync(), check_state_dir()]; if let Ok((config, _)) = &loaded { checks.push(check_generated_config(config)); + checks.extend(check_google_authorisation(config)); } checks.push(check_config(loaded)); checks @@ -124,6 +126,40 @@ fn check_state_dir() -> Check { } } +/// Reports whether each Google endpoint still has a usable authorisation. +/// +/// Worth a check of its own because an unpublished consent screen has its +/// refresh tokens expired after seven days, so an installation that worked last +/// week can stop without anything having changed locally. +fn check_google_authorisation(config: &Config) -> Vec { + let Ok(state_dir) = paths::state_dir() else { + return Vec::new(); + }; + config + .endpoints + .iter() + .filter(|endpoint| matches!(endpoint.kind, EndpointKind::Google { .. })) + .map(|endpoint| { + let outcome = match auth::credentials_for(config, &endpoint.id) { + Err(error) => Outcome::Fail(error.to_string()), + Ok(credentials) => { + match auth::access_token(&state_dir, &endpoint.id, &credentials) { + Ok(_) => Outcome::Ok(format!("`{}` is authorised", endpoint.id)), + Err(error @ (AuthError::NotAuthorised(_) | AuthError::Expired { .. })) => { + Outcome::Warn(error.to_string()) + } + Err(error) => Outcome::Warn(format!("could not be checked: {error}")), + } + } + }; + Check { + name: "google auth", + outcome, + } + }) + .collect() +} + fn check_config(loaded: Result<(Config, std::path::PathBuf), ConfigError>) -> Check { let outcome = match loaded { Ok((config, path)) => Outcome::Ok(format!( diff --git a/src/google/auth.rs b/src/google/auth.rs new file mode 100644 index 0000000..ac5045f --- /dev/null +++ b/src/google/auth.rs @@ -0,0 +1,605 @@ +//! OAuth 2.0 for an installed application. +//! +//! Google's Calendar API requires OAuth; there is no password to hand over. The +//! authorisation code is collected over a loopback redirect, which is the flow +//! Google supports for desktop clients since the copy-paste flow was withdrawn. +//! PKCE is used as well, so a code intercepted on the loopback interface is +//! useless without the verifier. +//! +//! Only the refresh token is persisted, in the state directory with owner-only +//! permissions. It never enters the portable configuration. + +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +const AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token"; + +/// Read and write access to calendars. Google classes this as sensitive, which +/// is why the consent screen needs publishing before refresh tokens stop +/// expiring after seven days. +pub const SCOPE: &str = "https://www.googleapis.com/auth/calendar"; + +/// Refresh a little early, so a token cannot expire mid-cycle. +const EXPIRY_MARGIN: Duration = Duration::from_secs(60); + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("could not run the secret command `{command}`: {source}")] + SecretCommand { + command: String, + #[source] + source: std::io::Error, + }, + #[error("the secret command `{command}` failed: {message}")] + SecretFailed { command: String, message: String }, + #[error("endpoint `{0}` has no client_secret_command, which Google's OAuth flow requires")] + NoClientSecret(String), + #[error("no endpoint named `{0}` in the configuration")] + UnknownEndpoint(String), + #[error("endpoint `{endpoint}` is a {kind} endpoint, not a Google one")] + NotGoogle { endpoint: String, kind: String }, + #[error("the client_secret_command for `{0}` produced nothing")] + EmptyClientSecret(String), + #[error("could not listen on a loopback port: {0}")] + Listen(#[source] std::io::Error), + #[error("could not read randomness from /dev/urandom: {0}")] + Randomness(#[source] std::io::Error), + #[error("the browser did not complete authorisation: {0}")] + Redirect(String), + #[error("authorisation was refused: {0}")] + Denied(String), + #[error("the redirect did not match the request; authorisation was abandoned")] + StateMismatch, + #[error("token request failed: {0}")] + Token(String), + #[error( + "the stored authorisation for `{endpoint}` is no longer accepted by Google. \ + An app whose consent screen is still in Testing has its refresh tokens expired \ + after 7 days. Run `calcalist google login {endpoint}` to authorise again." + )] + Expired { endpoint: String }, + #[error( + "Google returned no refresh token; re-run after removing calcalist from your account's third-party access, so consent is asked for again" + )] + NoRefreshToken, + #[error("endpoint `{0}` has not been authorised; run `calcalist google login {0}`")] + NotAuthorised(String), + #[error("could not access {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("could not parse the stored token at {path}: {source}")] + Parse { + path: PathBuf, + #[source] + source: serde_json::Error, + }, +} + +/// What is persisted between runs. The access token is cached only to avoid a +/// refresh on every command; the refresh token is the durable credential. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredToken { + pub refresh_token: String, + #[serde(default)] + pub access_token: Option, + /// Unix seconds at which `access_token` stops being usable. + #[serde(default)] + pub expires_at: u64, +} + +impl StoredToken { + fn usable_access_token(&self) -> Option<&str> { + let now = unix_now() + EXPIRY_MARGIN.as_secs(); + self.access_token + .as_deref() + .filter(|_| self.expires_at > now) + } +} + +/// Where an endpoint's refresh token lives. +pub fn token_path(state_dir: &Path, endpoint_id: &str) -> PathBuf { + state_dir.join("google").join(format!("{endpoint_id}.json")) +} + +/// Runs an external command and returns its first line of output. +/// +/// Secrets are fetched this way rather than stored, so the configuration file +/// stays portable and free of credentials. +pub fn run_secret_command(command: &str) -> Result { + let output = Command::new("sh") + .arg("-c") + .arg(command) + .output() + .map_err(|source| AuthError::SecretCommand { + command: command.to_string(), + source, + })?; + if !output.status.success() { + return Err(AuthError::SecretFailed { + command: command.to_string(), + message: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }); + } + let text = String::from_utf8_lossy(&output.stdout); + Ok(text.lines().next().unwrap_or_default().trim().to_string()) +} + +/// Everything needed to talk to Google for one endpoint. +#[derive(Debug, Clone)] +pub struct Credentials { + pub client_id: String, + pub client_secret: String, +} + +/// Resolves an endpoint's OAuth client, running its secret command to fetch the +/// client secret. The secret is never written to the configuration. +pub fn credentials_for( + config: &crate::config::Config, + endpoint_id: &str, +) -> Result { + let endpoint = config + .endpoint(endpoint_id) + .ok_or_else(|| AuthError::UnknownEndpoint(endpoint_id.to_string()))?; + let crate::config::EndpointKind::Google { + client_id, + client_secret_command, + .. + } = &endpoint.kind + else { + return Err(AuthError::NotGoogle { + endpoint: endpoint_id.to_string(), + kind: endpoint.kind.kind_name().to_string(), + }); + }; + let command = client_secret_command + .as_deref() + .ok_or_else(|| AuthError::NoClientSecret(endpoint_id.to_string()))?; + let client_secret = run_secret_command(command)?; + if client_secret.is_empty() { + return Err(AuthError::EmptyClientSecret(endpoint_id.to_string())); + } + Ok(Credentials { + client_id: client_id.clone(), + client_secret, + }) +} + +/// Obtains an access token, refreshing the stored one when it has expired. +pub fn access_token( + state_dir: &Path, + endpoint_id: &str, + credentials: &Credentials, +) -> Result { + let path = token_path(state_dir, endpoint_id); + let mut stored = + load_token(&path)?.ok_or_else(|| AuthError::NotAuthorised(endpoint_id.to_string()))?; + + if let Some(token) = stored.usable_access_token() { + return Ok(token.to_string()); + } + let refreshed = post_token(&[ + ("client_id", &credentials.client_id), + ("client_secret", &credentials.client_secret), + ("refresh_token", &stored.refresh_token), + ("grant_type", "refresh_token"), + ]) + .map_err(|error| match error { + AuthError::Token(message) if message.contains("invalid_grant") => AuthError::Expired { + endpoint: endpoint_id.to_string(), + }, + other => other, + })?; + stored.access_token = Some(refreshed.access_token.clone()); + stored.expires_at = unix_now() + refreshed.expires_in; + save_token(&path, &stored)?; + Ok(refreshed.access_token) +} + +/// Walks the user through authorisation and stores the resulting refresh token. +pub fn login( + state_dir: &Path, + endpoint_id: &str, + credentials: &Credentials, +) -> Result<(), AuthError> { + let listener = + TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).map_err(AuthError::Listen)?; + let port = listener.local_addr().map_err(AuthError::Listen)?.port(); + let redirect_uri = format!("http://127.0.0.1:{port}"); + + let state = random_token()?; + let verifier = random_token()?; + let challenge = pkce_challenge(&verifier); + + let url = format!( + "{AUTH_ENDPOINT}?client_id={}&redirect_uri={}&response_type=code&scope={}\ + &access_type=offline&prompt=consent&state={}&code_challenge={}&code_challenge_method=S256", + encode(&credentials.client_id), + encode(&redirect_uri), + encode(SCOPE), + encode(&state), + encode(&challenge), + ); + + println!("Open this URL to authorise calcalist:\n\n{url}\n"); + open_in_browser(&url); + println!("Waiting for the redirect on {redirect_uri} ..."); + + let code = wait_for_code(&listener, &state)?; + let issued = post_token(&[ + ("code", &code), + ("client_id", &credentials.client_id), + ("client_secret", &credentials.client_secret), + ("redirect_uri", &redirect_uri), + ("grant_type", "authorization_code"), + ("code_verifier", &verifier), + ])?; + let refresh_token = issued.refresh_token.ok_or(AuthError::NoRefreshToken)?; + + let path = token_path(state_dir, endpoint_id); + save_token( + &path, + &StoredToken { + refresh_token, + access_token: Some(issued.access_token), + expires_at: unix_now() + issued.expires_in, + }, + )?; + println!("Authorised. Refresh token stored at {}", path.display()); + Ok(()) +} + +/// Accepts one loopback request and extracts the authorisation code from it. +fn wait_for_code(listener: &TcpListener, expected_state: &str) -> Result { + let (mut stream, _) = listener.accept().map_err(AuthError::Listen)?; + let target = read_request_target(&stream)?; + let query = target.split_once('?').map(|(_, query)| query).unwrap_or(""); + let params = parse_query(query); + + let outcome = match (params.get("code"), params.get("error"), params.get("state")) { + (_, Some(error), _) => Err(AuthError::Denied(error.clone())), + (Some(_), _, state) if state.map(String::as_str) != Some(expected_state) => { + Err(AuthError::StateMismatch) + } + (Some(code), _, _) => Ok(code.clone()), + (None, None, _) => Err(AuthError::Redirect( + "the redirect carried neither a code nor an error".into(), + )), + }; + respond(&mut stream, outcome.is_ok()); + outcome +} + +fn read_request_target(stream: &TcpStream) -> Result { + let mut line = String::new(); + BufReader::new(stream) + .read_line(&mut line) + .map_err(|error| AuthError::Redirect(error.to_string()))?; + line.split_whitespace() + .nth(1) + .map(str::to_string) + .ok_or_else(|| AuthError::Redirect(format!("could not parse request line {line:?}"))) +} + +fn respond(stream: &mut TcpStream, success: bool) { + let body = if success { + "

calcalist is authorised

You can close this tab.

" + } else { + "

Authorisation failed

Check the terminal for details.

" + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + // The browser tab is a courtesy; failing to write to it changes nothing. + let _ = stream.write_all(response.as_bytes()); +} + +#[derive(Debug, Deserialize)] +struct IssuedToken { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default = "default_expiry")] + expires_in: u64, +} + +fn default_expiry() -> u64 { + 3600 +} + +fn post_token(form: &[(&str, &str)]) -> Result { + let body = form + .iter() + .map(|(key, value)| format!("{}={}", encode(key), encode(value))) + .collect::>() + .join("&"); + + let agent: ureq::Agent = ureq::Agent::config_builder() + .http_status_as_error(false) + .build() + .into(); + let mut response = agent + .post(TOKEN_ENDPOINT) + .content_type("application/x-www-form-urlencoded") + .send(&body) + .map_err(|error| AuthError::Token(error.to_string()))?; + + let status = response.status(); + let text = response + .body_mut() + .read_to_string() + .map_err(|error| AuthError::Token(error.to_string()))?; + if !status.is_success() { + return Err(AuthError::Token(format!("HTTP {status}: {text}"))); + } + serde_json::from_str(&text).map_err(|error| AuthError::Token(error.to_string())) +} + +fn load_token(path: &Path) -> Result, AuthError> { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(AuthError::Io { + path: path.to_path_buf(), + source, + }); + } + }; + serde_json::from_str(&text) + .map(Some) + .map_err(|source| AuthError::Parse { + path: path.to_path_buf(), + source, + }) +} + +/// Writes the token readable only by its owner. It is a durable credential, and +/// the state directory may not be private on every system. +fn save_token(path: &Path, token: &StoredToken) -> Result<(), AuthError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| AuthError::Io { + path: parent.to_path_buf(), + source, + })?; + let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700)); + } + let text = serde_json::to_string_pretty(token).map_err(|source| AuthError::Parse { + path: path.to_path_buf(), + source, + })?; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(|source| AuthError::Io { + path: path.to_path_buf(), + source, + })?; + file.write_all(text.as_bytes()) + .map_err(|source| AuthError::Io { + path: path.to_path_buf(), + source, + }) +} + +fn open_in_browser(url: &str) { + // Best effort: on a headless machine the printed URL is the real interface. + let _ = Command::new("xdg-open").arg(url).status(); +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// 32 bytes of kernel randomness, base64url encoded. +/// +/// Reads exactly 32 bytes. `/dev/urandom` is an endless stream, so anything that +/// reads it to completion — `fs::read`, for one — allocates until the machine +/// runs out of memory. +/// +/// A failure here is fatal rather than papered over: both callers need +/// unpredictable values, and a guessable `state` or PKCE verifier would defeat +/// the protection they exist to provide. +fn random_token() -> Result { + let mut bytes = [0u8; 32]; + fs::File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .map_err(AuthError::Randomness)?; + Ok(base64url(&bytes)) +} + +fn pkce_challenge(verifier: &str) -> String { + base64url(&Sha256::digest(verifier.as_bytes())) +} + +/// Base64 with the URL alphabet and no padding, as RFC 7636 requires. +fn base64url(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let bits = chunk.iter().enumerate().fold(0u32, |acc, (index, byte)| { + acc | (u32::from(*byte) << (16 - 8 * index)) + }); + // Three bytes make four characters; a short chunk makes proportionally fewer. + for index in 0..=chunk.len() { + let shift = 18 - 6 * index; + out.push(ALPHABET[((bits >> shift) & 0b11_1111) as usize] as char); + } + } + out +} + +fn encode(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn parse_query(query: &str) -> std::collections::HashMap { + query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .map(|(key, value)| (decode(key), decode(value))) + .collect() +} + +fn decode(value: &str) -> String { + let mut out = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' if index + 2 < bytes.len() => { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""); + match u8::from_str_radix(hex, 16) { + Ok(byte) => { + out.push(byte); + index += 3; + } + Err(_) => { + out.push(bytes[index]); + index += 1; + } + } + } + b'+' => { + out.push(b' '); + index += 1; + } + byte => { + out.push(byte); + index += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 4648 section 10 test vectors, minus the padding RFC 7636 forbids. + #[test] + fn base64url_matches_the_specification() { + assert_eq!(base64url(b""), ""); + assert_eq!(base64url(b"f"), "Zg"); + assert_eq!(base64url(b"fo"), "Zm8"); + assert_eq!(base64url(b"foo"), "Zm9v"); + assert_eq!(base64url(b"foob"), "Zm9vYg"); + assert_eq!(base64url(b"fooba"), "Zm9vYmE"); + assert_eq!(base64url(b"foobar"), "Zm9vYmFy"); + } + + /// The URL alphabet must use - and _ rather than + and /, or Google rejects + /// the challenge. + #[test] + fn base64url_uses_the_url_alphabet() { + let encoded = base64url(&[0xfb, 0xff, 0xfe]); + assert!(!encoded.contains('+'), "{encoded}"); + assert!(!encoded.contains('/'), "{encoded}"); + assert!(!encoded.contains('='), "{encoded}"); + } + + /// RFC 7636 appendix B. Getting this wrong fails authorisation with an + /// unhelpful error, so it is pinned to the published vector. + #[test] + fn pkce_challenge_matches_rfc_7636() { + assert_eq!( + pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + /// Regression: this previously read /dev/urandom to EOF, which allocates + /// without bound and takes the machine down with it. + #[test] + fn a_verifier_is_long_enough_and_url_safe() { + let verifier = random_token().expect("randomness should be available"); + assert!(verifier.len() >= 43, "too short for RFC 7636: {verifier}"); + assert!( + verifier + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')), + "{verifier}" + ); + assert_ne!( + verifier, + random_token().expect("randomness should be available"), + "tokens must not repeat" + ); + } + + #[test] + fn percent_encoding_round_trips() { + for value in ["plain", "with space", "a/b?c=d&e", "sömething", "100%"] { + assert_eq!(decode(&encode(value)), value, "{value}"); + } + } + + #[test] + fn reserved_characters_are_escaped() { + assert_eq!(encode("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e"); + assert_eq!(encode("a-b_c.d~e"), "a-b_c.d~e"); + } + + #[test] + fn a_redirect_query_is_parsed() { + let params = parse_query("code=4%2F0Ab&state=xyz&scope=https%3A%2F%2Fexample"); + assert_eq!(params.get("code").map(String::as_str), Some("4/0Ab")); + assert_eq!(params.get("state").map(String::as_str), Some("xyz")); + assert_eq!( + params.get("scope").map(String::as_str), + Some("https://example") + ); + } + + #[test] + fn a_cached_access_token_is_reused_only_while_valid() { + let fresh = StoredToken { + refresh_token: "r".into(), + access_token: Some("a".into()), + expires_at: unix_now() + 3600, + }; + assert_eq!(fresh.usable_access_token(), Some("a")); + + let expired = StoredToken { + expires_at: unix_now(), + ..fresh.clone() + }; + assert_eq!(expired.usable_access_token(), None); + + // Within the margin it is treated as expired, so it cannot lapse mid-cycle. + let nearly = StoredToken { + expires_at: unix_now() + 5, + ..fresh + }; + assert_eq!(nearly.usable_access_token(), None); + } +} diff --git a/src/google/mod.rs b/src/google/mod.rs new file mode 100644 index 0000000..b87c6ea --- /dev/null +++ b/src/google/mod.rs @@ -0,0 +1,8 @@ +//! The Google Calendar leg. +//! +//! pimsync cannot reach Google at all — it has no REST storage, and its CalDAV +//! storage speaks only HTTP Basic auth, which Google's endpoint has rejected +//! since March 2025. So calcalist keeps a Google calendar in step with its local +//! vdir itself, doing for Google what pimsync does for CalDAV. + +pub mod auth; diff --git a/src/main.rs b/src/main.rs index 0a9e159..8a300f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod config; mod doctor; +mod google; mod ical; mod mirror; mod paths; @@ -17,8 +18,9 @@ use std::process::ExitCode; use clap::Parser; -use crate::cli::{Cli, Command}; +use crate::cli::{Cli, Command, GoogleCommand}; use crate::config::Config; +use crate::google::auth; use crate::sync::Report; fn main() -> ExitCode { @@ -31,6 +33,13 @@ fn main() -> ExitCode { command: Command::Sync { dry_run, force }, .. } => run_sync(cli, *dry_run, *force), + cli @ Cli { + command: + Command::Google { + command: GoogleCommand::Login { endpoint }, + }, + .. + } => run_google_login(cli, endpoint), Cli { command, .. } => unimplemented(command), } } @@ -65,6 +74,26 @@ fn run_sync(cli: &Cli, dry_run: bool, force: bool) -> ExitCode { } } +/// Authorises one Google endpoint, storing a refresh token for later cycles. +fn run_google_login(cli: &Cli, endpoint_id: &str) -> ExitCode { + let (config, _) = match Config::load(cli.config.as_deref()) { + Ok(loaded) => loaded, + Err(error) => return fail(&error), + }; + let state_dir = match paths::state_dir() { + Ok(dir) => dir, + Err(error) => return fail(&error), + }; + let credentials = match auth::credentials_for(&config, endpoint_id) { + Ok(credentials) => credentials, + Err(error) => return fail(&error), + }; + match auth::login(&state_dir, endpoint_id, &credentials) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => fail(&error), + } +} + fn print_report(report: &Report) { if report.aggregates.is_empty() { println!("no aggregates configured"); diff --git a/src/pimsync.rs b/src/pimsync.rs index 6b397a7..3af46f9 100644 --- a/src/pimsync.rs +++ b/src/pimsync.rs @@ -130,7 +130,13 @@ pub fn generate(config: &Config, state_dir: &Path) -> Result out.push_str(&caldav_pair(endpoint)?), - EndpointKind::Webcal { url } => out.push_str(&webcal_pair(endpoint, url)), + EndpointKind::Webcal { url, url_command } => { + out.push_str(&webcal_pair( + endpoint, + url.as_deref(), + url_command.as_deref(), + )); + } // Handled by calcalist's own Google module. EndpointKind::Google { .. } => {} } @@ -178,18 +184,24 @@ fn caldav_pair(endpoint: &Endpoint) -> Result { Ok(block) } -fn webcal_pair(endpoint: &Endpoint, url: &str) -> String { +fn webcal_pair(endpoint: &Endpoint, url: Option<&str>, url_command: Option<&str>) -> String { let id = &endpoint.id; + // A feed URL that is itself a credential is fetched by command, the same way + // passwords are, so it never appears in either configuration file. + let url_directive = match (url, url_command) { + (_, Some(command)) => format!("\turl {{\n\t\tshell {command}\n\t}}\n"), + (Some(url), None) => format!("\turl {}\n", quote(url)), + (None, None) => String::new(), + }; // No `read_only` directive here: pimsync 0.5.11 documents it as applying to // every storage type, but rejects it on a webcal storage with a bare "Could // not parse file". It is redundant regardless — webcal is read-only by type, // and `one_way` already fixes the direction. format!( - "\nstorage {id}_remote {{\n\ttype webcal\n\turl {}\n\tcollection_id {id}\n}}\n\ + "\nstorage {id}_remote {{\n\ttype webcal\n{url_directive}\tcollection_id {id}\n}}\n\ \npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\ \tcollection {{\n\t\talias {id}\n\t\tid_a {id}\n\t\tid_b {id}\n\t}}\n\ - \tone_way\n\ton_empty skip\n\ton_delete skip\n}}\n", - quote(url) + \tone_way\n\ton_empty skip\n\ton_delete skip\n}}\n" ) } @@ -377,6 +389,28 @@ url = "https://example.org/holidays.ics" assert_eq!(text.matches("on_delete skip").count(), pairs); } + /// A feed whose URL is itself a credential — Google's secret iCal address — + /// must be fetched by command, never written into either config file. + #[test] + fn a_secret_feed_url_is_fetched_by_command() { + let config: Config = toml::from_str( + r#" +version = 1 + +[[endpoint]] +id = "gcal-feed" +type = "webcal" +url_command = "secret-tool lookup service calcalist account gcal-ics" +"#, + ) + .expect("config should parse"); + let text = generate(&config, Path::new("/var/state/calcalist")).expect("generate"); + + assert!(text.contains("url {")); + assert!(text.contains("shell secret-tool lookup service calcalist account gcal-ics")); + assert!(!text.contains("https://"), "no literal URL should appear"); + } + #[test] fn splits_a_calendar_url_into_container_and_path() { assert_eq!(