hephd: OIDC hub authentication — verification side (auth 10a)
Some checks failed
Build / validate (pull_request) Failing after 3s

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<verifier>; 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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-01 15:58:20 -07:00
commit 497c62a988
17 changed files with 1235 additions and 29 deletions

608
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -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<bool> {
// The owner's bound identity (NULL until first authenticated sync).
let current: Option<String> = 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<Vec<Conflict>> {
apply::list_conflicts(&self.conn, &self.owner_id)
}

View file

@ -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<bool>;
/// Open merge conflicts surfaced for the user (`heph conflicts`).
fn conflicts_list(&self) -> Result<Vec<Conflict>>;

View file

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

View file

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

153
crates/hephd/src/auth.rs Normal file
View file

@ -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<Claims, AuthError>;
}
/// 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<Option<JwkSet>>,
}
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/<slug>/`).
pub fn new(issuer: impl Into<String>, audience: impl Into<String>) -> 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<String, AuthError> {
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<Jwk> {
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<Jwk, AuthError> {
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<Claims, AuthError> {
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::<Claims>(bearer, &key, &validation)
.map_err(|e| AuthError::Invalid(e.to_string()))?;
Ok(data.claims)
}
}

View file

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

View file

@ -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<String>,
/// 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<String>,
/// OIDC audience (client id) hub tokens must carry (server mode).
#[arg(long)]
oidc_audience: Option<String>,
}
#[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<Arc<dyn hephd::TokenVerifier>> =
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}"))?;

View file

@ -206,6 +206,13 @@ impl Store for RemoteStore {
Ok(())
}
fn authorize_owner_sub(&mut self, _sub: &str) -> Result<bool> {
// 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<Vec<Conflict>> {
self.call_as("conflicts.list", json!({}))
}

View file

@ -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<Mutex<dyn Store + Send>>;
/// 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<Arc<dyn TokenVerifier>>,
}
/// 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<Arc<dyn TokenVerifier>>) -> 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<HubState>,
request: Request,
next: Next,
) -> Result<AxumResponse, StatusCode> {
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 <token>` value, if present.
fn bearer_token(request: &Request) -> Option<String> {
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<SharedStore>, Json(call): Json<RpcCall>) -> Json<Response> {
let store = store.clone();
async fn rpc_call(State(state): State<HubState>, Json(call): Json<RpcCall>) -> Json<Response> {
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=<hlc>` — ops past the caller's cursor, HLC order.
async fn pull(
State(store): State<SharedStore>,
State(state): State<HubState>,
Query(q): Query<PullQuery>,
) -> Result<Json<OpsBody>, 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<SharedStore>,
State(state): State<HubState>,
Json(body): Json<OpsBody>,
) -> Result<Json<SyncReport>, 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 {

View file

@ -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<TestKey> = 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<String, String>);
impl TokenVerifier for StubVerifier {
fn verify(&self, bearer: &str) -> Result<Claims, AuthError> {
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<Arc<dyn TokenVerifier>>) -> 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<Arc<dyn TokenVerifier>> {
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<String>) -> Json<Value> {
Json(json!({ "issuer": base, "jwks_uri": format!("{base}/jwks") }))
}
async fn jwks() -> Json<Value> {
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");
}

View file

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

View file

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

View file

@ -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=<hlc>`, axum) over the same store; `hephd --mode local --hub-url <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 <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 <url> --oidc-audience <client-id>` (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.

View file

@ -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=<hlc>` 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