From 44253e66e3dfb2c047fbd624d95fb2f3c27a504e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 23:44:12 +0000 Subject: [PATCH 1/5] Add web server & collaboration spec (ADRs C012-C020) and foundation crates - docs/adr/LOKI_WEB_SERVER_SPEC.md: ratified v1 spec for the collaboration server (tiered encryption, Loro relay, sovereignty posture) - loki-model: WorkspaceId/DocumentId/UserId newtypes, EncryptionTier with ADR-C015 capability gates, RBAC Role/Action matrix (ADR-C017), EU-pinned Residency type (ADR-C019) - loki-crypto: per-document DEK envelope encryption (XChaCha20-Poly1305), crypto-agile KeyWrap trait with algorithm-tagged WrappedDek (ADR-C014), AeadKeyWrap (Tier 0/1 symmetric KEK) and X25519KeyWrap (Tier 2 zero-knowledge sharing) - loki-server-audit: append-only hash-chained audit entries with tamper-detection verification (ADR-C020) - stub manifests for the remaining server crates (store/collab/auth/api/bin) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014eNuucay71HTkHJisfmSqG --- Cargo.lock | 1252 +++++++++++++++++++++++++- Cargo.toml | 37 +- docs/adr/LOKI_WEB_SERVER_SPEC.md | 393 ++++++++ loki-crypto/Cargo.toml | 21 + loki-crypto/src/aead_wrap.rs | 118 +++ loki-crypto/src/dek.rs | 141 +++ loki-crypto/src/error.rs | 31 + loki-crypto/src/lib.rs | 30 + loki-crypto/src/wrap.rs | 74 ++ loki-crypto/src/x25519_wrap.rs | 167 ++++ loki-crypto/src/x25519_wrap_tests.rs | 70 ++ loki-model/Cargo.toml | 15 + loki-model/src/ids.rs | 113 +++ loki-model/src/lib.rs | 23 + loki-model/src/residency.rs | 122 +++ loki-model/src/role.rs | 126 +++ loki-model/src/tier.rs | 102 +++ loki-server-api/Cargo.toml | 30 + loki-server-api/src/lib.rs | 2 + loki-server-audit/Cargo.toml | 13 + loki-server-audit/src/action.rs | 57 ++ loki-server-audit/src/entry.rs | 162 ++++ loki-server-audit/src/entry_tests.rs | 78 ++ loki-server-audit/src/lib.rs | 17 + loki-server-auth/Cargo.toml | 18 + loki-server-auth/src/lib.rs | 2 + loki-server-collab/Cargo.toml | 24 + loki-server-collab/src/lib.rs | 2 + loki-server-store/Cargo.toml | 25 + loki-server-store/src/lib.rs | 2 + loki-server/Cargo.toml | 23 + loki-server/src/main.rs | 2 + 32 files changed, 3280 insertions(+), 12 deletions(-) create mode 100644 docs/adr/LOKI_WEB_SERVER_SPEC.md create mode 100644 loki-crypto/Cargo.toml create mode 100644 loki-crypto/src/aead_wrap.rs create mode 100644 loki-crypto/src/dek.rs create mode 100644 loki-crypto/src/error.rs create mode 100644 loki-crypto/src/lib.rs create mode 100644 loki-crypto/src/wrap.rs create mode 100644 loki-crypto/src/x25519_wrap.rs create mode 100644 loki-crypto/src/x25519_wrap_tests.rs create mode 100644 loki-model/Cargo.toml create mode 100644 loki-model/src/ids.rs create mode 100644 loki-model/src/lib.rs create mode 100644 loki-model/src/residency.rs create mode 100644 loki-model/src/role.rs create mode 100644 loki-model/src/tier.rs create mode 100644 loki-server-api/Cargo.toml create mode 100644 loki-server-api/src/lib.rs create mode 100644 loki-server-audit/Cargo.toml create mode 100644 loki-server-audit/src/action.rs create mode 100644 loki-server-audit/src/entry.rs create mode 100644 loki-server-audit/src/entry_tests.rs create mode 100644 loki-server-audit/src/lib.rs create mode 100644 loki-server-auth/Cargo.toml create mode 100644 loki-server-auth/src/lib.rs create mode 100644 loki-server-collab/Cargo.toml create mode 100644 loki-server-collab/src/lib.rs create mode 100644 loki-server-store/Cargo.toml create mode 100644 loki-server-store/src/lib.rs create mode 100644 loki-server/Cargo.toml create mode 100644 loki-server/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index edfbe562..e2a51c01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "ahash" version = "0.8.12" @@ -584,6 +594,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -685,6 +704,73 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "base16" version = "0.2.1" @@ -697,6 +783,12 @@ 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" @@ -1045,6 +1137,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1084,6 +1200,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.1" @@ -1172,6 +1299,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-serialize" version = "0.7.2" @@ -1323,6 +1456,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1392,6 +1540,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1411,6 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1453,6 +1611,32 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[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", + "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 2.0.118", +] + [[package]] name = "darling" version = "0.20.11" @@ -1540,6 +1724,23 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c" +[[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" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1585,7 +1786,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -1715,7 +1918,7 @@ dependencies = [ "subsecond", "thiserror 2.0.18", "tracing", - "tungstenite", + "tungstenite 0.28.0", ] [[package]] @@ -2071,6 +2274,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -2155,6 +2364,9 @@ name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] [[package]] name = "embedded-io" @@ -2319,6 +2531,17 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "euclid" version = "0.22.14" @@ -2389,6 +2612,12 @@ dependencies = [ "bytemuck", ] +[[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" @@ -2456,6 +2685,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2615,6 +2855,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -2631,6 +2886,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-intrusive" version = "0.5.0" @@ -2690,6 +2956,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2918,6 +3185,25 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2999,6 +3285,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heapless" version = "0.7.17" @@ -3063,6 +3358,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[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 = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -3122,6 +3444,18 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.10.1" @@ -3132,9 +3466,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3152,10 +3488,11 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -3450,6 +3787,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3630,13 +3976,28 @@ dependencies = [ ] [[package]] -name = "keyboard-types" -version = "0.7.0" +name = "jsonwebtoken" +version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "bitflags 2.13.0", - "serde", + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", "unicode-segmentation", ] @@ -3705,6 +4066,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128" @@ -3762,6 +4126,16 @@ dependencies = [ "redox_syscall 0.8.1", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3845,6 +4219,22 @@ dependencies = [ "sys-locale", ] +[[package]] +name = "loki-crypto" +version = "0.1.0" +dependencies = [ + "base64", + "chacha20poly1305", + "hkdf", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "x25519-dalek", + "zeroize", +] + [[package]] name = "loki-doc-model" version = "0.1.1" @@ -3936,6 +4326,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "loki-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "loki-odf" version = "0.1.0" @@ -4090,6 +4490,110 @@ dependencies = [ "wgpu", ] +[[package]] +name = "loki-server" +version = "0.1.0" +dependencies = [ + "axum", + "loki-crypto", + "loki-model", + "loki-server-api", + "loki-server-auth", + "loki-server-collab", + "loki-server-store", + "object_store", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "loki-server-api" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64", + "chrono", + "http-body-util", + "loki-crypto", + "loki-model", + "loki-server-audit", + "loki-server-auth", + "loki-server-collab", + "loki-server-store", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", + "uuid", +] + +[[package]] +name = "loki-server-audit" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "loki-server-auth" +version = "0.1.0" +dependencies = [ + "async-trait", + "jsonwebtoken", + "loki-model", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "loki-server-collab" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64", + "loki-model", + "loki-server-store", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "loki-server-store" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "loki-crypto", + "loki-model", + "loki-server-audit", + "object_store", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "loki-sheet-model" version = "0.1.0" @@ -4516,6 +5020,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4526,6 +5036,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "md5" version = "0.7.0" @@ -4746,6 +5266,22 @@ dependencies = [ "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-complex" version = "0.4.6" @@ -4755,6 +5291,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -5217,6 +5759,42 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "object_store" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "form_urlencoded", + "futures", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools 0.14.0", + "md-5", + "parking_lot", + "percent-encoding", + "quick-xml 0.38.4", + "rand 0.9.4", + "reqwest", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5229,6 +5807,18 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -5377,6 +5967,25 @@ dependencies = [ "ryu", ] +[[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 = "peniko" version = "0.5.0" @@ -5534,6 +6143,27 @@ dependencies = [ "futures-io", ] +[[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" @@ -5607,6 +6237,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5646,6 +6287,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" @@ -5767,6 +6414,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -6130,6 +6787,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -6142,6 +6800,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -6149,14 +6808,16 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -6242,6 +6903,26 @@ dependencies = [ "memchr", ] +[[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 = "rust-embed" version = "8.11.0" @@ -6337,6 +7018,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -6397,6 +7090,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -6423,10 +7125,33 @@ dependencies = [ ] [[package]] -name = "selectors" -version = "0.32.0" +name = "security-framework" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09975d3195f34dce9c7b381cb0f00c3c13381d4d3735c0f1a9c894b283b302ab" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09975d3195f34dce9c7b381cb0f00c3c13381d4d3735c0f1a9c894b283b302ab" dependencies = [ "bitflags 2.13.0", "cssparser", @@ -6543,6 +7268,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6632,6 +7368,16 @@ dependencies = [ "libc", ] +[[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 = "simd-adler32" version = "0.3.9" @@ -6663,6 +7409,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[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 = "simplecss" version = "0.2.2" @@ -6883,6 +7641,214 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.118", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.118", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6929,6 +7895,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -7331,6 +8308,36 @@ dependencies = [ "weezl", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -7442,10 +8449,23 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7456,6 +8476,42 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -7533,6 +8589,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -7571,6 +8628,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -7608,6 +8666,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -7618,12 +8686,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -7669,6 +8740,22 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "twox-hash" version = "2.1.2" @@ -7758,6 +8845,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -7794,6 +8890,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -7870,6 +8976,7 @@ version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -7892,6 +8999,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vello" version = "0.6.0" @@ -8036,6 +9149,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -8261,6 +9380,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -8435,6 +9563,16 @@ dependencies = [ "wgpu", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8640,6 +9778,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -8691,6 +9838,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -8739,6 +9901,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8757,6 +9925,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8775,6 +9949,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8805,6 +9985,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8823,6 +10009,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8841,6 +10033,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8859,6 +10057,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8985,6 +10189,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -9306,6 +10522,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 003d1836..b56dbbca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell"] +members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server"] [workspace.dependencies] # Shared across appthere-ui and any other workspace member that needs Dioxus. @@ -20,6 +20,41 @@ loki-file-access = { git = "https://github.com/appthere/loki-file-access", branc loki-fonts = { path = "loki-fonts" } android-activity = "0.6" +# ── Server-side crates (LOKI_WEB_SERVER_SPEC.md, ADRs C012–C020) ───────────── +loki-model = { path = "loki-model" } +loki-crypto = { path = "loki-crypto" } +loki-server-audit = { path = "loki-server-audit" } +loki-server-store = { path = "loki-server-store" } +loki-server-collab = { path = "loki-server-collab" } +loki-server-auth = { path = "loki-server-auth" } +loki-server-api = { path = "loki-server-api" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde", "clock", "std"], default-features = false } +tokio = { version = "1", default-features = false } +axum = { version = "0.8", features = ["ws", "macros"] } +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio", + "tls-rustls", + "postgres", + "uuid", + "chrono", + "migrate", + "derive", +] } +object_store = { version = "0.12", features = ["aws"] } +async-trait = "0.1" +tracing = "0.1" +chacha20poly1305 = "0.10" +x25519-dalek = { version = "2", features = ["static_secrets"] } +hkdf = "0.12" +sha2 = "0.10" +rand_core = { version = "0.6", features = ["getrandom"] } +zeroize = { version = "1", features = ["derive"] } +jsonwebtoken = "9" +base64 = "0.22" + [patch.crates-io] # PATCH: vendors dioxus-native-dom to avoid runtime unimplemented!() panics in # HtmlEventConverter (composition, touch, scroll, etc.) — remove when upstream diff --git a/docs/adr/LOKI_WEB_SERVER_SPEC.md b/docs/adr/LOKI_WEB_SERVER_SPEC.md new file mode 100644 index 00000000..a165f40e --- /dev/null +++ b/docs/adr/LOKI_WEB_SERVER_SPEC.md @@ -0,0 +1,393 @@ + + +# AppThere Loki — Web Server & Collaboration Spec + +**Status:** Ratified (v1) — open decisions closed 2026-07-01 +**Series:** AppThere Cloud, ADRs C012–C020 (extends C009–C011) +**Supersedes:** nothing; extends the AppThere Cloud spec +**Target edition:** Rust 2024 + +--- + +## 0. Scope & relationship to existing specs + +This spec realises the Loki-specific server: the real-time collaboration relay, the +document/storage API, and the identity and deployment story. It builds directly on the +AppThere Cloud spec (Axum / SQLx / Tokio, OIDC delegation, CRDT relay, ADRs C009–C011) +and does **not** re-litigate those foundations. What is new here: + +1. A **tiered encryption model** that reconciles "optional E2EE" with enterprise + server-side features, resolving the E2EE-vs-server-side-export mutual exclusivity + already identified in the Cloud spec. +2. First-class **self-hostability**: the same artifact deploys to Hetzner Cloud or to + the customer's own infrastructure, with no functional gap. +3. An explicit **European Digital Sovereignty** posture aligned to C5:2026, NIS2, and the + EU Cloud Sovereignty Framework (SEAL / SOV objectives). + +The **headless render/print/convert worker** is specified separately in +`LOKI_HEADLESS_SERVER_SPEC.md` (ADRs C021–C027); the two are designed to interlock. + +### Goals + +- Real-time multi-user editing of Loki documents backed by Loro CRDTs. +- PostgreSQL as the durable system-of-record; object storage for large blobs and snapshots. +- Three selectable confidentiality tiers, including zero-knowledge E2EE. +- One binary, two deployment modes (Hetzner Cloud / self-host), no feature fork. +- Enterprise readiness: SSO, RBAC, audit trail, HA, backup/restore, observability. +- Digital-sovereignty defaults suitable for EU public sector and regulated industry. + +### Non-goals (v1) + +- Iris raster collaboration (deferred to per-layer ownership per Cloud spec). +- Server-side full-text search over E2EE documents (impossible by construction; see C015). +- Billing (already covered by the Cloud spec's `async-stripe` decision; out of scope here). +- Federation between independent Loki server instances. + +### Engineering standards (inherited, non-negotiable) + +300-line file ceiling; no `unwrap()`/`expect()` in library code; `#![forbid(unsafe_code)]` +at every crate root; typed errors via `thiserror`; Apache-2.0 SPDX header on line 1; +no hardcoded user-visible strings (`fl!()`); audit-first / implement-second Claude Code +discipline. + +--- + +## 1. Component architecture + +``` + ┌────────────────────────────────────────────┐ + │ Reverse proxy / LB │ + │ (TLS 1.3 termination, HTTP/2) │ + └───────────────┬──────────────┬───────────────┘ + │ │ + ┌─────────────────────▼──┐ ┌──▼─────────────────────┐ + │ loki-server (N≥1) │ ... │ loki-server (N≥1) │ + │ ── Axum binary ── │ │ │ + │ • REST/API module │ │ │ + │ • WS collaboration mod │ │ │ + │ • auth (OIDC) module │ │ │ + └───┬───────────┬─────────┘ └────────────────────────┘ + │ │ (fan-out bus: pg LISTEN/NOTIFY or Redis) + ┌──────────────▼──┐ ┌───▼─────────────┐ ┌───────────────────────────┐ + │ PostgreSQL │ │ Object storage │ │ Job queue (apalis) │ + │ system-of-record│ │ (S3-compatible)│ │ → headless workers │ + │ metadata/ACL/ │ │ blobs/snapshots│ │ (render/print/convert) │ + │ oplog/audit │ │ SSE-C / app-enc│ └───────────────────────────┘ + └──────────────────┘ └─────────────────┘ + ▲ + ┌──────────────┴───────────────┐ + │ OIDC IdP (external) │ + │ Keycloak / Authentik / Zitadel│ ← self-hostable for sovereignty + └────────────────────────────────┘ +``` + +Crates (all new unless noted): + +| Crate | Responsibility | +|---|---| +| `loki-server` | Axum binary; wires modules; config; graceful shutdown. | +| `loki-server-api` | REST surface: workspaces, documents, members, blobs. | +| `loki-server-collab` | WebSocket relay, Loro update ingest, presence/awareness. | +| `loki-server-auth` | OIDC verification, session/token issuance, RBAC checks. | +| `loki-server-store` | Persistence port: Postgres (SQLx) + `object_store` adapter. | +| `loki-crypto` | Envelope encryption, key wrapping, crypto-agility (Tier 1/2). | +| `loki-server-audit` | Append-only, hash-chained audit log. | +| `loki-model` (exists) | Shared `DocumentId`, `WorkspaceId`, ACL types (no server deps). | + +The transport port in `loki-server-store` and the fan-out bus in `loki-server-collab` are +traits, so single-node (Postgres LISTEN/NOTIFY) and clustered (Redis pub/sub) deployments +share code — a self-host requirement, not an afterthought. + +--- + +## 2. ADRs + +### ADR-C012 — Single modular Axum binary with a pluggable fan-out bus + +**Context.** We need horizontal scalability for HA behind a load balancer, but self-hosters +must be able to run everything on one small box. Splitting sync and API into separate +services now would double the operational surface for the 90% single-node case. + +**Decision.** Ship one `loki-server` binary containing API, collaboration, and auth as +route modules. Cross-instance awareness (so two collaborators pinned to different instances +see each other) goes through a `FanOutBus` trait with two implementations: +`PgNotifyBus` (default, zero extra infra) and `RedisBus` (for larger clusters). Sticky +sessions at the LB keep a document's WebSocket connections on one instance where possible; +the bus handles the residual cross-instance case. + +**Consequences.** One artifact, one config surface. Self-host stays trivial. If a document +becomes a hotspot, we can later shard by `DocumentId` without changing the public API. + +--- + +### ADR-C013 — Loro wire protocol over WebSocket; snapshot + oplog persistence + +**Context.** Loki already round-trips full `ParaProps`/`CharProps`/`PageLayout`/`HeaderFooter` +through Loro (per `loro_bridge.rs`). The Cloud spec already committed to Loro's official wire +protocol. The server is a relay, not an authority on document semantics. + +**Decision.** Clients exchange Loro update messages over a per-document WebSocket channel. +The server: + +- **Relays** updates to all connected members and appends them to a per-document **oplog**. +- **Compacts** periodically into a **snapshot** (Loro `export(Snapshot)`), stored in object + storage; the oplog is truncated to updates newer than the snapshot. +- **Never merges or interprets** CRDT semantics beyond opaque append + broadcast. This is + what makes Tier 2 (§ADR-C014) possible: under E2EE the server compacts nothing and merely + stores encrypted update blobs plus client-produced encrypted snapshots. + +**Persistence layout.** + +| Store | Data | +|---|---| +| Postgres `doc_oplog` | `(doc_id, seq, actor, payload, created_at)` — recent updates, hot. | +| Object storage | Snapshots (`{doc_id}/snap/{version}`), attachments, images. | +| Postgres `doc_meta` | Title, ACL, current snapshot pointer, encryption tier, residency. | + +Object storage is HDD/Ceph on Hetzner and therefore correct for warm snapshots and blobs +but **not** for the hot oplog — the oplog stays in Postgres on NVMe/volume. + +**Consequences.** Recovery = latest snapshot + replay of oplog tail. Presence is ephemeral +and never persisted. Awareness (cursors/selections) is broadcast-only. + +--- + +### ADR-C014 — Tiered confidentiality model (Tier 0 / Tier 1 CMK / Tier 2 zero-knowledge) + +**Context.** "Optional E2EE" and "enterprise-ready server-side printing/conversion" pull in +opposite directions. A single boolean E2EE flag would either cripple server features or fail +the sovereignty promise. The right primitive is a **tier**, chosen per workspace (default) or +per document (override). + +**Decision.** Three tiers, envelope-encrypted throughout via per-document **DEK**s wrapped by +a tier-specific **KEK**: + +| Tier | Name | Server sees plaintext? | Server-side render/print/convert/search? | Key custody | +|---|---|---|---|---| +| **0** | Transport + at-rest | Yes | Yes | Server / platform KMS | +| **1** | Customer-managed keys (CMK) | Yes, within trust boundary | Yes | Customer KMS/HSM (Vault, PKCS#11) | +| **2** | Zero-knowledge E2EE | No (ciphertext only) | No | Client only | + +- **Tier 0.** TLS 1.3 in transit. At rest: encrypted Postgres volume (LUKS/pgcrypto) and + **SSE-C or app-layer AEAD** for object storage — mandatory because Hetzner Object Storage + has **no default at-rest encryption**. Baseline; all features available. +- **Tier 1 (CMK).** Per-document DEK wrapped by a customer-controlled KEK in their KMS/HSM. + The server decrypts *inside the customer's trust boundary* to run server-side features. Keys + never leave the customer's jurisdiction. This is the sovereignty sweet spot: confidentiality + **and** server-side office printing, because in a self-hosted enterprise the server *is* the + trust boundary. +- **Tier 2 (Zero-knowledge).** DEK is generated and held client-side; the server stores only + ciphertext. Loro updates are AEAD-encrypted (XChaCha20-Poly1305) client-side before send; + snapshots are encrypted client-side. Sharing = re-wrapping the DEK to each new member's + public key (X25519). ACL/membership metadata remains server-visible; content does not. + +**Crypto-agility.** All wrapping is expressed through a `KeyWrap` trait so hybrid PQC +(X25519 + ML-KEM) can be adopted without a data migration — C5:2026 calls out post-quantum +readiness explicitly. + +**Consequences.** One code path, three policies. Enterprises needing both confidentiality and +server-side processing choose Tier 1. Tier 2 is reserved for maximal-confidentiality documents +that accept the loss of server-side processing. + +--- + +### ADR-C015 — E2EE (Tier 2) and server-side document processing are mutually exclusive + +**Context.** Under Tier 2 the server holds only ciphertext, so it cannot render, print, +convert, thumbnail, or index a document. This is a law of the design, not a limitation to be +engineered away. + +**Decision.** Make the exclusivity explicit and enforced, not implicit: + +- Encryption tier is a first-class field on `doc_meta` and on the workspace default. +- Server-side capabilities (headless print, convert, export, server search, thumbnails) are + **feature-gated on `tier != 2`**; the API returns a typed `E2eeCapabilityDisabled` error + rather than silently producing garbage. +- Under Tier 2, those operations are the **client's** responsibility (the client can drive a + local headless instance — see headless spec ADR-C026). +- The UI must surface the trade-off at the moment a workspace/document is set to Tier 2. + +**Consequences.** No surprising failures; the exclusivity is visible in the type system and the +product. A workspace can mix tiers per document if it needs both postures. + +--- + +### ADR-C016 — PostgreSQL system-of-record; object storage via `object_store` + +**Context.** The Cloud spec already chose `object_store` for S3 abstraction. We must run +equally on Hetzner Object Storage (managed, EU-only) and MinIO (self-host), with no code fork. + +**Decision.** + +- **PostgreSQL** holds all relational, transactional, and hot data: workspaces, users, ACLs, + `doc_meta`, `doc_oplog`, audit log, and the apalis job queue (Postgres backend, so no Redis + is *required* for jobs — Redis is optional, for the fan-out bus only at scale). +- **Object storage** (via `object_store`) holds snapshots, images, and attachments. Adapter is + chosen by config URL (`s3://…` for Hetzner/MinIO). SSE-C headers or app-layer AEAD supply the + at-rest encryption Hetzner does not provide by default. +- Schema migrations via `sqlx migrate`, forward-only, zero-downtime (expand/contract pattern). + +**Consequences.** A minimal self-host is Postgres + MinIO + `loki-server`. Managed Hetzner +deployments swap MinIO for Hetzner Object Storage by changing one URL and adding SSE-C keys. + +--- + +### ADR-C017 — OIDC identity delegation; self-hostable IdP; RBAC + +**Context.** Sovereignty forbids hard dependence on a US identity provider. Enterprises expect +SSO. The Cloud spec already chose OIDC delegation. + +**Decision.** + +- The server is an **OIDC relying party**; it never stores passwords. Any compliant IdP works; + the documented sovereign defaults are **Keycloak / Authentik / Zitadel** (all self-hostable, + EU-runnable). Azure AD / Google are supported but flagged as non-sovereign. +- Optional **SCIM 2.0** provisioning endpoint for enterprise user lifecycle. +- **RBAC** at workspace and document scope: + + | Role | Rights | + |---|---| + | Owner | Full control, membership, deletion, tier changes. | + | Editor | Read/write content and metadata. | + | Commenter | Read + comment; no content edit. | + | Viewer | Read only. | + +- Under Tier 2, granting a role additionally requires re-wrapping the document DEK to the new + member's public key (an out-of-band, client-driven step surfaced by the API). + +**Consequences.** No proprietary auth. Enterprises keep identity in-house. Access decisions are +always server-authoritative (metadata), independent of tier. + +--- + +### ADR-C018 — Deployment: Hetzner (OpenTofu) and self-host (Compose / Helm), EU region pinning + +**Context.** Users deploy to Hetzner Cloud *or* their own infrastructure. The two must not +diverge functionally. + +**Decision.** Three first-class deployment artifacts, all in-repo: + +1. **OpenTofu/Terraform module** targeting the `hcloud` provider: CCX (dedicated-vCPU) servers + for `loki-server` and render workers, Private Networks, a Hetzner Load Balancer, an encrypted + Volume for Postgres data, and Hetzner Object Storage buckets. **Region is pinned to `fsn1`, + `nbg1`, or `hel1` (DE/FI) only** — never a US location — with the choice recorded as the + document residency value. +2. **Docker Compose** (single node): Postgres + MinIO + `loki-server` + one headless worker, + for evaluation and small self-host. +3. **Helm chart** (k3s/k8s): HA `loki-server` replicas, external or in-cluster Postgres, + MinIO or external S3, horizontal worker pool. + +Config is env + optional file; secrets via env / Docker secrets / Vault. **No phone-home; +telemetry opt-in and off by default.** + +**Consequences.** "Deploy to the cloud or your own infrastructure" is literally one of three +supported paths, not a porting exercise. + +--- + +### ADR-C019 — Digital-sovereignty posture + +**Context.** Target market is European Digital Sovereignty: EU public sector and regulated +industry. Certifications don't change jurisdiction — a US-owned provider remains exposed to the +US CLOUD Act regardless of badges — so the architecture itself must carry the sovereignty +guarantee. + +**Decision.** Sovereignty is a design property, mapped to the EU Cloud Sovereignty Framework's +SOV objectives: + +- **Data residency (SOV-2).** All data in EU DCs; residency pinned and recorded per document; + object storage region enforced at config validation time. +- **No non-EU jurisdiction dependency (SOV-1).** Hetzner is a German GmbH operating EU-only + DCs; self-hostable IdP; no mandatory US SaaS in the data path. +- **Open standards / no lock-in / portability (SOV-6).** `object_store` abstraction, standard + S3, standard OIDC, open-source stack, documented backup/restore → workload is portable off + Hetzner or off the cloud entirely. +- **Operational sovereignty (SOV-4).** Full self-host path means the customer's own staff can + operate, patch, and recover with no vendor in the loop. +- **Crypto (C5:2026).** Crypto-agile key wrapping ready for hybrid PQC; confidential-computing + hardening for Tier-1 (AMD SEV-SNP / Intel TDX) is **adopted** — see headless ADR-C028. + +Design to **C5:2026 / EUCS-Substantial-level** controls. EUCS itself remains voluntary and its +"High/sovereignty" tier is still unresolved at EU level, so we target the stable C5:2026 baseline +and track the forthcoming BSI cloud-sovereignty catalogue. + +**Consequences.** The sovereignty story survives audit because it rests on ownership, +residency, and portability — not on marketing. + +--- + +### ADR-C020 — Audit, retention, and GDPR data-subject operations + +**Decision.** + +- **Append-only, hash-chained audit log** (`loki-server-audit`): auth events, ACL changes, tier + changes, exports, deletions. Each entry carries the prior entry's hash → tamper-evident. +- **Retention & legal hold** via object-storage **Object Lock** (retention + legal hold), + available on Hetzner and MinIO. +- **GDPR operations** as first-class endpoints: data export (portability), right-to-erasure + (crypto-shredding the document DEK renders Tier 1/2 ciphertext unrecoverable — clean erasure), + and records of processing. DPA-ready. + +**Consequences.** Erasure is provable under Tier 1/2 by destroying keys; audit is defensible. + +--- + +## 3. API surface (sketch) + +REST, JSON, versioned under `/v1`. WebSocket for collaboration. + +``` +POST /v1/workspaces +GET /v1/workspaces/{ws}/documents +POST /v1/workspaces/{ws}/documents # sets tier (default = workspace tier) +GET /v1/documents/{doc} # metadata; content via WS/snapshot +GET /v1/documents/{doc}/snapshot # latest snapshot (cipher or plain by tier) +POST /v1/documents/{doc}/members # role grant (+ DEK re-wrap under Tier 2) +POST /v1/documents/{doc}/blobs # attachment/image upload +WS /v1/documents/{doc}/collab # Loro updates + awareness +POST /v1/documents/{doc}/export # 409 E2eeCapabilityDisabled if tier==2 +GET /v1/gdpr/export POST /v1/gdpr/erase +``` + +Errors are typed (`thiserror`) and mapped to stable problem+json codes; `E2eeCapabilityDisabled` +is the canonical Tier-2/server-side rejection. + +--- + +## 4. Data model (essentials) + +```sql +workspace(id, name, default_tier, residency, created_at) +app_user(id, oidc_sub, display_name, public_key /* X25519, for Tier 2 */) +doc_meta(id, workspace_id, title, tier, residency, snapshot_ptr, dek_wrapped, created_at) +doc_member(doc_id, user_id, role, dek_wrapped_for_user /* Tier 2 only */) +doc_oplog(doc_id, seq, actor, payload, created_at) -- payload is ciphertext under Tier 2 +audit_log(id, prev_hash, hash, actor, action, target, created_at) +-- apalis job tables (Postgres backend) +``` + +--- + +## 5. HA, backup, observability + +- **HA:** ≥2 `loki-server` replicas behind the LB; Postgres primary + streaming replica; + fan-out bus makes collaboration instance-independent. +- **Backup:** Postgres PITR (WAL archived to object storage); object storage versioning + + Object Lock; documented restore runbook. +- **Observability:** OpenTelemetry traces, Prometheus metrics, structured JSON logs. No content + or plaintext ever logged; under Tier 2 nothing to leak by construction. +- **Security headers, rate limiting, CSP** at the proxy and app layers. + +--- + +## 6. Ratified decisions + +1. **Default tier — RATIFIED.** Tier 0 default for SaaS; Tier 1 default for enterprise + self-host; Tier 2 opt-in per document. +2. **Fan-out bus — RATIFIED.** `PgNotifyBus` is the default. Switch to `RedisBus` when any of: + `loki-server` replicas > 3; sustained cross-instance update fan-out > ~1,000 msg/s; or + concurrent collaborative WebSocket connections > ~2,000. The switch point is a single config + tunable; these are shipped defaults, not hard limits. +3. **Presence under Tier 2 — RATIFIED.** Awareness payloads are AEAD-encrypted client-side — + cursor sharing is retained, contents opaque to the server. +4. **ADR numbering — RATIFIED.** The C-series continues at C012. diff --git a/loki-crypto/Cargo.toml b/loki-crypto/Cargo.toml new file mode 100644 index 00000000..46fa4406 --- /dev/null +++ b/loki-crypto/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "loki-crypto" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Envelope encryption and crypto-agile key wrapping for the Loki server (ADR-C014)" +repository = "https://github.com/appthere/loki" + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +chacha20poly1305 = { workspace = true } +x25519-dalek = { workspace = true } +hkdf = { workspace = true } +sha2 = { workspace = true } +rand_core = { workspace = true } +zeroize = { workspace = true } +base64 = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/loki-crypto/src/aead_wrap.rs b/loki-crypto/src/aead_wrap.rs new file mode 100644 index 00000000..10745176 --- /dev/null +++ b/loki-crypto/src/aead_wrap.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Symmetric-KEK key wrapping for Tiers 0 and 1 (ADR-C014). + +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::dek::{Dek, DEK_LEN}; +use crate::error::CryptoError; +use crate::wrap::{KeyWrap, WrappedDek}; + +/// Stable algorithm tag for this scheme. +pub const AEAD_WRAP_ALGORITHM: &str = "xchacha20-poly1305-kek.v1"; + +/// A symmetric key-encryption key. +/// +/// Under Tier 0 the KEK lives in the platform KMS; under Tier 1 it is +/// customer-managed (Vault, PKCS#11). In both cases the raw KEK bytes enter +/// this process only inside the relevant trust boundary. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Kek([u8; DEK_LEN]); + +impl Kek { + /// Reconstructs a KEK from raw bytes supplied by the KMS integration. + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; DEK_LEN] = bytes.try_into().map_err(|_| CryptoError::InvalidKeyLength { + expected: DEK_LEN, + actual: bytes.len(), + })?; + Ok(Self(arr)) + } + + /// Generates a fresh random KEK (used by tests and local-dev setups). + #[must_use] + pub fn generate() -> Self { + Self(*Dek::generate().as_bytes()) + } +} + +impl std::fmt::Debug for Kek { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Kek(..)") + } +} + +/// Wraps DEKs under a symmetric KEK with XChaCha20-Poly1305. +#[derive(Debug)] +pub struct AeadKeyWrap { + kek: Kek, +} + +impl AeadKeyWrap { + /// Creates a wrapper around the given KEK. + #[must_use] + pub fn new(kek: Kek) -> Self { + Self { kek } + } +} + +impl KeyWrap for AeadKeyWrap { + fn algorithm(&self) -> &'static str { + AEAD_WRAP_ALGORITHM + } + + fn wrap(&self, dek: &Dek) -> Result { + // The KEK is structurally a 256-bit XChaCha20 key, so reuse Dek's + // sealed-blob layout (nonce || ciphertext+tag) with the algorithm + // tag as AAD. + let kek_cipher = Dek::from_bytes(&self.kek.0)?; + let blob = kek_cipher.seal(dek.as_bytes(), AEAD_WRAP_ALGORITHM.as_bytes())?; + Ok(WrappedDek { + algorithm: AEAD_WRAP_ALGORITHM.to_owned(), + blob, + }) + } + + fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result { + if wrapped.algorithm != AEAD_WRAP_ALGORITHM { + return Err(CryptoError::UnsupportedAlgorithm(wrapped.algorithm.clone())); + } + let kek_cipher = Dek::from_bytes(&self.kek.0)?; + let raw = kek_cipher.open(&wrapped.blob, AEAD_WRAP_ALGORITHM.as_bytes())?; + Dek::from_bytes(&raw) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_unwrap_round_trip() { + let wrapper = AeadKeyWrap::new(Kek::generate()); + let dek = Dek::generate(); + let wrapped = wrapper.wrap(&dek).unwrap(); + assert_eq!(wrapped.algorithm, AEAD_WRAP_ALGORITHM); + let unwrapped = wrapper.unwrap_dek(&wrapped).unwrap(); + assert_eq!(unwrapped.as_bytes(), dek.as_bytes()); + } + + #[test] + fn wrong_kek_fails() { + let wrapper = AeadKeyWrap::new(Kek::generate()); + let other = AeadKeyWrap::new(Kek::generate()); + let wrapped = wrapper.wrap(&Dek::generate()).unwrap(); + assert!(other.unwrap_dek(&wrapped).is_err()); + } + + #[test] + fn algorithm_mismatch_is_typed() { + let wrapper = AeadKeyWrap::new(Kek::generate()); + let mut wrapped = wrapper.wrap(&Dek::generate()).unwrap(); + wrapped.algorithm = "x25519-xchacha20.v1".to_owned(); + assert!(matches!( + wrapper.unwrap_dek(&wrapped), + Err(CryptoError::UnsupportedAlgorithm(_)) + )); + } +} diff --git a/loki-crypto/src/dek.rs b/loki-crypto/src/dek.rs new file mode 100644 index 00000000..12b686ef --- /dev/null +++ b/loki-crypto/src/dek.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Per-document data-encryption keys and AEAD sealing. + +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use rand_core::OsRng; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::error::CryptoError; + +/// Byte length of a DEK (XChaCha20-Poly1305 key). +pub const DEK_LEN: usize = 32; + +/// XChaCha20-Poly1305 nonce length; the nonce is prepended to ciphertext. +const NONCE_LEN: usize = 24; + +/// A per-document data-encryption key. +/// +/// The key material is zeroized on drop. Destroying every wrapped copy of a +/// DEK crypto-shreds the document (GDPR erasure, ADR-C020). +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Dek([u8; DEK_LEN]); + +impl Dek { + /// Generates a fresh random DEK. + #[must_use] + pub fn generate() -> Self { + let key = XChaCha20Poly1305::generate_key(&mut OsRng); + Self(key.into()) + } + + /// Reconstructs a DEK from raw bytes (e.g. after unwrapping). + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; DEK_LEN] = bytes.try_into().map_err(|_| CryptoError::InvalidKeyLength { + expected: DEK_LEN, + actual: bytes.len(), + })?; + Ok(Self(arr)) + } + + /// Exposes the raw key bytes (needed to wrap the DEK; handle with care). + #[must_use] + pub fn as_bytes(&self) -> &[u8; DEK_LEN] { + &self.0 + } + + /// Seals `plaintext` with this DEK, binding `aad` (e.g. the document id). + /// + /// Output layout: `nonce (24 bytes) || ciphertext+tag`. A fresh random + /// nonce is used per call; XChaCha's 192-bit nonce makes random nonces + /// collision-safe at any realistic volume. + pub fn seal(&self, plaintext: &[u8], aad: &[u8]) -> Result, CryptoError> { + let cipher = XChaCha20Poly1305::new(self.0.as_slice().into()); + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| CryptoError::EncryptFailed)?; + let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ciphertext); + Ok(out) + } + + /// Opens a blob produced by [`Dek::seal`] with the same `aad`. + pub fn open(&self, sealed: &[u8], aad: &[u8]) -> Result, CryptoError> { + if sealed.len() < NONCE_LEN { + return Err(CryptoError::CiphertextTooShort(sealed.len())); + } + let (nonce, ciphertext) = sealed.split_at(NONCE_LEN); + let cipher = XChaCha20Poly1305::new(self.0.as_slice().into()); + cipher + .decrypt( + XNonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| CryptoError::DecryptFailed) + } +} + +impl std::fmt::Debug for Dek { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print key material. + f.write_str("Dek(..)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seal_open_round_trip() { + let dek = Dek::generate(); + let sealed = dek.seal(b"hello loki", b"doc-1").unwrap(); + assert_eq!(dek.open(&sealed, b"doc-1").unwrap(), b"hello loki"); + } + + #[test] + fn wrong_key_or_aad_fails() { + let dek = Dek::generate(); + let other = Dek::generate(); + let sealed = dek.seal(b"secret", b"doc-1").unwrap(); + assert!(matches!( + other.open(&sealed, b"doc-1"), + Err(CryptoError::DecryptFailed) + )); + assert!(matches!( + dek.open(&sealed, b"doc-2"), + Err(CryptoError::DecryptFailed) + )); + } + + #[test] + fn tampered_ciphertext_fails() { + let dek = Dek::generate(); + let mut sealed = dek.seal(b"secret", b"").unwrap(); + let last = sealed.len() - 1; + sealed[last] ^= 0x01; + assert!(dek.open(&sealed, b"").is_err()); + assert!(matches!( + dek.open(&sealed[..10], b""), + Err(CryptoError::CiphertextTooShort(10)) + )); + } + + #[test] + fn debug_never_leaks_key_material() { + let dek = Dek::generate(); + assert_eq!(format!("{dek:?}"), "Dek(..)"); + } +} diff --git a/loki-crypto/src/error.rs b/loki-crypto/src/error.rs new file mode 100644 index 00000000..a7ddd417 --- /dev/null +++ b/loki-crypto/src/error.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Typed errors for envelope encryption and key wrapping. + +/// Errors produced by sealing, opening, wrapping, or unwrapping keys. +/// +/// AEAD failures are deliberately opaque (no distinction between a bad key, +/// a truncated ciphertext, and a forged tag) to avoid oracle behaviour. +#[derive(Debug, thiserror::Error)] +pub enum CryptoError { + /// Authenticated decryption failed (wrong key, tampered or truncated data). + #[error("authenticated decryption failed")] + DecryptFailed, + /// Authenticated encryption failed (should not happen with valid inputs). + #[error("authenticated encryption failed")] + EncryptFailed, + /// The ciphertext blob is too short to contain a nonce. + #[error("ciphertext too short ({0} bytes)")] + CiphertextTooShort(usize), + /// A wrapped DEK declared an algorithm this build does not implement. + #[error("unsupported key-wrap algorithm {0:?}")] + UnsupportedAlgorithm(String), + /// A key blob had the wrong length. + #[error("invalid key length {actual} (expected {expected})")] + InvalidKeyLength { + /// Expected byte length. + expected: usize, + /// Actual byte length received. + actual: usize, + }, +} diff --git a/loki-crypto/src/lib.rs b/loki-crypto/src/lib.rs new file mode 100644 index 00000000..432a1e9f --- /dev/null +++ b/loki-crypto/src/lib.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Envelope encryption and crypto-agile key wrapping (ADR-C014). +//! +//! Every document has a per-document **DEK** (data-encryption key). Content +//! is sealed with the DEK via XChaCha20-Poly1305. The DEK itself is wrapped +//! by a tier-specific **KEK** through the [`KeyWrap`] trait: +//! +//! - Tier 0/1: a symmetric KEK ([`AeadKeyWrap`]) held by the platform KMS +//! (Tier 0) or the customer's KMS/HSM (Tier 1). +//! - Tier 2: the recipient's X25519 public key ([`X25519KeyWrap`]) — the +//! server only ever sees the wrapped form. +//! +//! [`WrappedDek`] records the wrapping algorithm alongside the blob so hybrid +//! PQC (X25519 + ML-KEM) can be introduced later without a data migration +//! (crypto-agility, C5:2026). + +#![forbid(unsafe_code)] + +mod aead_wrap; +mod dek; +mod error; +mod wrap; +mod x25519_wrap; + +pub use aead_wrap::{AeadKeyWrap, Kek}; +pub use dek::{Dek, DEK_LEN}; +pub use error::CryptoError; +pub use wrap::{KeyWrap, WrappedDek}; +pub use x25519_wrap::{X25519KeyWrap, X25519PublicKey, X25519SecretKey}; diff --git a/loki-crypto/src/wrap.rs b/loki-crypto/src/wrap.rs new file mode 100644 index 00000000..08129237 --- /dev/null +++ b/loki-crypto/src/wrap.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The crypto-agile key-wrapping port (ADR-C014). + +use serde::{Deserialize, Serialize}; + +use crate::dek::Dek; +use crate::error::CryptoError; + +/// A wrapped (encrypted) DEK, tagged with the algorithm that wrapped it. +/// +/// The `algorithm` tag is stored alongside the blob (in `doc_meta.dek_wrapped` +/// / `doc_member.dek_wrapped_for_user`) so a future hybrid-PQC wrap +/// (X25519 + ML-KEM) can coexist with current data — unwrapping dispatches on +/// the tag instead of assuming one scheme. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WrappedDek { + /// Identifier of the wrapping scheme, e.g. `"xchacha20-poly1305-kek.v1"`. + pub algorithm: String, + /// Opaque wrapped key material. + #[serde(with = "serde_bytes_base64")] + pub blob: Vec, +} + +/// A scheme that can wrap and unwrap document DEKs. +/// +/// Implementations: [`crate::AeadKeyWrap`] (symmetric KEK, Tiers 0/1) and +/// [`crate::X25519KeyWrap`] (per-member public key, Tier 2). A hybrid PQC +/// implementation slots in without touching stored data. +pub trait KeyWrap { + /// Stable identifier written into [`WrappedDek::algorithm`]. + fn algorithm(&self) -> &'static str; + + /// Wraps `dek` for the key custodian this instance represents. + fn wrap(&self, dek: &Dek) -> Result; + + /// Unwraps a DEK previously produced by [`KeyWrap::wrap`]. + /// + /// Returns [`CryptoError::UnsupportedAlgorithm`] if `wrapped.algorithm` + /// does not match this scheme. + fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result; +} + +/// Serializes the wrapped blob as base64 for JSON transport. +mod serde_bytes_base64 { + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let s = String::deserialize(deserializer)?; + STANDARD.decode(&s).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrapped_dek_json_round_trip() { + let wrapped = WrappedDek { + algorithm: "xchacha20-poly1305-kek.v1".to_owned(), + blob: (0..64u8).collect(), + }; + let json = serde_json::to_string(&wrapped).unwrap(); + let back: WrappedDek = serde_json::from_str(&json).unwrap(); + assert_eq!(back, wrapped); + } +} diff --git a/loki-crypto/src/x25519_wrap.rs b/loki-crypto/src/x25519_wrap.rs new file mode 100644 index 00000000..fba6e949 --- /dev/null +++ b/loki-crypto/src/x25519_wrap.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Public-key DEK wrapping for Tier 2 (zero-knowledge E2EE, ADR-C014). +//! +//! Sharing a Tier-2 document means re-wrapping its DEK to each member's +//! X25519 public key (ECIES-style: ephemeral X25519 → HKDF-SHA256 → +//! XChaCha20-Poly1305). The server stores only the wrapped form; unwrapping +//! happens on clients holding the member's secret key. + +use hkdf::Hkdf; +use rand_core::OsRng; +use sha2::Sha256; +use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; + +use crate::dek::{Dek, DEK_LEN}; +use crate::error::CryptoError; +use crate::wrap::{KeyWrap, WrappedDek}; + +/// Stable algorithm tag for this scheme. +pub const X25519_WRAP_ALGORITHM: &str = "x25519-hkdf-sha256-xchacha20.v1"; + +/// Byte length of an X25519 public key. +const PK_LEN: usize = 32; + +/// A member's X25519 public key (stored in `app_user.public_key`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct X25519PublicKey(PublicKey); + +impl X25519PublicKey { + /// Reconstructs a public key from its 32-byte form. + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; PK_LEN] = bytes.try_into().map_err(|_| CryptoError::InvalidKeyLength { + expected: PK_LEN, + actual: bytes.len(), + })?; + Ok(Self(PublicKey::from(arr))) + } + + /// Returns the 32-byte wire form. + #[must_use] + pub fn as_bytes(&self) -> &[u8; PK_LEN] { + self.0.as_bytes() + } +} + +/// A member's X25519 secret key (client-side only; never sent to the server). +pub struct X25519SecretKey(StaticSecret); + +impl X25519SecretKey { + /// Generates a fresh keypair secret. + #[must_use] + pub fn generate() -> Self { + Self(StaticSecret::random_from_rng(OsRng)) + } + + /// Reconstructs a secret key from its 32-byte form. + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; PK_LEN] = bytes.try_into().map_err(|_| CryptoError::InvalidKeyLength { + expected: PK_LEN, + actual: bytes.len(), + })?; + Ok(Self(StaticSecret::from(arr))) + } + + /// Derives the matching public key. + #[must_use] + pub fn public_key(&self) -> X25519PublicKey { + X25519PublicKey(PublicKey::from(&self.0)) + } +} + +impl std::fmt::Debug for X25519SecretKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("X25519SecretKey(..)") + } +} + +/// Wraps DEKs to an X25519 recipient; unwraps with the recipient's secret. +/// +/// Blob layout: `ephemeral_pk (32 bytes) || nonce || ciphertext+tag`. +#[derive(Debug)] +pub struct X25519KeyWrap { + recipient_pk: X25519PublicKey, + recipient_sk: Option, +} + +impl X25519KeyWrap { + /// Wrap-only instance (all the server / a sharing client needs). + #[must_use] + pub fn for_recipient(recipient_pk: X25519PublicKey) -> Self { + Self { + recipient_pk, + recipient_sk: None, + } + } + + /// Full instance for the key holder (a client unwrapping its own DEKs). + #[must_use] + pub fn for_key_holder(secret: X25519SecretKey) -> Self { + Self { + recipient_pk: secret.public_key(), + recipient_sk: Some(secret), + } + } + + fn derive_wrapping_key( + shared: &x25519_dalek::SharedSecret, + ephemeral_pk: &PublicKey, + recipient_pk: &X25519PublicKey, + ) -> Result { + // Salt binds both public keys; info binds the algorithm tag. + let mut salt = Vec::with_capacity(PK_LEN * 2); + salt.extend_from_slice(ephemeral_pk.as_bytes()); + salt.extend_from_slice(recipient_pk.as_bytes()); + let hkdf = Hkdf::::new(Some(&salt), shared.as_bytes()); + let mut okm = [0u8; DEK_LEN]; + hkdf.expand(X25519_WRAP_ALGORITHM.as_bytes(), &mut okm) + .map_err(|_| CryptoError::EncryptFailed)?; + Dek::from_bytes(&okm) + } +} + +impl KeyWrap for X25519KeyWrap { + fn algorithm(&self) -> &'static str { + X25519_WRAP_ALGORITHM + } + + fn wrap(&self, dek: &Dek) -> Result { + let ephemeral = EphemeralSecret::random_from_rng(OsRng); + let ephemeral_pk = PublicKey::from(&ephemeral); + let shared = ephemeral.diffie_hellman(&self.recipient_pk.0); + let wrapping_key = Self::derive_wrapping_key(&shared, &ephemeral_pk, &self.recipient_pk)?; + let sealed = wrapping_key.seal(dek.as_bytes(), X25519_WRAP_ALGORITHM.as_bytes())?; + let mut blob = Vec::with_capacity(PK_LEN + sealed.len()); + blob.extend_from_slice(ephemeral_pk.as_bytes()); + blob.extend_from_slice(&sealed); + Ok(WrappedDek { + algorithm: X25519_WRAP_ALGORITHM.to_owned(), + blob, + }) + } + + fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result { + if wrapped.algorithm != X25519_WRAP_ALGORITHM { + return Err(CryptoError::UnsupportedAlgorithm(wrapped.algorithm.clone())); + } + let Some(secret) = &self.recipient_sk else { + // A wrap-only instance (e.g. the server) cannot unwrap — that is + // the zero-knowledge property, expressed as a decrypt failure. + return Err(CryptoError::DecryptFailed); + }; + if wrapped.blob.len() < PK_LEN { + return Err(CryptoError::CiphertextTooShort(wrapped.blob.len())); + } + let (pk_bytes, sealed) = wrapped.blob.split_at(PK_LEN); + let ephemeral_pk = X25519PublicKey::from_bytes(pk_bytes)?; + let shared = secret.0.diffie_hellman(&ephemeral_pk.0); + let wrapping_key = + Self::derive_wrapping_key(&shared, &ephemeral_pk.0, &self.recipient_pk)?; + let raw = wrapping_key.open(sealed, X25519_WRAP_ALGORITHM.as_bytes())?; + Dek::from_bytes(&raw) + } +} + +#[cfg(test)] +#[path = "x25519_wrap_tests.rs"] +mod tests; diff --git a/loki-crypto/src/x25519_wrap_tests.rs b/loki-crypto/src/x25519_wrap_tests.rs new file mode 100644 index 00000000..43e54402 --- /dev/null +++ b/loki-crypto/src/x25519_wrap_tests.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for the Tier-2 X25519 DEK wrap. + +use super::*; + +#[test] +fn share_flow_round_trips() { + // A sharing client wraps the DEK to a member's public key… + let member_secret = X25519SecretKey::generate(); + let member_pk = member_secret.public_key(); + let dek = Dek::generate(); + let sharer = X25519KeyWrap::for_recipient(member_pk); + let wrapped = sharer.wrap(&dek).unwrap(); + assert_eq!(wrapped.algorithm, X25519_WRAP_ALGORITHM); + + // …and only the member's client can unwrap it. + let member = X25519KeyWrap::for_key_holder(member_secret); + let unwrapped = member.unwrap_dek(&wrapped).unwrap(); + assert_eq!(unwrapped.as_bytes(), dek.as_bytes()); +} + +#[test] +fn server_side_wrap_only_instance_cannot_unwrap() { + let member_secret = X25519SecretKey::generate(); + let sharer = X25519KeyWrap::for_recipient(member_secret.public_key()); + let wrapped = sharer.wrap(&Dek::generate()).unwrap(); + // The zero-knowledge property: without the secret key, unwrap fails. + assert!(matches!( + sharer.unwrap_dek(&wrapped), + Err(CryptoError::DecryptFailed) + )); +} + +#[test] +fn wrong_member_cannot_unwrap() { + let alice = X25519SecretKey::generate(); + let mallory = X25519SecretKey::generate(); + let wrapped = X25519KeyWrap::for_recipient(alice.public_key()) + .wrap(&Dek::generate()) + .unwrap(); + assert!( + X25519KeyWrap::for_key_holder(mallory) + .unwrap_dek(&wrapped) + .is_err() + ); +} + +#[test] +fn truncated_blob_is_typed_error() { + let member_secret = X25519SecretKey::generate(); + let member = X25519KeyWrap::for_key_holder(member_secret); + let wrapped = WrappedDek { + algorithm: X25519_WRAP_ALGORITHM.to_owned(), + blob: vec![0u8; 16], + }; + assert!(matches!( + member.unwrap_dek(&wrapped), + Err(CryptoError::CiphertextTooShort(16)) + )); +} + +#[test] +fn public_key_round_trips_through_bytes() { + let secret = X25519SecretKey::generate(); + let pk = secret.public_key(); + let restored = X25519PublicKey::from_bytes(pk.as_bytes()).unwrap(); + assert_eq!(restored, pk); + assert!(X25519PublicKey::from_bytes(&[0u8; 16]).is_err()); +} diff --git a/loki-model/Cargo.toml b/loki-model/Cargo.toml new file mode 100644 index 00000000..8ace8d9a --- /dev/null +++ b/loki-model/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "loki-model" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Shared server-side identifiers, encryption tiers, and ACL types for the Loki suite (no server dependencies)" +repository = "https://github.com/appthere/loki" + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/loki-model/src/ids.rs b/loki-model/src/ids.rs new file mode 100644 index 00000000..7600d201 --- /dev/null +++ b/loki-model/src/ids.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Identifier newtypes for server-side entities. +//! +//! Each identifier wraps a UUID so that a `DocumentId` can never be passed +//! where a `WorkspaceId` is expected. All types serialize as plain UUID +//! strings. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +macro_rules! id_newtype { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, + )] + #[serde(transparent)] + pub struct $name(Uuid); + + impl $name { + /// Creates a fresh random identifier. + #[must_use] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + /// Wraps an existing UUID (e.g. one loaded from the database). + #[must_use] + pub const fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + /// Returns the underlying UUID. + #[must_use] + pub const fn as_uuid(&self) -> Uuid { + self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } + + impl std::str::FromStr for $name { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(Uuid::parse_str(s)?)) + } + } + + impl From for $name { + fn from(uuid: Uuid) -> Self { + Self(uuid) + } + } + + impl From<$name> for Uuid { + fn from(id: $name) -> Self { + id.0 + } + } + }; +} + +id_newtype!( + /// Identifies a workspace (the unit of membership and default policy). + WorkspaceId +); +id_newtype!( + /// Identifies a single collaborative document. + DocumentId +); +id_newtype!( + /// Identifies a user account (linked to an OIDC subject, never a password). + UserId +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_round_trip_through_strings() { + let id = DocumentId::new(); + let parsed: DocumentId = id.to_string().parse().unwrap(); + assert_eq!(id, parsed); + } + + #[test] + fn ids_are_distinct_types() { + // Compile-time property: this test documents that the newtypes exist. + let doc = DocumentId::new(); + let ws = WorkspaceId::from_uuid(doc.as_uuid()); + assert_eq!(doc.as_uuid(), ws.as_uuid()); + } + + #[test] + fn serde_is_transparent() { + let id = UserId::new(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, format!("\"{id}\"")); + } +} diff --git a/loki-model/src/lib.rs b/loki-model/src/lib.rs new file mode 100644 index 00000000..ad468d00 --- /dev/null +++ b/loki-model/src/lib.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared server-side domain types for the Loki suite. +//! +//! This crate holds the identifier newtypes, the tiered-confidentiality model +//! (ADR-C014), the RBAC role/action matrix (ADR-C017), and the data-residency +//! type (ADR-C019) shared between `loki-server-*` crates and future clients. +//! It deliberately has **no** server dependencies (no Axum, no SQLx) so that +//! client crates can depend on it too. +//! +//! See `docs/adr/LOKI_WEB_SERVER_SPEC.md`. + +#![forbid(unsafe_code)] + +mod ids; +mod residency; +mod role; +mod tier; + +pub use ids::{DocumentId, UserId, WorkspaceId}; +pub use residency::{Residency, ResidencyError}; +pub use role::{Action, Role, RoleParseError}; +pub use tier::{EncryptionTier, TierParseError}; diff --git a/loki-model/src/residency.rs b/loki-model/src/residency.rs new file mode 100644 index 00000000..4b6f4f38 --- /dev/null +++ b/loki-model/src/residency.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Data-residency type (ADR-C018 / ADR-C019). +//! +//! Residency is recorded per workspace and per document. Managed (Hetzner) +//! deployments are pinned to the EU regions `fsn1`, `nbg1`, `hel1`; +//! self-hosted deployments declare a free-form `self-hosted: