From 497c62a9880cee9f0674d2316e070bc730f6d846 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 15:58:20 -0700 Subject: [PATCH] =?UTF-8?q?hephd:=20OIDC=20hub=20authentication=20?= =?UTF-8?q?=E2=80=94=20verification=20side=20(auth=2010a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticate op exchange at the network boundary (tech-spec §13). The hub now requires a valid OIDC bearer token on /sync/* and /rpc; local mode is unchanged (no auth). - heph-core: Store::authorize_owner_sub — single-tenant gate that claims the owner's oidc_sub on first sight, then authorizes only that sub (403 for any other identity). LocalStore impl over users.oidc_sub; RemoteStore stub. - hephd auth module: TokenVerifier trait (mockable seam) + OidcVerifier (jsonwebtoken, rust_crypto). Strict validation: RS256 pinned, exact iss + aud, exp/nbf, required sub; JWKS discovered + cached, refetched on unknown kid (rotation). Claims/AuthError. - Hub router takes Option; an axum middleware on every route extracts the Bearer token, verifies it off the async worker, and runs the owner gate — 401 missing/invalid, 403 wrong identity, 503 IdP-unreachable. Open (no auth) when unconfigured, for local dev. - main: --oidc-issuer/--oidc-audience enable the hub verifier (server mode). - Security tests, all offline: stub-verifier middleware (missing/bad/valid + owner gate) and an adversarial battery driving OidcVerifier against an in-process mock IdP — rejects expired, wrong iss/aud, unknown kid, tampered signature, alg confusion (HS256/none), and missing sub. The RSA key + JWKS are generated at runtime (rsa/rand/base64 dev-deps) so no key is committed. - tech-spec: add an end-of-v1 dependency-refresh pass to the roadmap. 108 tests green; clippy -D warnings + fmt + prek clean. Next: client-side device-code login + keyring (10b). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 608 ++++++++++++++++++++++- Cargo.toml | 1 + README.md | 7 +- crates/heph-core/src/sqlite/mod.rs | 23 + crates/heph-core/src/store.rs | 7 + crates/heph-core/tests/convergence.rs | 15 + crates/hephd/Cargo.toml | 5 + crates/hephd/src/auth.rs | 153 ++++++ crates/hephd/src/lib.rs | 2 + crates/hephd/src/main.rs | 41 +- crates/hephd/src/remote.rs | 7 + crates/hephd/src/sync.rs | 88 +++- crates/hephd/tests/auth_hub.rs | 294 +++++++++++ crates/hephd/tests/client_mode.rs | 4 +- crates/hephd/tests/sync_http.rs | 2 +- docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 6 +- 17 files changed, 1235 insertions(+), 29 deletions(-) create mode 100644 crates/hephd/src/auth.rs create mode 100644 crates/hephd/tests/auth_hub.rs diff --git a/Cargo.lock b/Cargo.lock index c901551..6a74c3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,12 +183,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -210,6 +222,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -328,18 +349,82 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -354,6 +439,38 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -365,6 +482,65 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "errno" version = "0.3.14" @@ -414,9 +590,25 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -497,6 +689,28 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[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.3.4" @@ -511,6 +725,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -571,10 +796,14 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "base64", "clap", "fs4", "heph-core", + "jsonwebtoken", + "rand 0.8.6", "reqwest", + "rsa", "serde", "serde_json", "tempfile", @@ -584,6 +813,24 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.1" @@ -830,11 +1077,38 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -842,6 +1116,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -933,6 +1213,58 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -940,6 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -954,6 +1287,30 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -982,6 +1339,25 @@ dependencies = [ "regex", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1032,6 +1408,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -1047,6 +1444,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1056,6 +1459,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1076,7 +1488,7 @@ dependencies = [ "bitflags", "num-traits", "rand 0.9.4", - "rand_chacha", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -1122,6 +1534,8 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -1131,10 +1545,20 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1150,6 +1574,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -1157,7 +1584,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -1241,6 +1668,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rrule" version = "0.13.0" @@ -1255,6 +1692,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -1269,6 +1726,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1325,6 +1791,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1391,6 +1877,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1406,6 +1903,28 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -1443,6 +1962,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1455,6 +1990,12 @@ 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.117" @@ -1493,7 +2034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -1548,6 +2089,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1698,6 +2270,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "ulid" version = "1.2.1" @@ -2120,6 +2698,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index c9a922c..bd921e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } clap = { version = "4", features = ["derive"] } fs4 = "0.12" axum = "0.8" +jsonwebtoken = { version = "10", features = ["rust_crypto"] } reqwest = { version = "0.13", default-features = false, features = [ "json", "query", diff --git a/README.md b/README.md index f8ba65f..217a590 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision ## Status -**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. **All three runtime modes are implemented and replicas sync through a hub over HTTP** — the offline-first everyday config (`local` + `hub_url`) converges end-to-end with a `yrs` text-CRDT merging bodies, and `client` mode proxies to a server with no local replica. Remaining: auth and the Neovim plugin. Built test-first (102 tests at last update). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and the hub authenticates op exchange with OIDC bearer tokens.** The offline-first everyday config (`local` + `hub_url`) converges end-to-end with a `yrs` text-CRDT merging bodies; the hub verifies tokens (JWKS/RS256) and enforces single-tenant ownership. Remaining: the client-side device-code login + token cache, and the Neovim plugin. Built test-first (108 tests at last update). The canonical tracker is **tech-spec §14**. | Area | State | |---|---| @@ -22,7 +22,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | yrs text-CRDT for body merge | ✅ done | | `server` (hub) mode + spoke push/pull sync over HTTP (axum) | ✅ done | | `client` mode + `RemoteStore` (online-only, no replica) | ✅ done | -| OIDC/Authentik auth + per-user isolation | ⏳ next | +| OIDC hub auth — bearer-token verification + owner gate | ✅ done | +| OIDC client — device-code login, keyring token cache | ⏳ next | | `heph.nvim` (primary surface) | ⏳ | ## Architecture @@ -34,7 +35,7 @@ A Cargo workspace, layered so the same core runs from a laptop to a hub: - **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). - **`heph.nvim/`** *(planned)* — the Neovim plugin, the primary editing/agenda surface. -**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth** *(planned)*: OIDC against Authentik, with per-user isolation. +**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth:** the hub verifies an OIDC bearer token (Authentik) on every op exchange — RS256/JWKS verification + a single-tenant owner gate; the client-side device-code login is in progress. Local-only instances need no auth. ## Build & run diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index f09821f..ee160fb 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -330,6 +330,29 @@ impl Store for LocalStore { syncstate::record(&self.conn, peer, pushed, pulled, now) } + fn authorize_owner_sub(&mut self, sub: &str) -> Result { + // The owner's bound identity (NULL until first authenticated sync). + let current: Option = self + .conn + .query_row( + "SELECT oidc_sub FROM users WHERE id = ?1", + [&self.owner_id], + |r| r.get(0), + ) + .optional()? + .flatten(); + match current { + None => { + self.conn.execute( + "UPDATE users SET oidc_sub = ?1 WHERE id = ?2", + (sub, &self.owner_id), + )?; + Ok(true) + } + Some(existing) => Ok(existing == sub), + } + } + fn conflicts_list(&self) -> Result> { apply::list_conflicts(&self.conn, &self.owner_id) } diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index d1fc704..162cf70 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -138,6 +138,13 @@ pub trait Store { fn record_sync(&mut self, peer: &str, pushed: Option<&str>, pulled: Option<&str>) -> Result<()>; + /// Single-tenant authentication gate (tech-spec §13). Map an OIDC `sub` to + /// this store's owner: on first sight, **claim** the owner by binding its + /// `oidc_sub`; thereafter authorize only that same `sub`. Returns `true` if + /// the `sub` owns this store, `false` if a different identity presented a + /// token. A hub calls this before serving any op exchange. + fn authorize_owner_sub(&mut self, sub: &str) -> Result; + /// Open merge conflicts surfaced for the user (`heph conflicts`). fn conflicts_list(&self) -> Result>; diff --git a/crates/heph-core/tests/convergence.rs b/crates/heph-core/tests/convergence.rs index f84af1e..ea5d8fb 100644 --- a/crates/heph-core/tests/convergence.rs +++ b/crates/heph-core/tests/convergence.rs @@ -74,6 +74,21 @@ fn sync_cursors_default_empty_then_advance_per_direction() { ); } +#[test] +fn owner_sub_gate_claims_first_then_requires_match() { + // Single-tenant gate (§13): the first sub claims the owner; only that sub + // is authorized thereafter. + let (mut a, _ca) = replica(1000); + assert!(a.authorize_owner_sub("sub-alice").unwrap(), "first claims"); + assert!(a.authorize_owner_sub("sub-alice").unwrap(), "same sub ok"); + assert!( + !a.authorize_owner_sub("sub-mallory").unwrap(), + "a different identity must be rejected" + ); + // Still bound to the original after a rejection. + assert!(a.authorize_owner_sub("sub-alice").unwrap()); +} + #[test] fn online_round_trip_propagates_a_node() { let (mut a, _ca) = replica(1000); diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml index d15d0b8..8a425fb 100644 --- a/crates/hephd/Cargo.toml +++ b/crates/hephd/Cargo.toml @@ -28,7 +28,12 @@ tracing-subscriber.workspace = true clap.workspace = true fs4.workspace = true axum.workspace = true +jsonwebtoken.workspace = true reqwest.workspace = true [dev-dependencies] tempfile = "3" +# Auth tests generate a throwaway RSA key + JWKS at runtime (no key in the repo). +rsa = "0.9" +rand = "0.8" +base64 = "0.22" diff --git a/crates/hephd/src/auth.rs b/crates/hephd/src/auth.rs new file mode 100644 index 0000000..b7eee96 --- /dev/null +++ b/crates/hephd/src/auth.rs @@ -0,0 +1,153 @@ +//! Hub-side OIDC bearer-token verification (tech-spec §13). +//! +//! The hub authenticates op exchange at the **network boundary**: a request to +//! `/sync/*` or `/rpc` must carry a valid OIDC access token. Verification is a +//! [`TokenVerifier`] trait so the hub never hard-depends on a live IdP — tests +//! inject a stub, and the real [`OidcVerifier`] is exercised against an +//! in-process mock IdP (see `tests/auth_hub.rs`). +//! +//! Security posture (the easy-to-get-wrong parts, each covered by a test): +//! - **algorithm pinning** — only `RS256`; `jsonwebtoken::decode` rejects any +//! token whose header `alg` is not in the validation set, so `none`/`HS256` +//! confusion cannot select a different key type. +//! - **issuer + audience** — both required and matched exactly. +//! - **expiry** — `exp` validated (and `nbf` if present). +//! - **subject** — `sub` required. +//! - **key rotation** — an unknown `kid` triggers a single JWKS refetch. + +use std::sync::RwLock; + +use jsonwebtoken::jwk::{Jwk, JwkSet}; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde::Deserialize; + +/// The verified identity we rely on — only the subject is consumed; the rest of +/// the token is validated by [`jsonwebtoken`] but discarded. +#[derive(Debug, Clone, Deserialize)] +pub struct Claims { + /// The stable OIDC subject (Authentik `sub_mode: hashed_user_id`). + pub sub: String, +} + +/// Why a token was not accepted. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + /// No bearer token was presented. + #[error("missing bearer token")] + Missing, + /// The token was present but failed validation. + #[error("invalid token: {0}")] + Invalid(String), + /// The identity provider could not be reached to fetch keys. + #[error("identity provider unreachable: {0}")] + Provider(String), +} + +/// Verifies a bearer token and returns its [`Claims`]. A trait so the hub can be +/// tested with a stub and never requires a live IdP at test time. +pub trait TokenVerifier: Send + Sync { + /// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its + /// claims, or an [`AuthError`]. + fn verify(&self, bearer: &str) -> Result; +} + +/// What an OIDC provider's discovery document tells us (we only need the JWKS). +#[derive(Debug, Deserialize)] +struct Discovery { + jwks_uri: String, +} + +/// Verifies tokens against a real OIDC provider: discovers the JWKS, caches it, +/// and validates RS256 signatures + `iss`/`aud`/`exp`/`sub`. +pub struct OidcVerifier { + issuer: String, + audience: String, + http: reqwest::blocking::Client, + jwks: RwLock>, +} + +impl OidcVerifier { + /// Verify tokens whose `iss` equals `issuer` and `aud` contains `audience`. + /// `issuer` must match the token's `iss` claim exactly (for Authentik, the + /// per-application issuer `https://.../application/o//`). + pub fn new(issuer: impl Into, audience: impl Into) -> OidcVerifier { + OidcVerifier { + issuer: issuer.into(), + audience: audience.into(), + http: reqwest::blocking::Client::new(), + jwks: RwLock::new(None), + } + } + + /// Resolve the JWKS URI from the provider's discovery document. + fn jwks_uri(&self) -> Result { + let url = format!( + "{}/.well-known/openid-configuration", + self.issuer.trim_end_matches('/') + ); + let disc: Discovery = self + .http + .get(url) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .and_then(reqwest::blocking::Response::json) + .map_err(|e| AuthError::Provider(e.to_string()))?; + Ok(disc.jwks_uri) + } + + /// Fetch (and cache) the provider's JWKS. + fn refresh_jwks(&self) -> Result<(), AuthError> { + let uri = self.jwks_uri()?; + let set: JwkSet = self + .http + .get(uri) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .and_then(reqwest::blocking::Response::json) + .map_err(|e| AuthError::Provider(e.to_string()))?; + *self.jwks.write().expect("jwks lock poisoned") = Some(set); + Ok(()) + } + + /// The cached signing key for `kid`, if present. + fn cached_key(&self, kid: &str) -> Option { + self.jwks + .read() + .expect("jwks lock poisoned") + .as_ref() + .and_then(|set| set.find(kid).cloned()) + } + + /// Find `kid`, refetching the JWKS once if it is not already cached (key + /// rotation). + fn key_for(&self, kid: &str) -> Result { + if let Some(key) = self.cached_key(kid) { + return Ok(key); + } + self.refresh_jwks()?; + self.cached_key(kid) + .ok_or_else(|| AuthError::Invalid(format!("unknown signing key '{kid}'"))) + } +} + +impl TokenVerifier for OidcVerifier { + fn verify(&self, bearer: &str) -> Result { + let header = decode_header(bearer).map_err(|e| AuthError::Invalid(e.to_string()))?; + let kid = header + .kid + .ok_or_else(|| AuthError::Invalid("token header has no kid".into()))?; + let jwk = self.key_for(&kid)?; + let key = DecodingKey::from_jwk(&jwk).map_err(|e| AuthError::Invalid(e.to_string()))?; + + // Strict validation: RS256 only (decode rejects other `alg`s), exact + // issuer + audience, and require the claims we depend on. + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&[&self.issuer]); + validation.set_audience(&[&self.audience]); + validation.set_required_spec_claims(&["exp", "sub", "aud", "iss"]); + + let data = decode::(bearer, &key, &validation) + .map_err(|e| AuthError::Invalid(e.to_string()))?; + Ok(data.claims) + } +} diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index c665ecd..14b0b80 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -7,6 +7,7 @@ //! query/mutation logic all lives in `heph-core`; this crate is transport, //! locking, and (later) sync/auth. +pub mod auth; pub mod client; pub mod clock; pub mod lock; @@ -17,6 +18,7 @@ pub mod sync; use std::path::PathBuf; +pub use auth::{AuthError, Claims, OidcVerifier, TokenVerifier}; pub use client::Client; pub use clock::SystemClock; pub use lock::LockGuard; diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index 781009a..dcc0b72 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -1,12 +1,14 @@ -//! `hephd` binary — starts the daemon in `local` or `server` mode. +//! `hephd` binary — starts the daemon in `local`, `server`, or `client` mode. //! -//! Both modes own the local SQLite file (exclusive lock) and serve surfaces -//! over a unix socket. **server** additionally exposes the hub HTTP endpoint for -//! spokes to sync against; a **local** instance given `--hub-url` becomes a -//! syncing spoke that background-exchanges its op-log with that hub (tech-spec -//! §3.1, §6.1, §12). `client` mode (no local replica) is a later slice. +//! `local`/`server` own the local SQLite file (exclusive lock); `client` keeps +//! no replica and proxies to a `--server-url`. All three serve surfaces over a +//! unix socket. **server** additionally exposes the hub HTTP endpoint for spokes +//! to sync against (requiring OIDC bearer tokens when `--oidc-issuer`/`-audience` +//! are set); a **local** instance given `--hub-url` becomes a syncing spoke that +//! background-exchanges its op-log with that hub (tech-spec §3.1, §6.1, §12, §13). use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; @@ -60,6 +62,15 @@ struct Cli { /// Server to proxy to (client mode only; required there). #[arg(long)] server_url: Option, + + /// OIDC issuer to verify hub bearer tokens against (server mode). When set + /// with --oidc-audience, the hub endpoints require a valid token. + #[arg(long)] + oidc_issuer: Option, + + /// OIDC audience (client id) hub tokens must carry (server mode). + #[arg(long)] + oidc_audience: Option, } #[tokio::main] @@ -106,7 +117,23 @@ async fn main() -> Result<()> { .http_addr .clone() .unwrap_or_else(|| DEFAULT_HTTP_ADDR.to_string()); - let app = sync::router(daemon.store()); + let verifier: Option> = + match (cli.oidc_issuer.clone(), cli.oidc_audience.clone()) { + (Some(issuer), Some(audience)) => { + tracing::info!(%issuer, "hub requires OIDC bearer tokens"); + Some(Arc::new(hephd::OidcVerifier::new(issuer, audience))) + } + (None, None) => { + tracing::warn!( + "hub running UNAUTHENTICATED (no --oidc-issuer/--oidc-audience)" + ); + None + } + _ => { + anyhow::bail!("--oidc-issuer and --oidc-audience must be set together") + } + }; + let app = sync::router(daemon.store(), verifier); let http_listener = TcpListener::bind(&addr) .await .with_context(|| format!("binding hub HTTP endpoint {addr}"))?; diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index db5dfea..33ec49a 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -206,6 +206,13 @@ impl Store for RemoteStore { Ok(()) } + fn authorize_owner_sub(&mut self, _sub: &str) -> Result { + // Hub-side gate; a no-replica client never hosts an endpoint to guard. + Err(Error::Remote( + "authorize_owner_sub is a hub-side operation".into(), + )) + } + fn conflicts_list(&self) -> Result> { self.call_as("conflicts.list", json!({})) } diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index bbbbb27..7a8ce59 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -19,8 +19,10 @@ use std::sync::{Arc, Mutex}; use anyhow::Result; -use axum::extract::{Query, State}; +use axum::extract::{Query, Request, State}; use axum::http::StatusCode; +use axum::middleware::{self, Next}; +use axum::response::Response as AxumResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; @@ -28,12 +30,21 @@ use serde_json::Value; use heph_core::{Op, Store}; +use crate::auth::{AuthError, TokenVerifier}; use crate::rpc::{self, Response, RpcError, INTERNAL_ERROR}; /// The shared store a hub serves from — any [`Store`], so a `server` fronts a /// `LocalStore` and (later modes) could front another backend. pub type SharedStore = Arc>; +/// What the hub HTTP routes share: the store and (when authentication is +/// configured) the bearer-token verifier. +#[derive(Clone)] +struct HubState { + store: SharedStore, + verifier: Option>, +} + /// A batch of ops in flight (push body / pull response). #[derive(Debug, Serialize, Deserialize)] pub struct OpsBody { @@ -86,13 +97,70 @@ fn apply_batch( Ok((applied, max_hlc)) } -/// The hub's HTTP router (server mode). Mount it on a TCP listener. -pub fn router(store: SharedStore) -> Router { +/// The hub's HTTP router (server mode). Mount it on a TCP listener. When +/// `verifier` is `Some`, every route requires a valid OIDC bearer token whose +/// `sub` owns this hub (tech-spec §13); `None` leaves the hub open (local dev). +pub fn router(store: SharedStore, verifier: Option>) -> Router { + let state = HubState { store, verifier }; Router::new() .route("/sync/pull", get(pull)) .route("/sync/push", post(push)) .route("/rpc", post(rpc_call)) - .with_state(store) + .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) + .with_state(state) +} + +/// Reject any request lacking a valid bearer token whose `sub` owns this hub. +/// A no-op when the hub has no verifier configured (open dev mode). +async fn require_auth( + State(state): State, + request: Request, + next: Next, +) -> Result { + let Some(verifier) = state.verifier.clone() else { + return Ok(next.run(request).await); // open: no auth configured + }; + + let Some(token) = bearer_token(&request) else { + return Err(StatusCode::UNAUTHORIZED); + }; + + // Verification (and the store gate) hit the network / DB — run off the async + // worker on the blocking pool. + let claims = tokio::task::spawn_blocking(move || verifier.verify(&token)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|e| match e { + AuthError::Provider(_) => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::UNAUTHORIZED, + })?; + + // Single-tenant gate: the token's identity must own this hub. + let store = state.store.clone(); + let owns = tokio::task::spawn_blocking(move || { + store + .lock() + .expect("store mutex poisoned") + .authorize_owner_sub(&claims.sub) + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !owns { + return Err(StatusCode::FORBIDDEN); + } + + Ok(next.run(request).await) +} + +/// Extract the `Authorization: Bearer ` value, if present. +fn bearer_token(request: &Request) -> Option { + request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::to_string) } /// One `POST /rpc` call: a method name + params, mirroring the unix-socket RPC. @@ -106,8 +174,8 @@ struct RpcCall { /// `POST /rpc` — run one [`rpc::dispatch`] call on the hub's store and return a /// JSON-RPC-shaped [`Response`] (result xor error). Always HTTP 200; method /// failures travel in the body so the client can reconstruct the error. -async fn rpc_call(State(store): State, Json(call): Json) -> Json { - let store = store.clone(); +async fn rpc_call(State(state): State, Json(call): Json) -> Json { + let store = state.store.clone(); let dispatched = tokio::task::spawn_blocking(move || { let mut guard = store.lock().expect("store mutex poisoned"); rpc::dispatch(&mut *guard, &call.method, call.params) @@ -136,10 +204,10 @@ struct PullQuery { /// `GET /sync/pull?after=` — ops past the caller's cursor, HLC order. async fn pull( - State(store): State, + State(state): State, Query(q): Query, ) -> Result, StatusCode> { - let ops = with_store(&store, move |s| s.ops_since(q.after.as_deref())) + let ops = with_store(&state.store, move |s| s.ops_since(q.after.as_deref())) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(OpsBody { ops })) @@ -147,10 +215,10 @@ async fn pull( /// `POST /sync/push` — merge the caller's ops; reply with how many newly applied. async fn push( - State(store): State, + State(state): State, Json(body): Json, ) -> Result, StatusCode> { - let (applied, _max) = with_store(&store, move |s| apply_batch(s, body.ops)) + let (applied, _max) = with_store(&state.store, move |s| apply_batch(s, body.ops)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(SyncReport { diff --git a/crates/hephd/tests/auth_hub.rs b/crates/hephd/tests/auth_hub.rs new file mode 100644 index 0000000..c594cf0 --- /dev/null +++ b/crates/hephd/tests/auth_hub.rs @@ -0,0 +1,294 @@ +//! Hub authentication (tech-spec §13, slice 10a). Two layers, both offline: +//! +//! 1. **Middleware + owner gate** via a stub verifier — proves the hub rejects +//! missing/invalid tokens, admits valid ones, and enforces single-tenant +//! ownership — with zero crypto. +//! 2. **`OidcVerifier` against an in-process mock IdP** (a real RSA key + JWKS) — +//! an adversarial battery proving the *crypto* path accepts a good token and +//! rejects every common forgery (expired, wrong iss/aud, unknown kid, +//! tampered signature, `alg` confusion, missing `sub`). +//! +//! No external IdP is touched; Authentik is only needed for a manual smoke test. + +use std::collections::HashMap; +use std::sync::{mpsc, Arc, Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use rsa::pkcs8::{EncodePrivateKey, LineEnding}; +use rsa::traits::PublicKeyParts; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde::Serialize; +use serde_json::{json, Value}; + +use heph_core::{FixedClock, LocalStore}; +use hephd::auth::{AuthError, Claims, OidcVerifier, TokenVerifier}; +use hephd::sync::{self, SharedStore}; + +const NOW: i64 = 1_704_067_200_000; +const AUDIENCE: &str = "heph-hub"; +const KID: &str = "test-key-1"; + +/// A throwaway RSA keypair generated once per test run — no key material lives +/// in the repo. `pem` signs tokens; `n`/`e` (base64url) are served in the JWKS. +struct TestKey { + pem: String, + n: String, + e: String, +} + +fn test_key() -> &'static TestKey { + static KEY: OnceLock = OnceLock::new(); + KEY.get_or_init(|| { + let mut rng = rand::thread_rng(); + let private = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA key"); + let public = RsaPublicKey::from(&private); + TestKey { + pem: private + .to_pkcs8_pem(LineEnding::LF) + .expect("encode PEM") + .to_string(), + n: URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()), + e: URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()), + } + }) +} + +// --- layer 1: middleware + owner gate (stub verifier) --------------------- + +/// A verifier that maps known opaque tokens to subjects — no crypto. +struct StubVerifier(HashMap); +impl TokenVerifier for StubVerifier { + fn verify(&self, bearer: &str) -> Result { + self.0 + .get(bearer) + .map(|sub| Claims { sub: sub.clone() }) + .ok_or_else(|| AuthError::Invalid("unknown stub token".into())) + } +} + +/// Start a hub with the given verifier over a fresh temp store; return base URL. +fn start_hub(verifier: Option>) -> String { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let dir = tempfile::tempdir().unwrap(); + let store = + LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap(); + let shared: SharedStore = Arc::new(Mutex::new(store)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + tx.send(listener.local_addr().unwrap()).unwrap(); + let _keep = dir; + axum::serve(listener, sync::router(shared, verifier)) + .await + .unwrap(); + }); + }); + format!( + "http://{}", + rx.recv_timeout(Duration::from_secs(5)).unwrap() + ) +} + +/// `POST /rpc health` with an optional bearer token; return the HTTP status. +fn rpc_health_status(base: &str, token: Option<&str>) -> u16 { + let http = reqwest::blocking::Client::new(); + let mut req = http + .post(format!("{base}/rpc")) + .json(&json!({ "method": "health", "params": {} })); + if let Some(t) = token { + req = req.header("Authorization", format!("Bearer {t}")); + } + req.send().unwrap().status().as_u16() +} + +fn stub(pairs: &[(&str, &str)]) -> Option> { + let map = pairs + .iter() + .map(|(t, s)| (t.to_string(), s.to_string())) + .collect(); + Some(Arc::new(StubVerifier(map))) +} + +#[test] +fn open_hub_needs_no_token() { + let base = start_hub(None); + assert_eq!(rpc_health_status(&base, None), 200); +} + +#[test] +fn authed_hub_rejects_missing_and_bad_tokens_admits_valid() { + let base = start_hub(stub(&[("tok-alice", "alice")])); + assert_eq!(rpc_health_status(&base, None), 401, "missing token"); + assert_eq!(rpc_health_status(&base, Some("garbage")), 401, "bad token"); + assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200, "valid"); +} + +#[test] +fn owner_gate_rejects_a_second_identity() { + let base = start_hub(stub(&[("tok-alice", "alice"), ("tok-mallory", "mallory")])); + // Alice authenticates first and claims the hub. + assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200); + // A different valid identity is forbidden (single-tenant isolation). + assert_eq!(rpc_health_status(&base, Some("tok-mallory")), 403); + // Alice still works. + assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200); +} + +// --- layer 2: OidcVerifier against a mock IdP (real RS256) ----------------- + +/// Start a mock OIDC provider (discovery + JWKS) on an ephemeral port; the +/// returned URL is both the issuer and the discovery base. +fn start_mock_idp() -> String { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tx.send(base.clone()).unwrap(); + let app = Router::new() + .route("/.well-known/openid-configuration", get(discovery)) + .route("/jwks", get(jwks)) + .with_state(base); + axum::serve(listener, app).await.unwrap(); + }); + }); + rx.recv_timeout(Duration::from_secs(5)).unwrap() +} + +async fn discovery(State(base): State) -> Json { + Json(json!({ "issuer": base, "jwks_uri": format!("{base}/jwks") })) +} + +async fn jwks() -> Json { + let key = test_key(); + Json(json!({ + "keys": [{ + "kty": "RSA", "use": "sig", "alg": "RS256", + "kid": KID, "n": key.n, "e": key.e, + }] + })) +} + +#[derive(Serialize)] +struct TokenClaims { + sub: String, + iss: String, + aud: String, + exp: u64, + iat: u64, +} + +#[derive(Serialize)] +struct NoSubClaims { + iss: String, + aud: String, + exp: u64, +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn rsa_key() -> EncodingKey { + EncodingKey::from_rsa_pem(test_key().pem.as_bytes()).unwrap() +} + +/// Sign an RS256 token with the given `kid` and standard claims. +fn sign(claims: &TokenClaims, kid: &str) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_string()); + encode(&header, claims, &rsa_key()).unwrap() +} + +fn good_claims(issuer: &str) -> TokenClaims { + TokenClaims { + sub: "hashed-eblume".into(), + iss: issuer.into(), + aud: AUDIENCE.into(), + exp: unix_now() + 3600, + iat: unix_now(), + } +} + +#[test] +fn oidc_verifier_accepts_a_valid_token() { + let issuer = start_mock_idp(); + let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE); + let token = sign(&good_claims(&issuer), KID); + let claims = verifier.verify(&token).expect("valid token accepted"); + assert_eq!(claims.sub, "hashed-eblume"); +} + +#[test] +fn oidc_verifier_rejects_forgeries() { + let issuer = start_mock_idp(); + let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE); + + // expired (well past jsonwebtoken's default 60s leeway) + let mut c = good_claims(&issuer); + c.exp = unix_now() - 3600; + assert!(verifier.verify(&sign(&c, KID)).is_err(), "expired"); + + // wrong issuer + let mut c = good_claims(&issuer); + c.iss = "https://evil.example".into(); + assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong iss"); + + // wrong audience + let mut c = good_claims(&issuer); + c.aud = "someone-else".into(); + assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong aud"); + + // unknown signing key (kid not in JWKS, even after refetch) + assert!( + verifier + .verify(&sign(&good_claims(&issuer), "other-kid")) + .is_err(), + "unknown kid" + ); + + // tampered signature + let mut token = sign(&good_claims(&issuer), KID); + let last = token.pop().unwrap(); + token.push(if last == 'A' { 'B' } else { 'A' }); + assert!(verifier.verify(&token).is_err(), "tampered signature"); + + // algorithm confusion: HS256-signed token must be rejected (RS256 pinned) + let mut hs = Header::new(Algorithm::HS256); + hs.kid = Some(KID.to_string()); + let hs_token = encode(&hs, &good_claims(&issuer), &EncodingKey::from_secret(b"x")).unwrap(); + assert!(verifier.verify(&hs_token).is_err(), "HS256 confusion"); + + // alg: none — a header claiming no signature + let none_token = "eyJhbGciOiJub25lIiwia2lkIjoidGVzdC1rZXktMSJ9.eyJzdWIiOiJ4In0."; + assert!(verifier.verify(none_token).is_err(), "alg none"); + + // missing sub + let nosub = NoSubClaims { + iss: issuer.clone(), + aud: AUDIENCE.into(), + exp: unix_now() + 3600, + }; + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(KID.to_string()); + let token = encode(&header, &nosub, &rsa_key()).unwrap(); + assert!(verifier.verify(&token).is_err(), "missing sub"); +} diff --git a/crates/hephd/tests/client_mode.rs b/crates/hephd/tests/client_mode.rs index f79b91d..15707a2 100644 --- a/crates/hephd/tests/client_mode.rs +++ b/crates/hephd/tests/client_mode.rs @@ -32,7 +32,9 @@ fn start_server() -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); tx.send(listener.local_addr().unwrap()).unwrap(); let _keep = dir; // keep the temp DB alive while we serve - axum::serve(listener, sync::router(shared)).await.unwrap(); + axum::serve(listener, sync::router(shared, None)) + .await + .unwrap(); }); }); let addr = rx.recv_timeout(Duration::from_secs(5)).unwrap(); diff --git a/crates/hephd/tests/sync_http.rs b/crates/hephd/tests/sync_http.rs index de4194e..c9c8e21 100644 --- a/crates/hephd/tests/sync_http.rs +++ b/crates/hephd/tests/sync_http.rs @@ -48,7 +48,7 @@ async fn start_hub() -> String { Box::leak(Box::new(dir)); // keep the hub DB file alive let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let app = sync::router(hub); + let app = sync::router(hub, None); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 6e7f14f..e5bec27 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -11,4 +11,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Body text CRDT (§5, §12, slice 8d): node bodies now merge through the `yrs` text CRDT (`body_crdt`) instead of last-writer-wins — whole-buffer writes are diffed into the doc and the yrs delta rides the op, so concurrent edits to different regions both survive and never enqueue a conflict. - Network sync over HTTP (§6.1, §12, slice 9a): `hephd --mode server` exposes a sync hub (`POST /sync/push`, `GET /sync/pull?after=`, axum) over the same store; `hephd --mode local --hub-url ` becomes a spoke that background-syncs its op-log with that hub (and on demand via the `sync.now`/`sync.status` RPC). Exchange is incremental by HLC cursor (`sync_state`) and idempotent. The merge engine is `heph-core`'s, unchanged. Unauthenticated/single-owner for now (auth lands with OIDC). `conflicts.list`/`conflicts.resolve` are now reachable over the daemon socket. - Client mode (§3.1, slice 9b): `hephd --mode client --server-url ` runs with no local replica, proxying every store call to a server's `POST /rpc` endpoint (the full daemon API over HTTP). The daemon is now backend-agnostic (`local`/`server` front a `LocalStore`, `client` a `RemoteStore`), so surfaces see the same unix-socket API in every mode. +- Hub authentication (§13, slice 10a): the sync hub now verifies an OIDC bearer token on `/sync/*` and `/rpc` — RS256-pinned JWT validation with exact issuer/audience, expiry, and a required subject; JWKS discovered and cached, refetched on key rotation (`jsonwebtoken`). Enabled with `hephd --mode server --oidc-issuer --oidc-audience ` (open when unset, for local dev). A single-tenant owner gate binds the hub to the first authenticated identity and rejects any other. Verification sits behind a `TokenVerifier` trait, so it's tested entirely offline (stub middleware + an adversarial battery against an in-process mock IdP). Client-side login (device-code flow) lands next. - CI runs the Rust suite (fmt/clippy/test) via the project build hook. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 1f0bdf6..5998b5b 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -292,6 +292,7 @@ All layers are required; CI runs them on every push/PR (extend `.forgejo/scripts - **Web UI** (the hub serves sync only in v1; reserve `axum` for it later). - **Actual k3s deployment** to blumeops (Dagger→Zot image, ArgoCD app + Kustomize manifests, external-secrets) — fast-follow once the architecture is proven; the hub binary is built to be deployable. - **Calendar** integration (read-mostly CalDAV; **never explode recurrence into stored events**), iOS/Watch capture, inferred/semantic context, P2P-over-tailnet sync fallback. +- **Dependency-refresh pass:** before declaring v1 done, sweep every external dependency (Cargo crates, the Neovim plugin's deps, CI/tooling) up to its latest stable release, re-running the full suite + `clippy`/`fmt` to catch breakage. Slices add deps at the version current when written; this reconciles them once at the end rather than churning mid-build. See [[design]] §5–§7 for the constraints later phases impose on present choices (keep tasks vs. calendar events separate; expand RRULEs lazily). @@ -326,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **102 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **108 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). **Done** @@ -340,12 +341,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Body text CRDT (§5, §12, slice 8d):** node bodies merge through the **`yrs`** text CRDT (`body_crdt` BLOB) instead of LWW. A device authors under a stable `client_id` derived from its `origin`; whole-buffer writes are diffed (common prefix/suffix, char-boundary safe) into the doc; the yrs delta rides the `node.create`/`node.set` op (`body_crdt` field) and `apply` merges it — concurrent disjoint edits both survive and never enqueue a conflict. - ✅ **Network sync (§6.1, §12, slice 9a):** **transport ratified = `axum` HTTP/JSON.** The hub (`server` mode) exposes `POST /sync/push` + `GET /sync/pull?after=` over the same store; a spoke (`local` + `hub_url`) runs `sync::sync_once` (pull→merge, then push) and background-syncs on a 30s interval. Incremental by HLC cursor (`sync_state`/`SyncCursors`); idempotent re-push is a no-op. Two spokes converge through a real-HTTP hub (incl. scalar conflict) in `tests/sync_http.rs`. **Unauthenticated for now, single-owner** (auth + per-user scoping is slice 10). - ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`. +- ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected). - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). **Not yet done (resume order)** -1. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub `POST /sync/*` + `/rpc` endpoints, full per-user isolation, adoption-with-deterministic-ids. +1. ⏳ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`heph auth login`), token cache in the OS keyring + auto-refresh, the spoke attaching its bearer to `sync_once`/RPC, and local→authed **adoption** (owner-embedded deterministic-id rewrite). Multi-tenant hub (owner-per-token storage) remains a future extension beyond the current single-tenant gate. 2. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). ## Related