diff --git a/Cargo.lock b/Cargo.lock index e0ec8c8..2593623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ dependencies = [ "lazy_static", "nom", "pin-project", - "rand", + "rand 0.8.5", "rust-embed", "scrypt", "sha2", @@ -65,7 +65,7 @@ dependencies = [ "hkdf", "io_tee", "nom", - "rand", + "rand 0.8.5", "secrecy", "sha2", ] @@ -143,6 +143,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -239,6 +245,22 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cc" +version = "1.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.3" @@ -355,6 +377,22 @@ dependencies = [ "futures", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -395,7 +433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -433,7 +471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ "cfg-if", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core 0.9.11", @@ -495,6 +533,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -510,6 +554,12 @@ dependencies = [ "toml", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fluent" version = "0.16.1" @@ -551,9 +601,15 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" dependencies = [ - "thiserror", + "thiserror 1.0.69", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -644,6 +700,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -689,7 +751,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.5+wasi-0.2.4", ] [[package]] @@ -698,12 +772,37 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + [[package]] name = "heck" version = "0.5.0" @@ -724,7 +823,8 @@ dependencies = [ "clap", "ed25519-dalek", "futures", - "rand", + "jsonrpsee", + "rand 0.8.5", "redb", "redis", "secrecy", @@ -732,8 +832,9 @@ dependencies = [ "serde_json", "sha2", "sled", - "thiserror", + "thiserror 1.0.69", "tokio", + "x25519-dalek", ] [[package]] @@ -754,6 +855,113 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +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 = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.0", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "i18n-config" version = "0.4.8" @@ -764,7 +972,7 @@ dependencies = [ "log", "serde", "serde_derive", - "thiserror", + "thiserror 1.0.69", "unic-langid", ] @@ -784,7 +992,7 @@ dependencies = [ "log", "parking_lot 0.12.4", "rust-embed", - "thiserror", + "thiserror 1.0.69", "unic-langid", "walkdir", ] @@ -930,6 +1138,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", +] + [[package]] name = "inout" version = "0.1.4" @@ -996,6 +1214,183 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jsonrpsee" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" +dependencies = [ + "jsonrpsee-core", + "jsonrpsee-http-client", + "jsonrpsee-proc-macros", + "jsonrpsee-server", + "jsonrpsee-types", + "jsonrpsee-ws-client", + "tokio", + "tracing", +] + +[[package]] +name = "jsonrpsee-client-transport" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf36eb27f8e13fa93dcb50ccb44c417e25b818cfa1a481b5470cd07b19c60b98" +dependencies = [ + "base64 0.22.1", + "futures-util", + "http", + "jsonrpsee-core", + "pin-project", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "soketto", + "thiserror 2.0.16", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "316c96719901f05d1137f19ba598b5fe9c9bc39f4335f67f6be8613921946480" +dependencies = [ + "async-trait", + "bytes", + "futures-timer", + "futures-util", + "http", + "http-body", + "http-body-util", + "jsonrpsee-types", + "parking_lot 0.12.4", + "pin-project", + "rand 0.9.2", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "jsonrpsee-http-client" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790bedefcec85321e007ff3af84b4e417540d5c87b3c9779b9e247d1bcc3dab8" +dependencies = [ + "base64 0.22.1", + "http-body", + "hyper", + "hyper-rustls", + "hyper-util", + "jsonrpsee-core", + "jsonrpsee-types", + "rustls", + "rustls-platform-verifier", + "serde", + "serde_json", + "thiserror 2.0.16", + "tokio", + "tower", + "url", +] + +[[package]] +name = "jsonrpsee-proc-macros" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "jsonrpsee-server" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c51b7c290bb68ce3af2d029648148403863b982f138484a73f02a9dd52dbd7f" +dependencies = [ + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "jsonrpsee-core", + "jsonrpsee-types", + "pin-project", + "route-recognizer", + "serde", + "serde_json", + "soketto", + "thiserror 2.0.16", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tracing", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc88ff4688e43cc3fa9883a8a95c6fa27aa2e76c96e610b737b6554d650d7fd5" +dependencies = [ + "http", + "serde", + "serde_json", + "thiserror 2.0.16", +] + +[[package]] +name = "jsonrpsee-ws-client" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6fceceeb05301cc4c065ab3bd2fa990d41ff4eb44e4ca1b30fa99c057c3e79" +dependencies = [ + "http", + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "tower", + "url", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1058,7 +1453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -1099,6 +1494,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "parking_lot" version = "0.11.2" @@ -1234,6 +1635,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -1276,6 +1686,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1283,8 +1699,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1294,7 +1720,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1303,7 +1739,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", ] [[package]] @@ -1354,6 +1799,26 @@ dependencies = [ "bitflags 2.9.3", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + [[package]] name = "rust-embed" version = "8.7.2" @@ -1415,6 +1880,80 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a37813727b78798e53c2bec3f5e8fe12a6d6f8389bf9ca7802add4c9905ad8" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "ryu" version = "1.0.20" @@ -1439,6 +1978,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1465,6 +2013,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" +dependencies = [ + "bitflags 2.9.3", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "0.10.3" @@ -1518,6 +2089,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -1535,6 +2117,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -1550,7 +2138,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1601,6 +2189,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", +] + [[package]] name = "spki" version = "0.7.3" @@ -1656,6 +2260,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1673,7 +2283,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", ] [[package]] @@ -1687,6 +2306,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1728,6 +2358,28 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -1736,6 +2388,7 @@ checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -1750,6 +2403,86 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "type-map" version = "0.5.1" @@ -1800,6 +2533,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.6" @@ -1839,12 +2578,57 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.5+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.0+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.2", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1882,6 +2666,30 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1900,6 +2708,30 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1922,7 +2754,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -1933,6 +2765,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1945,6 +2783,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1957,6 +2801,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1981,6 +2831,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1993,6 +2849,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2005,6 +2867,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2017,6 +2885,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2029,6 +2903,21 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" + [[package]] name = "writeable" version = "0.6.1" @@ -2042,7 +2931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 50d3208..f07b6fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,9 @@ sha2 = "0.10" age = "0.10" secrecy = "0.8" ed25519-dalek = "2" +x25519-dalek = "2" base64 = "0.22" +jsonrpsee = { version = "0.26.0", features = ["http-client", "ws-client", "server", "macros"] } [dev-dependencies] redis = { version = "0.24", features = ["aio", "tokio-comp"] } diff --git a/README.md b/README.md index e308eaf..82762bf 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ The main purpose of HeroDB is to offer a lightweight, embeddable, and Redis-comp - **Expiration**: Time-to-live (TTL) functionality for keys. - **Scanning**: Cursor-based iteration for keys and hash fields (`SCAN`, `HSCAN`). - **AGE Cryptography Commands**: HeroDB-specific extensions for cryptographic operations. +- **Symmetric Encryption**: Stateless symmetric encryption using XChaCha20-Poly1305. +- **Admin Database 0**: Centralized control for database management, access control, and per-database encryption. ## Quick Start @@ -30,31 +32,14 @@ cargo build --release ### Running HeroDB -You can start HeroDB with different backends and encryption options: - -#### Default `redb` Backend +Launch HeroDB with the required `--admin-secret` flag, which encrypts the admin database (DB 0) and authorizes admin access. Optional flags include `--dir` for the database directory, `--port` for the TCP port (default 6379), `--sled` for the sled backend, and `--enable-rpc` to start the JSON-RPC management server on port 8080. +Example: ```bash -./target/release/herodb --dir /tmp/herodb_redb --port 6379 +./target/release/herodb --dir /tmp/herodb --admin-secret myadminsecret --port 6379 --enable-rpc ``` -#### `sled` Backend - -```bash -./target/release/herodb --dir /tmp/herodb_sled --port 6379 --sled -``` - -#### `redb` with Encryption - -```bash -./target/release/herodb --dir /tmp/herodb_encrypted --port 6379 --encrypt --encryption_key mysecretkey -``` - -#### `sled` with Encryption - -```bash -./target/release/herodb --dir /tmp/herodb_sled_encrypted --port 6379 --sled --encrypt --encryption_key mysecretkey -``` +For detailed launch options, see [Basics](docs/basics.md). ## Usage with Redis Clients @@ -76,10 +61,24 @@ redis-cli -p 6379 SCAN 0 MATCH user:* COUNT 10 # 2) 1) "user:1" ``` +## Cryptography + +HeroDB supports asymmetric encryption/signatures via AGE commands (X25519 for encryption, Ed25519 for signatures) in stateless or key-managed modes, and symmetric encryption via SYM commands. Keys are persisted in the admin database (DB 0) for managed modes. + +For details, see [AGE Cryptography](docs/age.md) and [Basics](docs/basics.md). + +## Database Management + +Databases are managed via JSON-RPC API, with metadata stored in the encrypted admin database (DB 0). Databases are public by default upon creation; use RPC to set them private, requiring access keys for SELECT operations (read or readwrite based on permissions). This includes per-database encryption keys, access control, and lifecycle management. + +For examples, see [JSON-RPC Examples](docs/rpc_examples.md) and [Admin DB 0 Model](docs/admin.md). + ## Documentation For more detailed information on commands, features, and advanced usage, please refer to the documentation: - [Basics](docs/basics.md) - [Supported Commands](docs/cmds.md) -- [AGE Cryptography](docs/age.md) \ No newline at end of file +- [AGE Cryptography](docs/age.md) +- [Admin DB 0 Model (access control, per-db encryption)](docs/admin.md) +- [JSON-RPC Examples (management API)](docs/rpc_examples.md) \ No newline at end of file diff --git a/docs/admin.md b/docs/admin.md new file mode 100644 index 0000000..55b9ffb --- /dev/null +++ b/docs/admin.md @@ -0,0 +1,181 @@ +# Admin Database 0 (`0.db`) + +This page explains what the Admin Database `DB 0` is, why HeroDB uses it, and how to work with it as a developer and end-user. It’s a practical guide covering how databases are created, listed, secured with access keys, and encrypted using per-database secrets. + +## What is `DB 0`? + +`DB 0` is the control-plane for a HeroDB instance. It stores metadata for all user databases (`db_id >= 1`) so the server can: +- Know which databases exist (without scanning the filesystem) +- Enforce access control (public/private with access keys) +- Enforce per-database encryption (whether a given database must be opened encrypted and with which write-only key) + +`DB 0` itself is always encrypted with the admin secret (the process-level secret provided at startup). + +## How `DB 0` is created and secured + +- `DB 0` lives at `/0.db` +- It is always encrypted using the `admin secret` provided at process startup (using the `--admin-secret ` CLI flag) +- Only clients that provide the correct admin secret can `SELECT 0` (see “`SELECT` + `KEY`” below) + +At startup, the server bootstraps `DB 0` (initializes counters and structures) if it’s missing. + +## Metadata stored in `DB 0` + +Keys in `DB 0` (internal layout, but useful to understand how things work): + +- `admin:next_id` + - String counter holding the next id to allocate (initialized to `"1"`) + +- `admin:dbs` + - A hash acting as a set of existing database ids + - field = id (as string), value = `"1"` + +- `meta:db:` + - A hash holding db-level metadata + - field `public` = `"true"` or `"false"` (defaults to `true` if missing) + +- `meta:db::keys` + - A hash mapping access-key hashes to the string `Permission:created_at_seconds` + - Examples: `Read:1713456789` or `ReadWrite:1713456789` + - The plaintext access keys are never stored; only their `SHA-256` hashes are kept + +- `meta:db::enc` + - A string holding the per-database encryption key used to open `.db` encrypted + - This value is write-only from the perspective of the management APIs (it’s set at creation and never returned) + +- `age:key:` + - Base64-encoded X25519 recipient (public encryption key) for named AGE keys +- `age:privkey:` + - Base64-encoded X25519 identity (secret encryption key) for named AGE keys +- `age:signpub:` + - Base64-encoded Ed25519 verify public key for named AGE keys +- `age:signpriv:` + - Base64-encoded Ed25519 signing secret key for named AGE keys + +> You don’t need to manipulate these keys directly; they’re listed to clarify the model. AGE keys are managed via AGE commands. + +## Database lifecycle + +1) Create a database (via JSON-RPC) +- The server allocates an id from `admin:next_id`, registers it in `admin:dbs`, and defaults the database to `public=true` +- If you pass an optional `encryption_key` during creation, the server persists it in `meta:db::enc`. That database will be opened in encrypted mode from then on + +2) Open and use a database +- Clients select a database over RESP using `SELECT` +- Authorization and encryption state are enforced using `DB 0` metadata + +3) Delete database files +- Removing `.db` removes the physical storage +- `DB 0` remains the source of truth for existence and may be updated by future management methods as the system evolves + +## Access control model + +- Public database (default) + - Anyone can `SELECT ` with no key, and will get `ReadWrite` permission +- Private database + - You must provide an access key when selecting the database + - The server hashes the provided key with `SHA-256` and checks membership in `meta:db::keys` + - Permissions are `Read` or `ReadWrite` depending on how the key was added +- Admin `DB 0` + - Requires the exact admin secret as the `KEY` argument to `SELECT 0` + - Permission is `ReadWrite` when the secret matches + +### How to select databases with optional `KEY` + +- Public DB (no key required) + - `SELECT ` + +- Private DB (access key required) + - `SELECT KEY ` + +- Admin `DB 0` (admin secret required) + - `SELECT 0 KEY ` + +Examples (using `redis-cli`): +```bash +# Public database +redis-cli -p $PORT SELECT 1 +# → OK + +# Private database +redis-cli -p $PORT SELECT 2 KEY my-db2-access-key +# → OK + +# Admin DB 0 +redis-cli -p $PORT SELECT 0 KEY my-admin-secret +# → OK +``` + +## Per-database encryption + +- At database creation, you can provide an optional per-db encryption key +- If provided, the server persists that key in `DB 0` as `meta:db::enc` +- When you later open the database, the engine checks whether `meta:db::enc` exists to decide if it must open `.db` in encrypted mode +- The per-db key is not returned by RPC—it is considered write-only configuration data + +Operationally: +- Create with encryption: pass a non-null `encryption_key` to the `createDatabase` RPC +- Open later: simply `SELECT` the database; encryption is transparent to clients + +## Management via JSON-RPC + +You can manage databases using the management RPC (namespaced `herodb.*`). Typical operations: +- `createDatabase(backend, config, encryption_key?)` + - Allocates a new id, sets optional encryption key +- `listDatabases()` + - Lists database ids and info (including whether storage is currently encrypted) +- `getDatabaseInfo(db_id)` + - Returns details: backend, encrypted flag, size on disk, `key_count`, timestamps, etc. +- `addAccessKey(db_id, key, permissions)` + - Adds a `Read` or `ReadWrite` access key (permissions = `"read"` | `"readwrite"`) +- `listAccessKeys(db_id)` + - Returns hashes and permissions; you can use these hashes to delete keys +- `deleteAccessKey(db_id, key_hash)` + - Removes a key by its hash +- `setDatabasePublic(db_id, public)` + - Toggles public/private + +Copyable JSON examples are provided in the [RPC examples documentation](./rpc_examples.md). + +## Typical flows + +1) Public, unencrypted database +- Create a new database without an encryption key +- Clients can immediately `SELECT ` without a key +- You can later make it private and add keys if needed + +2) Private, encrypted database +- Create passing an `encryption_key` +- Mark it private (`setDatabasePublic false`) and add access keys +- Clients must use `SELECT KEY ` +- Storage opens in encrypted mode automatically + +## Security notes + +- Only `SHA-256` hashes of access keys are stored in `DB 0`; keep plaintext keys safe on the client side +- The per-db encryption key is never exposed via the API after it is set +- The admin secret must be kept secure; anyone with it can `SELECT 0` and perform administrative actions + +## Troubleshooting + +- `ERR invalid access key` when selecting a private db + - Ensure you passed the `KEY` argument: `SELECT KEY ` + - If you recently added the key, confirm the permissions and that you used the exact plaintext (hash must match) + +- `Database X not found` + - The id isn’t registered in `DB 0` (`admin:dbs`). Use the management APIs to create or list databases + +- Cannot `SELECT 0` + - The `KEY` must be the exact admin secret passed at server startup + +## Reference + +- Admin metadata lives in `DB 0` (`0.db`) and controls: + - Existence: `admin:dbs` + - Access: `meta:db:.public` and `meta:db::keys` + - Encryption: `meta:db::enc` + +For command examples and management payloads: +- RESP command basics: `docs/basics.md` +- Supported commands: `docs/cmds.md` +- JSON-RPC examples: `docs/rpc_examples.md` \ No newline at end of file diff --git a/docs/age.md b/docs/age.md index 9a440bb..3dc8358 100644 --- a/docs/age.md +++ b/docs/age.md @@ -1,188 +1,96 @@ -# HeroDB AGE usage: Stateless vs Key‑Managed +# HeroDB AGE Cryptography -This document explains how to use the AGE cryptography commands exposed by HeroDB over the Redis protocol in two modes: -- Stateless (ephemeral keys; nothing stored on the server) -- Key‑managed (server‑persisted, named keys) +HeroDB provides AGE-based asymmetric encryption and digital signatures over the Redis protocol using X25519 for encryption and Ed25519 for signatures. Keys can be used in stateless (ephemeral) or key-managed (persistent, named) modes. -If you are new to the codebase, the exact tests that exercise these behaviors are: -- [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495) -- [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555) +In key-managed mode, HeroDB uses a unified keypair concept: a single Ed25519 signing key is deterministically derived into X25519 keys for encryption, allowing one keypair to handle both encryption and signatures transparently. -Implementation entry points: -- [herodb/src/age.rs](herodb/src/age.rs) -- Dispatch from [herodb/src/cmd.rs](herodb/src/cmd.rs) +## Cryptographic Algorithms -Note: Database-at-rest encryption flags in the test harness are unrelated to AGE commands; those flags control storage-level encryption of DB files. See the harness near [rust.start_test_server()](herodb/tests/usage_suite.rs:10). +### X25519 (Encryption) +- Elliptic-curve Diffie-Hellman key exchange for symmetric key derivation. +- Used for encrypting/decrypting messages. -## Quick start +### Ed25519 (Signatures) +- EdDSA digital signatures for message authentication. +- Used for signing/verifying messages. -Assuming the server is running on localhost on some $PORT: +### Key Derivation +Ed25519 signing keys are deterministically converted to X25519 keys for encryption. This enables a single keypair to support both operations without additional keys. Derivation uses the Ed25519 secret scalar clamped for X25519. + +In named keypairs, Ed25519 keys are stored, and X25519 keys are derived on-demand and cached. + +## Stateless Mode (Ephemeral Keys) +No server-side storage; keys are provided with each command. + +Available commands: +- `AGE GENENC`: Generate ephemeral X25519 keypair. Returns `[recipient, identity]`. +- `AGE GENSIGN`: Generate ephemeral Ed25519 keypair. Returns `[verify_pub, sign_secret]`. +- `AGE ENCRYPT `: Encrypt message. Returns base64 ciphertext. +- `AGE DECRYPT `: Decrypt ciphertext. Returns plaintext. +- `AGE SIGN `: Sign message. Returns base64 signature. +- `AGE VERIFY `: Verify signature. Returns 1 (valid) or 0 (invalid). + +Example: ```bash -~/code/git.ourworld.tf/herocode/herodb/herodb/build.sh -~/code/git.ourworld.tf/herocode/herodb/target/release/herodb --dir /tmp/data --debug --$PORT 6381 --encryption-key 1234 --encrypt -``` +redis-cli AGE GENENC +# → 1) "age1qz..." # recipient (X25519 public) +# 2) "AGE-SECRET-KEY-1..." # identity (X25519 secret) +redis-cli AGE ENCRYPT "age1qz..." "hello" +# → base64_ciphertext -```bash -export PORT=6381 -# Generate an ephemeral keypair and encrypt/decrypt a message (stateless mode) -redis-cli -p $PORT AGE GENENC -# → returns an array: [recipient, identity] - -redis-cli -p $PORT AGE ENCRYPT "hello world" -# → returns ciphertext (base64 in a bulk string) - -redis-cli -p $PORT AGE DECRYPT -# → returns "hello world" -``` - -For key‑managed mode, generate a named key once and reference it by name afterwards: - -```bash -redis-cli -p $PORT AGE KEYGEN app1 -# → persists encryption keypair under name "app1" - -redis-cli -p $PORT AGE ENCRYPTNAME app1 "hello" -redis-cli -p $PORT AGE DECRYPTNAME app1 -``` - -## Stateless AGE (ephemeral) - -Characteristics - -- No server‑side storage of keys. -- You pass the actual key material with every call. -- Not listable via AGE LIST. - -Commands and examples - -1) Ephemeral encryption keys - -```bash -# Generate an ephemeral encryption keypair -redis-cli -p $PORT AGE GENENC -# Example output (abridged): -# 1) "age1qz..." # recipient (public key) = can be used by others e.g. to verify what I sign -# 2) "AGE-SECRET-KEY-1..." # identity (secret) = is like my private, cannot lose this one - -# Encrypt with the recipient public key -redis-cli -p $PORT AGE ENCRYPT "age1qz..." "hello world" - -# → returns bulk string payload: base64 ciphertext (encrypted content) - -# Decrypt with the identity (secret) in other words your private key -redis-cli -p $PORT AGE DECRYPT "AGE-SECRET-KEY-1..." "" -# → "hello world" -``` - -2) Ephemeral signing keys - -> ? is this same as my private key - -```bash - -# Generate an ephemeral signing keypair -redis-cli -p $PORT AGE GENSIGN -# Example output: -# 1) "" -# 2) "" - -# Sign a message with the secret -redis-cli -p $PORT AGE SIGN "" "msg" -# → returns "" - -# Verify with the public key -redis-cli -p $PORT AGE VERIFY "" "msg" "" -# → 1 (valid) or 0 (invalid) -``` - -When to use -- You do not want the server to store private keys. -- You already manage key material on the client side. -- You need ad‑hoc operations without persistence. - -Reference test: [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495) - -## Key‑managed AGE (persistent, named) - -Characteristics -- Server generates and persists keypairs under a chosen name. -- Clients refer to keys by name; raw secrets are not supplied on each call. -- Keys are discoverable via AGE LIST. - -Commands and examples - -1) Named encryption keys - -```bash -# Create/persist a named encryption keypair -redis-cli -p $PORT AGE KEYGEN app1 -# → returns [recipient, identity] but also stores them under name "app1" - -> TODO: should not return identity (security, but there can be separate function to export it e.g. AGE EXPORTKEY app1) - -# Encrypt using the stored public key -redis-cli -p $PORT AGE ENCRYPTNAME app1 "hello" -# → returns bulk string payload: base64 ciphertext - -# Decrypt using the stored secret -redis-cli -p $PORT AGE DECRYPTNAME app1 "" +redis-cli AGE DECRYPT "AGE-SECRET-KEY-1..." base64_ciphertext # → "hello" ``` -2) Named signing keys +## Key-Managed Mode (Persistent Named Keys) +Keys are stored server-side under names. Supports unified keypairs for both encryption and signatures. +Available commands: +- `AGE KEYGEN `: Generate and store unified keypair. Returns `[recipient, identity]` in age format. +- `AGE SIGNKEYGEN `: Generate and store Ed25519 signing keypair. Returns `[verify_pub, sign_secret]`. +- `AGE ENCRYPTNAME `: Encrypt with named key. Returns base64 ciphertext. +- `AGE DECRYPTNAME `: Decrypt with named key. Returns plaintext. +- `AGE SIGNNAME `: Sign with named key. Returns base64 signature. +- `AGE VERIFYNAME `: Verify with named key. Returns 1 or 0. +- `AGE LIST`: List all stored key names. Returns sorted array of names. + +### AGE LIST Output +Returns a flat, deduplicated, sorted array of key names (strings). Each name corresponds to a stored keypair, which may include encryption keys (X25519), signing keys (Ed25519), or both. + +Output format: `["name1", "name2", ...]` + +Example: ```bash -# Create/persist a named signing keypair -redis-cli -p $PORT AGE SIGNKEYGEN app1 -# → returns [verify_pub_b64, sign_secret_b64] and stores under name "app1" - -> TODO: should not return sign_secret_b64 (for security, but there can be separate function to export it e.g. AGE EXPORTSIGNKEY app1) - -# Sign using the stored secret -redis-cli -p $PORT AGE SIGNNAME app1 "msg" -# → returns "" - -# Verify using the stored public key -redis-cli -p $PORT AGE VERIFYNAME app1 "msg" "" -# → 1 (valid) or 0 (invalid) +redis-cli AGE LIST +# → 1) "" +# 2) "" ``` -3) List stored AGE keys +For unified keypairs (from `AGE KEYGEN`), the name handles both encryption (derived X25519) and signatures (stored Ed25519) transparently. +Example with named keys: ```bash -redis-cli -p $PORT AGE LIST -# Example output includes labels such as "encpub" and your key names (e.g., "app1") +redis-cli AGE KEYGEN app1 +# → 1) "age1..." # recipient +# 2) "AGE-SECRET-KEY-1..." # identity + +redis-cli AGE ENCRYPTNAME app1 "secret message" +# → base64_ciphertext + +redis-cli AGE DECRYPTNAME app1 base64_ciphertext +# → "secret message" + +redis-cli AGE SIGNNAME app1 "message" +# → base64_signature + +redis-cli AGE VERIFYNAME app1 "message" base64_signature +# → 1 ``` -When to use -- You want centralized key storage/rotation and fewer secrets on the client. -- You need names/labels for workflows and can trust the server with secrets. -- You want discoverability (AGE LIST) and simpler client commands. +## Choosing a Mode +- **Stateless**: For ad-hoc operations without persistence; client manages keys. +- **Key-managed**: For centralized key lifecycle; server stores keys for convenience and discoverability. -Reference test: [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555) - -## Choosing a mode - -- Prefer Stateless when: - - Minimizing server trust for secret material is the priority. - - Clients already have a secure mechanism to store/distribute keys. -- Prefer Key‑managed when: - - Centralized lifecycle, naming, and discoverability are beneficial. - - You plan to integrate rotation, ACLs, or auditability on the server side. - -## Security notes - -- Treat identities and signing secrets as sensitive; avoid logging them. -- For key‑managed mode, ensure server storage (and backups) are protected. -- AGE operations here are application‑level crypto and are distinct from database-at-rest encryption configured in the test harness. - -## Repository pointers - -- Stateless examples in tests: [rust.test_07_age_stateless_suite()](herodb/tests/usage_suite.rs:495) -- Key‑managed examples in tests: [rust.test_08_age_persistent_named_suite()](herodb/tests/usage_suite.rs:555) -- AGE implementation: [herodb/src/age.rs](herodb/src/age.rs) -- Command dispatch: [herodb/src/cmd.rs](herodb/src/cmd.rs) -- Bash demo: [herodb/examples/age_bash_demo.sh](herodb/examples/age_bash_demo.sh) -- Rust persistent demo: [herodb/examples/age_persist_demo.rs](herodb/examples/age_persist_demo.rs) -- Additional notes: [herodb/instructions/encrypt.md](herodb/instructions/encrypt.md) \ No newline at end of file +Implementation: [herodb/src/age.rs](herodb/src/age.rs)
+Tests: [herodb/tests/usage_suite.rs](herodb/tests/usage_suite.rs) \ No newline at end of file diff --git a/docs/basics.md b/docs/basics.md index a00a3a8..20d73bd 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -1,4 +1,58 @@ -Here's an expanded version of the cmds.md documentation to include the list commands: +# HeroDB Basics + +## Launching HeroDB + +To launch HeroDB, use the binary with required and optional flags. The `--admin-secret` flag is mandatory, encrypting the admin database (DB 0) and authorizing admin access. + +### Launch Flags +- `--dir `: Directory for database files (default: current directory). +- `--port `: TCP port for Redis protocol (default: 6379). +- `--debug`: Enable debug logging. +- `--sled`: Use Sled backend (default: Redb). +- `--enable-rpc`: Start JSON-RPC management server on port 8080. +- `--rpc-port `: Custom RPC port (default: 8080). +- `--admin-secret `: Required secret for DB 0 encryption and admin access. + +Example: +```bash +./target/release/herodb --dir /tmp/herodb --admin-secret mysecret --port 6379 --enable-rpc +``` + +Deprecated flags (`--encrypt`, `--encryption-key`) are ignored for data DBs; per-database encryption is managed via RPC. + +## Admin Database (DB 0) + +DB 0 acts as the administrative database instance, storing metadata for all user databases (IDs >= 1). It controls existence, access control, and per-database encryption. DB 0 is always encrypted with the `--admin-secret`. + +When creating a new database, DB 0 allocates an ID, registers it, and optionally stores a per-database encryption key (write-only). Databases are public by default; use RPC to set them private, requiring access keys for SELECT (read or readwrite based on permissions). Keys are persisted in DB 0 for managed AGE operations. + +Access DB 0 with `SELECT 0 KEY `. + +## Symmetric Encryption + +HeroDB supports stateless symmetric encryption via SYM commands, using XChaCha20-Poly1305 AEAD. + +Commands: +- `SYM KEYGEN`: Generate 32-byte key. Returns base64-encoded key. +- `SYM ENCRYPT `: Encrypt message. Returns base64 ciphertext. +- `SYM DECRYPT `: Decrypt. Returns plaintext. + +Example: +```bash +redis-cli SYM KEYGEN +# → base64_key + +redis-cli SYM ENCRYPT base64_key "secret" +# → base64_ciphertext + +redis-cli SYM DECRYPT base64_key base64_ciphertext +# → "secret" +``` + +## RPC Options + +Enable the JSON-RPC server with `--enable-rpc` for database management. Methods include creating databases, managing access keys, and setting encryption. See [JSON-RPC Examples](./rpc_examples.md) for payloads. + # HeroDB Commands HeroDB implements a subset of Redis commands over the Redis protocol. This document describes the available commands and their usage. @@ -575,6 +629,29 @@ redis-cli -p $PORT AGE LIST # 2) "keyname2" ``` +## SYM Commands + +### SYM KEYGEN +Generate a symmetric encryption key. +```bash +redis-cli -p $PORT SYM KEYGEN +# → base64_encoded_32byte_key +``` + +### SYM ENCRYPT +Encrypt a message with a symmetric key. +```bash +redis-cli -p $PORT SYM ENCRYPT "message" +# → base64_encoded_ciphertext +``` + +### SYM DECRYPT +Decrypt a ciphertext with a symmetric key. +```bash +redis-cli -p $PORT SYM DECRYPT +# → decrypted_message +``` + ## Server Information Commands ### INFO @@ -621,3 +698,27 @@ This expanded documentation includes all the list commands that were implemented 10. LINDEX - get element by index 11. LRANGE - get range of elements + +## Updated Database Selection and Access Keys + +HeroDB uses an `Admin DB 0` to control database existence, access, and encryption. Access to data DBs can be public (no key) or private (requires a key). See detailed model in `docs/admin.md`. + +Examples: + +```bash +# Public database (no key required) +redis-cli -p $PORT SELECT 1 +# → OK +``` + +```bash +# Private database (requires access key) +redis-cli -p $PORT SELECT 2 KEY my-db2-access-key +# → OK +``` + +```bash +# Admin DB 0 (requires admin secret) +redis-cli -p $PORT SELECT 0 KEY my-admin-secret +# → OK +``` diff --git a/docs/cmds.md b/docs/cmds.md index 78a6e78..2f61c87 100644 --- a/docs/cmds.md +++ b/docs/cmds.md @@ -122,4 +122,27 @@ redis-cli -p 6379 --rdb dump.rdb # Import to sled redis-cli -p 6381 --pipe < dump.rdb +``` + +## Authentication and Database Selection + +HeroDB uses an `Admin DB 0` to govern database existence, access and per-db encryption. Access control is enforced via `Admin DB 0` metadata. See the full model in `docs/admin.md`. + +Examples: +```bash +# Public database (no key required) +redis-cli -p $PORT SELECT 1 +# → OK +``` + +```bash +# Private database (requires access key) +redis-cli -p $PORT SELECT 2 KEY my-db2-access-key +# → OK +``` + +```bash +# Admin DB 0 (requires admin secret) +redis-cli -p $PORT SELECT 0 KEY my-admin-secret +# → OK ``` \ No newline at end of file diff --git a/docs/rpc_examples.md b/docs/rpc_examples.md new file mode 100644 index 0000000..2b941cc --- /dev/null +++ b/docs/rpc_examples.md @@ -0,0 +1,141 @@ +# HeroDB JSON-RPC Examples + +These examples show full JSON-RPC 2.0 payloads for managing HeroDB via the RPC API (enable with `--enable-rpc`). Methods are named as `hero_`. Params are positional arrays; enum values are strings (e.g., `"Redb"`). Copy-paste into Postman or similar clients. + +## Database Management + +### Create Database +Creates a new database with optional per-database encryption key (stored write-only in Admin DB 0). + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "hero_createDatabase", + "params": [ + "Redb", + { "name": null, "storage_path": null, "max_size": null, "redis_version": null }, + null + ] +} +``` + +With encryption: +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "hero_createDatabase", + "params": [ + "Sled", + { "name": "secure-db", "storage_path": null, "max_size": null, "redis_version": null }, + "my-per-db-encryption-key" + ] +} +``` + +### List Databases +Returns array of database infos (id, backend, encrypted status, size, etc.). + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "method": "hero_listDatabases", + "params": [] +} +``` + +### Get Database Info +Retrieves detailed info for a specific database. + +```json +{ + "jsonrpc": "2.0", + "id": 4, + "method": "hero_getDatabaseInfo", + "params": [1] +} +``` + +### Delete Database +Removes physical database file; metadata remains in Admin DB 0. + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "method": "hero_deleteDatabase", + "params": [1] +} +``` + +## Access Control + +### Add Access Key +Adds a hashed access key for private databases. Permissions: `"read"` or `"readwrite"`. + +```json +{ + "jsonrpc": "2.0", + "id": 6, + "method": "hero_addAccessKey", + "params": [2, "my-access-key", "readwrite"] +} +``` + +### List Access Keys +Returns array of key hashes, permissions, and creation timestamps. + +```json +{ + "jsonrpc": "2.0", + "id": 7, + "method": "hero_listAccessKeys", + "params": [2] +} +``` + +### Delete Access Key +Removes key by its SHA-256 hash. + +```json +{ + "jsonrpc": "2.0", + "id": 8, + "method": "hero_deleteAccessKey", + "params": [2, "0123abcd...keyhash..."] +} +``` + +### Set Database Public/Private +Toggles public access (default true). Private databases require access keys. + +```json +{ + "jsonrpc": "2.0", + "id": 9, + "method": "hero_setDatabasePublic", + "params": [2, false] +} +``` + +## Server Info + +### Get Server Stats +Returns stats like total databases and uptime. + +```json +{ + "jsonrpc": "2.0", + "id": 10, + "method": "hero_getServerStats", + "params": [] +} +``` + +## Notes +- Per-database encryption keys are write-only; set at creation and used transparently. +- Access keys are hashed (SHA-256) for storage; provide plaintext in requests. +- Backend options: `"Redb"` (default) or `"Sled"`. +- Config object fields (name, storage_path, etc.) are optional and currently ignored but positional. \ No newline at end of file diff --git a/src/admin_meta.rs b/src/admin_meta.rs new file mode 100644 index 0000000..9039402 --- /dev/null +++ b/src/admin_meta.rs @@ -0,0 +1,481 @@ +use std::path::PathBuf; +use std::sync::{Arc, OnceLock, Mutex, RwLock}; +use std::collections::HashMap; + +use crate::error::DBError; +use crate::options; +use crate::rpc::Permissions; +use crate::storage::Storage; +use crate::storage_sled::SledStorage; +use crate::storage_trait::StorageBackend; + +// Key builders +fn k_admin_next_id() -> &'static str { + "admin:next_id" +} +fn k_admin_dbs() -> &'static str { + "admin:dbs" +} +fn k_meta_db(id: u64) -> String { + format!("meta:db:{}", id) +} +fn k_meta_db_keys(id: u64) -> String { + format!("meta:db:{}:keys", id) +} +fn k_meta_db_enc(id: u64) -> String { + format!("meta:db:{}:enc", id) +} + +// Global cache of admin DB 0 handles per base_dir to avoid sled/reDB file-lock contention +// and to correctly isolate different test instances with distinct directories. +static ADMIN_STORAGES: OnceLock>>> = OnceLock::new(); + +// Global registry for data DB storages to avoid double-open across process. +static DATA_STORAGES: OnceLock>>> = OnceLock::new(); +static DATA_INIT_LOCK: Mutex<()> = Mutex::new(()); + +fn init_admin_storage( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, +) -> Result, DBError> { + let db_file = PathBuf::from(base_dir).join("0.db"); + if let Some(parent_dir) = db_file.parent() { + std::fs::create_dir_all(parent_dir).map_err(|e| { + DBError(format!("Failed to create directory {}: {}", parent_dir.display(), e)) + })?; + } + let storage: Arc = match backend { + options::BackendType::Redb => Arc::new(Storage::new(&db_file, true, Some(admin_secret))?), + options::BackendType::Sled => Arc::new(SledStorage::new(&db_file, true, Some(admin_secret))?), + }; + Ok(storage) +} + +// Get or initialize a cached handle to admin DB 0 per base_dir (thread-safe, no double-open race) +pub fn open_admin_storage( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, +) -> Result, DBError> { + let map = ADMIN_STORAGES.get_or_init(|| RwLock::new(HashMap::new())); + // Fast path + if let Some(st) = map.read().unwrap().get(base_dir) { + return Ok(st.clone()); + } + // Slow path with write lock + { + let mut w = map.write().unwrap(); + if let Some(st) = w.get(base_dir) { + return Ok(st.clone()); + } + + // Detect existing 0.db backend by filesystem, if present. + let admin_path = PathBuf::from(base_dir).join("0.db"); + let detected = if admin_path.exists() { + if admin_path.is_file() { + Some(options::BackendType::Redb) + } else if admin_path.is_dir() { + Some(options::BackendType::Sled) + } else { + None + } + } else { + None + }; + + let effective_backend = match detected { + Some(d) if d != backend => { + eprintln!( + "warning: Admin DB 0 at {} appears to be {:?}, but process default is {:?}. Using detected backend.", + admin_path.display(), + d, + backend + ); + d + } + Some(d) => d, + None => backend, // First boot: use requested backend to initialize 0.db + }; + + let st = init_admin_storage(base_dir, effective_backend, admin_secret)?; + w.insert(base_dir.to_string(), st.clone()); + Ok(st) + } +} + +// Ensure admin structures exist in encrypted DB 0 +pub fn ensure_bootstrap( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, +) -> Result<(), DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + + // Initialize next id if missing + if !admin.exists(k_admin_next_id())? { + admin.set(k_admin_next_id().to_string(), "1".to_string())?; + } + // admin:dbs is a hash; it's fine if it doesn't exist (hlen -> 0) + Ok(()) +} + +// Get or initialize a shared handle to a data DB (> 0), avoiding double-open across subsystems +pub fn open_data_storage( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, +) -> Result, DBError> { + if id == 0 { + return open_admin_storage(base_dir, backend, admin_secret); + } + + // Validate existence in admin metadata + if !db_exists(base_dir, backend.clone(), admin_secret, id)? { + return Err(DBError(format!( + "Cannot open database instance {}, as that database instance does not exist.", + id + ))); + } + + let map = DATA_STORAGES.get_or_init(|| RwLock::new(HashMap::new())); + // Fast path + if let Some(st) = map.read().unwrap().get(&id) { + return Ok(st.clone()); + } + + // Slow path with init lock + let _guard = DATA_INIT_LOCK.lock().unwrap(); + if let Some(st) = map.read().unwrap().get(&id) { + return Ok(st.clone()); + } + + // Resolve effective backend for this db id: + // 1) Try admin meta "backend" field + // 2) If missing, sniff filesystem (file => Redb, dir => Sled), then persist into admin meta + // 3) Fallback to requested 'backend' (startup default) if nothing else is known + let meta_backend = get_database_backend(base_dir, backend.clone(), admin_secret, id).ok().flatten(); + let db_path = PathBuf::from(base_dir).join(format!("{}.db", id)); + let sniffed_backend = if db_path.exists() { + if db_path.is_file() { + Some(options::BackendType::Redb) + } else if db_path.is_dir() { + Some(options::BackendType::Sled) + } else { + None + } + } else { + None + }; + let effective_backend = meta_backend.clone().or(sniffed_backend).unwrap_or(backend.clone()); + + // If we had to sniff (i.e., meta missing), persist it for future robustness + if meta_backend.is_none() { + let _ = set_database_backend(base_dir, backend.clone(), admin_secret, id, effective_backend.clone()); + } + + // Warn if caller-provided backend differs from effective + if effective_backend != backend { + eprintln!( + "notice: Database {} backend resolved to {:?} (caller requested {:?}). Using resolved backend.", + id, effective_backend, backend + ); + } + + // Determine per-db encryption (from admin meta) + let enc = get_enc_key(base_dir, backend.clone(), admin_secret, id)?; + let should_encrypt = enc.is_some(); + + // Build database file path and ensure parent dir exists + let db_file = PathBuf::from(base_dir).join(format!("{}.db", id)); + if let Some(parent_dir) = db_file.parent() { + std::fs::create_dir_all(parent_dir).map_err(|e| { + DBError(format!("Failed to create directory {}: {}", parent_dir.display(), e)) + })?; + } + + // Open storage using the effective backend + let storage: Arc = match effective_backend { + options::BackendType::Redb => Arc::new(Storage::new(&db_file, should_encrypt, enc.as_deref())?), + options::BackendType::Sled => Arc::new(SledStorage::new(&db_file, should_encrypt, enc.as_deref())?), + }; + + // Publish to registry + map.write().unwrap().insert(id, storage.clone()); + Ok(storage) +} + +// Allocate the next DB id and persist new pointer +pub fn allocate_next_id( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, +) -> Result { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let cur = admin + .get(k_admin_next_id())? + .unwrap_or_else(|| "1".to_string()); + let id: u64 = cur.parse().unwrap_or(1); + let next = id.checked_add(1).ok_or_else(|| DBError("next_id overflow".into()))?; + admin.set(k_admin_next_id().to_string(), next.to_string())?; + + // Register into admin:dbs set/hash + let _ = admin.hset(k_admin_dbs(), vec![(id.to_string(), "1".to_string())])?; + + // Default meta for the new db: public true + let meta_key = k_meta_db(id); + let _ = admin.hset(&meta_key, vec![("public".to_string(), "true".to_string())])?; + + Ok(id) +} + +// Check existence of a db id in admin:dbs +pub fn db_exists( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, +) -> Result { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + Ok(admin.hexists(k_admin_dbs(), &id.to_string())?) +} + +// Get per-db encryption key, if any +pub fn get_enc_key( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, +) -> Result, DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + admin.get(&k_meta_db_enc(id)) +} + +// Set per-db encryption key (called during create) +pub fn set_enc_key( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, + key: &str, +) -> Result<(), DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + admin.set(k_meta_db_enc(id), key.to_string()) +} + +// Set database public flag +pub fn set_database_public( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, + public: bool, +) -> Result<(), DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let mk = k_meta_db(id); + let _ = admin.hset(&mk, vec![("public".to_string(), public.to_string())])?; + Ok(()) +} + +// Persist per-db backend type in admin metadata (module-scope) +pub fn set_database_backend( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, + db_backend: options::BackendType, +) -> Result<(), DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let mk = k_meta_db(id); + let val = match db_backend { + options::BackendType::Redb => "Redb", + options::BackendType::Sled => "Sled", + }; + let _ = admin.hset(&mk, vec![("backend".to_string(), val.to_string())])?; + Ok(()) +} + +pub fn get_database_backend( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, +) -> Result, DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let mk = k_meta_db(id); + match admin.hget(&mk, "backend")? { + Some(s) if s == "Redb" => Ok(Some(options::BackendType::Redb)), + Some(s) if s == "Sled" => Ok(Some(options::BackendType::Sled)), + _ => Ok(None), + } +} + +// Set database name +pub fn set_database_name( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, + name: &str, +) -> Result<(), DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let mk = k_meta_db(id); + let _ = admin.hset(&mk, vec![("name".to_string(), name.to_string())])?; + Ok(()) +} + +// Get database name +pub fn get_database_name( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, +) -> Result, DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let mk = k_meta_db(id); + admin.hget(&mk, "name") +} + +// Internal: load public flag; default to true when meta missing +fn load_public( + admin: &Arc, + id: u64, +) -> Result { + let mk = k_meta_db(id); + match admin.hget(&mk, "public")? { + Some(v) => Ok(v == "true"), + None => Ok(true), + } +} + +// Add access key for db (value format: "Read:ts" or "ReadWrite:ts") +pub fn add_access_key( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, + key_plain: &str, + perms: Permissions, +) -> Result<(), DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let hash = crate::rpc::hash_key(key_plain); + let v = match perms { + Permissions::Read => format!("Read:{}", now_secs()), + Permissions::ReadWrite => format!("ReadWrite:{}", now_secs()), + }; + let _ = admin.hset(&k_meta_db_keys(id), vec![(hash, v)])?; + Ok(()) +} + +// Delete access key by hash +pub fn delete_access_key( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, + key_hash: &str, +) -> Result { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let n = admin.hdel(&k_meta_db_keys(id), vec![key_hash.to_string()])?; + Ok(n > 0) +} + +// List access keys, returning (hash, perms, created_at_secs) +pub fn list_access_keys( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, +) -> Result, DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let pairs = admin.hgetall(&k_meta_db_keys(id))?; + let mut out = Vec::new(); + for (hash, val) in pairs { + let (perm, ts) = parse_perm_value(&val); + out.push((hash, perm, ts)); + } + Ok(out) +} + +// Verify access permission for db id with optional key +// Returns: +// - Ok(Some(Permissions)) when access is allowed +// - Ok(None) when not allowed or db missing (caller can distinguish by calling db_exists) +pub fn verify_access( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, + id: u64, + key_opt: Option<&str>, +) -> Result, DBError> { + // Admin DB 0: require exact admin_secret + if id == 0 { + if let Some(k) = key_opt { + if k == admin_secret { + return Ok(Some(Permissions::ReadWrite)); + } + } + return Ok(None); + } + + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + if !admin.hexists(k_admin_dbs(), &id.to_string())? { + return Ok(None); + } + + // Public? + if load_public(&admin, id)? { + return Ok(Some(Permissions::ReadWrite)); + } + + // Private: require key and verify + if let Some(k) = key_opt { + let hash = crate::rpc::hash_key(k); + if let Some(v) = admin.hget(&k_meta_db_keys(id), &hash)? { + let (perm, _ts) = parse_perm_value(&v); + return Ok(Some(perm)); + } + } + Ok(None) +} + +// Enumerate all db ids +pub fn list_dbs( + base_dir: &str, + backend: options::BackendType, + admin_secret: &str, +) -> Result, DBError> { + let admin = open_admin_storage(base_dir, backend, admin_secret)?; + let ids = admin.hkeys(k_admin_dbs())?; + let mut out = Vec::new(); + for s in ids { + if let Ok(v) = s.parse() { + out.push(v); + } + } + Ok(out) +} + +// Helper: parse permission value "Read:ts" or "ReadWrite:ts" +fn parse_perm_value(v: &str) -> (Permissions, u64) { + let mut parts = v.split(':'); + let p = parts.next().unwrap_or("Read"); + let ts = parts + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0u64); + let perm = match p { + "ReadWrite" => Permissions::ReadWrite, + _ => Permissions::Read, + }; + (perm, ts) +} + +fn now_secs() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} \ No newline at end of file diff --git a/src/age.rs b/src/age.rs index 77501da..62ef14f 100644 --- a/src/age.rs +++ b/src/age.rs @@ -19,6 +19,8 @@ use age::x25519; use ed25519_dalek::{Signature, Signer, Verifier, SigningKey, VerifyingKey}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use std::collections::HashSet; +use std::convert::TryInto; use crate::protocol::Protocol; use crate::server::Server; @@ -74,6 +76,125 @@ fn parse_ed25519_verifying_key(s: &str) -> Result { VerifyingKey::from_bytes(&key_bytes).map_err(|_| AgeWireError::ParseKey) } +// ---------- Derivation + Raw X25519 (Ed25519 -> X25519) ---------- +// +// We deterministically derive an X25519 keypair from an Ed25519 SigningKey. +// We persist the X25519 public/secret as base64-encoded 32-byte raw values +// (no "age1..."/"AGE-SECRET-KEY-1..." formatting). Name-based encrypt/decrypt +// uses these raw values directly via x25519-dalek + ChaCha20Poly1305. + +use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Key, Nonce}; +use sha2::{Digest, Sha256}; +use x25519_dalek::{PublicKey as XPublicKey, StaticSecret as XStaticSecret}; + +fn derive_x25519_raw_from_ed25519(sk: &SigningKey) -> ([u8; 32], [u8; 32]) { + // X25519 secret scalar (clamped) from Ed25519 secret + let scalar: [u8; 32] = sk.to_scalar_bytes(); + // Build X25519 secret/public using dalek + let xsec = XStaticSecret::from(scalar); + let xpub = XPublicKey::from(&xsec); + (xpub.to_bytes(), xsec.to_bytes()) +} + +fn derive_x25519_raw_b64_from_ed25519(sk: &SigningKey) -> (String, String) { + let (xpub, xsec) = derive_x25519_raw_from_ed25519(sk); + (B64.encode(xpub), B64.encode(xsec)) +} + +// Helper: detect whether a stored key looks like an age-formatted string +fn looks_like_age_format(s: &str) -> bool { + s.starts_with("age1") || s.starts_with("AGE-SECRET-KEY-1") +} + +// Our container format for name-based raw X25519 encryption: +// bytes = "HDBX1" (5) || eph_pub(32) || nonce(12) || ciphertext(..) +// Entire blob is base64-encoded for transport. +const HDBX1_MAGIC: &[u8; 5] = b"HDBX1"; + +fn encrypt_b64_with_x25519_raw(recip_pub_b64: &str, msg: &str) -> Result { + use rand::RngCore; + use rand::rngs::OsRng; + + // Parse recipient public key (raw 32 bytes, base64) + let recip_pub_bytes = B64.decode(recip_pub_b64).map_err(|_| AgeWireError::ParseKey)?; + if recip_pub_bytes.len() != 32 { return Err(AgeWireError::ParseKey); } + let recip_pub_arr: [u8; 32] = recip_pub_bytes.as_slice().try_into().map_err(|_| AgeWireError::ParseKey)?; + let recip_pub: XPublicKey = XPublicKey::from(recip_pub_arr); + + // Generate ephemeral X25519 keypair + let mut eph_sec_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut eph_sec_bytes); + let eph_sec = XStaticSecret::from(eph_sec_bytes); + let eph_pub = XPublicKey::from(&eph_sec); + + // ECDH + let shared = eph_sec.diffie_hellman(&recip_pub); + // Derive symmetric key via SHA-256 over context + shared + parties + let mut hasher = Sha256::default(); + hasher.update(b"herodb-x25519-v1"); + hasher.update(shared.as_bytes()); + hasher.update(eph_pub.as_bytes()); + hasher.update(recip_pub.as_bytes()); + let key_bytes = hasher.finalize(); + let key = Key::from_slice(&key_bytes[..32]); + + // Nonce (12 bytes) + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt + let cipher = ChaCha20Poly1305::new(key); + let ct = cipher.encrypt(nonce, msg.as_bytes()) + .map_err(|e| AgeWireError::Crypto(format!("encrypt: {e}")))?; + + // Assemble container + let mut out = Vec::with_capacity(5 + 32 + 12 + ct.len()); + out.extend_from_slice(HDBX1_MAGIC); + out.extend_from_slice(eph_pub.as_bytes()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ct); + + Ok(B64.encode(out)) +} + +fn decrypt_b64_with_x25519_raw(identity_sec_b64: &str, ct_b64: &str) -> Result { + // Parse X25519 secret (raw 32 bytes, base64) + let sec_bytes = B64.decode(identity_sec_b64).map_err(|_| AgeWireError::ParseKey)?; + if sec_bytes.len() != 32 { return Err(AgeWireError::ParseKey); } + let sec_arr: [u8; 32] = sec_bytes.as_slice().try_into().map_err(|_| AgeWireError::ParseKey)?; + let xsec = XStaticSecret::from(sec_arr); + let xpub = XPublicKey::from(&xsec); // self public + + // Decode container + let blob = B64.decode(ct_b64.as_bytes()).map_err(|e| AgeWireError::Crypto(e.to_string()))?; + if blob.len() < 5 + 32 + 12 { return Err(AgeWireError::Crypto("ciphertext too short".to_string())); } + if &blob[..5] != HDBX1_MAGIC { return Err(AgeWireError::Crypto("bad header".to_string())); } + + let eph_pub_arr: [u8; 32] = blob[5..5+32].try_into().map_err(|_| AgeWireError::Crypto("bad eph pub".to_string()))?; + let eph_pub = XPublicKey::from(eph_pub_arr); + let nonce_bytes: [u8; 12] = blob[5+32..5+32+12].try_into().unwrap(); + let ct = &blob[5+32+12..]; + + // Recompute shared + key + let shared = xsec.diffie_hellman(&eph_pub); + let mut hasher = Sha256::default(); + hasher.update(b"herodb-x25519-v1"); + hasher.update(shared.as_bytes()); + hasher.update(eph_pub.as_bytes()); + hasher.update(xpub.as_bytes()); + let key_bytes = hasher.finalize(); + let key = Key::from_slice(&key_bytes[..32]); + + // Decrypt + let cipher = ChaCha20Poly1305::new(key); + let nonce = Nonce::from_slice(&nonce_bytes); + let pt = cipher.decrypt(nonce, ct) + .map_err(|e| AgeWireError::Crypto(format!("decrypt: {e}")))?; + + String::from_utf8(pt).map_err(|_| AgeWireError::Utf8) +} + // ---------- Stateless crypto helpers (string in/out) ---------- pub fn gen_enc_keypair() -> (String, String) { @@ -210,13 +331,72 @@ pub async fn cmd_age_verify(verify_pub: &str, message: &str, sig_b64: &str) -> P } } +// ---------- NEW: unified stateless generator (Ed25519 + derived X25519 raw) ---------- +// +// Returns 4-tuple: +// [ verify_pub_b64 (32B), signpriv_b64 (32B), x25519_pub_b64 (32B), x25519_sec_b64 (32B) ] +// No persistence (stateless). +pub async fn cmd_age_genkey() -> Protocol { + use rand::RngCore; + use rand::rngs::OsRng; + + let mut secret_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut secret_bytes); + + let signing_key = SigningKey::from_bytes(&secret_bytes); + let verifying_key = signing_key.verifying_key(); + + let verify_b64 = B64.encode(verifying_key.to_bytes()); + let sign_b64 = B64.encode(signing_key.to_bytes()); + + let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&signing_key); + + Protocol::Array(vec![ + Protocol::BulkString(verify_b64), + Protocol::BulkString(sign_b64), + Protocol::BulkString(xpub_b64), + Protocol::BulkString(xsec_b64), + ]) +} + // ---------- NEW: Persistent, named-key commands ---------- pub async fn cmd_age_keygen(server: &Server, name: &str) -> Protocol { - let (recip, ident) = gen_enc_keypair(); - if let Err(e) = sset(server, &enc_pub_key_key(name), &recip) { return e.to_protocol(); } - if let Err(e) = sset(server, &enc_priv_key_key(name), &ident) { return e.to_protocol(); } - Protocol::Array(vec![Protocol::BulkString(recip), Protocol::BulkString(ident)]) + use rand::RngCore; + use rand::rngs::OsRng; + + // Generate Ed25519 keypair + let mut secret_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut secret_bytes); + let signing_key = SigningKey::from_bytes(&secret_bytes); + let verifying_key = signing_key.verifying_key(); + + // Encode Ed25519 as base64 (32 bytes) + let verify_b64 = B64.encode(verifying_key.to_bytes()); + let sign_b64 = B64.encode(signing_key.to_bytes()); + + // Derive X25519 raw (32-byte) keys and encode as base64 + let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&signing_key); + + // Decode to create age-formatted strings + let xpub_bytes = B64.decode(&xpub_b64).unwrap(); + let xsec_bytes = B64.decode(&xsec_b64).unwrap(); + let xpub_arr: [u8; 32] = xpub_bytes.as_slice().try_into().unwrap(); + let xsec_arr: [u8; 32] = xsec_bytes.as_slice().try_into().unwrap(); + let recip_str = format!("age1{}", B64.encode(xpub_arr)); + let ident_str = format!("AGE-SECRET-KEY-1{}", B64.encode(xsec_arr)); + + // Persist Ed25519 and derived X25519 (key-managed mode) + if let Err(e) = sset(server, &sign_pub_key_key(name), &verify_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &sign_priv_key_key(name), &sign_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); } + + // Return [recipient, identity] in age format + Protocol::Array(vec![ + Protocol::BulkString(recip_str), + Protocol::BulkString(ident_str), + ]) } pub async fn cmd_age_signkeygen(server: &Server, name: &str) -> Protocol { @@ -227,26 +407,76 @@ pub async fn cmd_age_signkeygen(server: &Server, name: &str) -> Protocol { } pub async fn cmd_age_encrypt_name(server: &Server, name: &str, message: &str) -> Protocol { - let recip = match sget(server, &enc_pub_key_key(name)) { + // Load stored recipient (could be raw b64 32-byte or "age1..." from legacy) + let recip_or_b64 = match sget(server, &enc_pub_key_key(name)) { Ok(Some(v)) => v, - Ok(None) => return AgeWireError::NotFound("recipient (age:key:{name})").to_protocol(), + Ok(None) => { + // Derive from stored Ed25519 if present, then persist + match sget(server, &sign_priv_key_key(name)) { + Ok(Some(sign_b64)) => { + let sk = match parse_ed25519_signing_key(&sign_b64) { + Ok(k) => k, + Err(e) => return e.to_protocol(), + }; + let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&sk); + if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); } + xpub_b64 + } + Ok(None) => return AgeWireError::NotFound("recipient (age:key:{name})").to_protocol(), + Err(e) => return e.to_protocol(), + } + } Err(e) => return e.to_protocol(), }; - match encrypt_b64(&recip, message) { - Ok(ct) => Protocol::BulkString(ct), - Err(e) => e.to_protocol(), + + if looks_like_age_format(&recip_or_b64) { + match encrypt_b64(&recip_or_b64, message) { + Ok(ct) => Protocol::BulkString(ct), + Err(e) => e.to_protocol(), + } + } else { + match encrypt_b64_with_x25519_raw(&recip_or_b64, message) { + Ok(ct) => Protocol::BulkString(ct), + Err(e) => e.to_protocol(), + } } } pub async fn cmd_age_decrypt_name(server: &Server, name: &str, ct_b64: &str) -> Protocol { - let ident = match sget(server, &enc_priv_key_key(name)) { + // Load stored identity (could be raw b64 32-byte or "AGE-SECRET-KEY-1..." from legacy) + let ident_or_b64 = match sget(server, &enc_priv_key_key(name)) { Ok(Some(v)) => v, - Ok(None) => return AgeWireError::NotFound("identity (age:privkey:{name})").to_protocol(), + Ok(None) => { + // Derive from stored Ed25519 if present, then persist + match sget(server, &sign_priv_key_key(name)) { + Ok(Some(sign_b64)) => { + let sk = match parse_ed25519_signing_key(&sign_b64) { + Ok(k) => k, + Err(e) => return e.to_protocol(), + }; + let (xpub_b64, xsec_b64) = derive_x25519_raw_b64_from_ed25519(&sk); + if let Err(e) = sset(server, &enc_pub_key_key(name), &xpub_b64) { return e.to_protocol(); } + if let Err(e) = sset(server, &enc_priv_key_key(name), &xsec_b64) { return e.to_protocol(); } + xsec_b64 + } + Ok(None) => return AgeWireError::NotFound("identity (age:privkey:{name})").to_protocol(), + Err(e) => return e.to_protocol(), + } + } Err(e) => return e.to_protocol(), }; - match decrypt_b64(&ident, ct_b64) { - Ok(pt) => Protocol::BulkString(pt), - Err(e) => e.to_protocol(), + + if looks_like_age_format(&ident_or_b64) { + match decrypt_b64(&ident_or_b64, ct_b64) { + Ok(pt) => Protocol::BulkString(pt), + Err(e) => e.to_protocol(), + } + } else { + match decrypt_b64_with_x25519_raw(&ident_or_b64, ct_b64) { + Ok(pt) => Protocol::BulkString(pt), + Err(e) => e.to_protocol(), + } } } @@ -276,33 +506,31 @@ pub async fn cmd_age_verify_name(server: &Server, name: &str, message: &str, sig } pub async fn cmd_age_list(server: &Server) -> Protocol { - // Returns 4 arrays: ["encpub", ], ["encpriv", ...], ["signpub", ...], ["signpriv", ...] + // Return a flat, deduplicated, sorted list of managed key names (no labels) let st = match server.current_storage() { Ok(s) => s, Err(e) => return Protocol::err(&e.0) }; let pull = |pat: &str, prefix: &str| -> Result, DBError> { let keys = st.keys(pat)?; - let mut names: Vec = keys.into_iter() + let mut names: Vec = keys + .into_iter() .filter_map(|k| k.strip_prefix(prefix).map(|x| x.to_string())) .collect(); names.sort(); Ok(names) }; - let encpub = match pull("age:key:*", "age:key:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) }; - let encpriv = match pull("age:privkey:*", "age:privkey:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) }; - let signpub = match pull("age:signpub:*", "age:signpub:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) }; - let signpriv= match pull("age:signpriv:*", "age:signpriv:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) }; + let encpub = match pull("age:key:*", "age:key:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) }; + let encpriv = match pull("age:privkey:*", "age:privkey:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) }; + let signpub = match pull("age:signpub:*", "age:signpub:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) }; + let signpriv = match pull("age:signpriv:*", "age:signpriv:") { Ok(v) => v, Err(e)=> return Protocol::err(&e.0) }; - let to_arr = |label: &str, v: Vec| { - let mut out = vec![Protocol::BulkString(label.to_string())]; - out.push(Protocol::Array(v.into_iter().map(Protocol::BulkString).collect())); - Protocol::Array(out) - }; + let mut set: HashSet = HashSet::new(); + for n in encpub.into_iter().chain(encpriv).chain(signpub).chain(signpriv) { + set.insert(n); + } - Protocol::Array(vec![ - to_arr("encpub", encpub), - to_arr("encpriv", encpriv), - to_arr("signpub", signpub), - to_arr("signpriv", signpriv), - ]) + let mut names: Vec = set.into_iter().collect(); + names.sort(); + + Protocol::Array(names.into_iter().map(Protocol::BulkString).collect()) } \ No newline at end of file diff --git a/src/cmd.rs b/src/cmd.rs index 176ed2f..3405f02 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -6,7 +6,7 @@ use futures::future::select_all; pub enum Cmd { Ping, Echo(String), - Select(u64), // Changed from u16 to u64 + Select(u64, Option), // db_index, optional_key Get(String), Set(String, String), SetPx(String, String, u128), @@ -71,12 +71,13 @@ pub enum Cmd { // AGE (rage) commands — stateless AgeGenEnc, AgeGenSign, + AgeGenKey, // unified stateless: returns [verify_b64, signpriv_b64, x25519_pub_b64, x25519_sec_b64] AgeEncrypt(String, String), // recipient, message AgeDecrypt(String, String), // identity, ciphertext_b64 AgeSign(String, String), // signing_secret, message AgeVerify(String, String, String), // verify_pub, message, signature_b64 - // NEW: persistent named-key commands + // Persistent named-key commands AgeKeygen(String), // name AgeSignKeygen(String), // name AgeEncryptName(String, String), // name, message @@ -84,6 +85,12 @@ pub enum Cmd { AgeSignName(String, String), // name, message AgeVerifyName(String, String, String), // name, message, signature_b64 AgeList, + + // SYM (symmetric) commands — stateless + // Raw 32-byte key provided as base64; ciphertext returned as base64 + SymKeygen, + SymEncrypt(String, String), // key_b64, message + SymDecrypt(String, String), // key_b64, ciphertext_b64 } impl Cmd { @@ -98,11 +105,18 @@ impl Cmd { Ok(( match cmd[0].to_lowercase().as_str() { "select" => { - if cmd.len() != 2 { + if cmd.len() < 2 || cmd.len() > 4 { return Err(DBError("wrong number of arguments for SELECT".to_string())); } let idx = cmd[1].parse::().map_err(|_| DBError("ERR DB index is not an integer".to_string()))?; - Cmd::Select(idx) + let key = if cmd.len() == 4 && cmd[2].to_lowercase() == "key" { + Some(cmd[3].clone()) + } else if cmd.len() == 2 { + None + } else { + return Err(DBError("ERR syntax error".to_string())); + }; + Cmd::Select(idx, key) } "echo" => Cmd::Echo(cmd[1].clone()), "ping" => Cmd::Ping, @@ -589,6 +603,8 @@ impl Cmd { Cmd::AgeGenEnc } "gensign" => { if cmd.len() != 2 { return Err(DBError("AGE GENSIGN takes no args".to_string())); } Cmd::AgeGenSign } + "genkey" => { if cmd.len() != 2 { return Err(DBError("AGE GENKEY takes no args".to_string())); } + Cmd::AgeGenKey } "encrypt" => { if cmd.len() != 4 { return Err(DBError("AGE ENCRYPT ".to_string())); } Cmd::AgeEncrypt(cmd[2].clone(), cmd[3].clone()) } "decrypt" => { if cmd.len() != 4 { return Err(DBError("AGE DECRYPT ".to_string())); } @@ -616,6 +632,20 @@ impl Cmd { _ => return Err(DBError(format!("unsupported AGE subcommand {:?}", cmd))), } } + "sym" => { + if cmd.len() < 2 { + return Err(DBError("wrong number of arguments for SYM".to_string())); + } + match cmd[1].to_lowercase().as_str() { + "keygen" => { if cmd.len() != 2 { return Err(DBError("SYM KEYGEN takes no args".to_string())); } + Cmd::SymKeygen } + "encrypt" => { if cmd.len() != 4 { return Err(DBError("SYM ENCRYPT ".to_string())); } + Cmd::SymEncrypt(cmd[2].clone(), cmd[3].clone()) } + "decrypt" => { if cmd.len() != 4 { return Err(DBError("SYM DECRYPT ".to_string())); } + Cmd::SymDecrypt(cmd[2].clone(), cmd[3].clone()) } + _ => return Err(DBError(format!("unsupported SYM subcommand {:?}", cmd))), + } + } _ => Cmd::Unknow(cmd[0].clone()), }, protocol, @@ -642,7 +672,7 @@ impl Cmd { } match self { - Cmd::Select(db) => select_cmd(server, db).await, + Cmd::Select(db, key) => select_cmd(server, db, key).await, Cmd::Ping => Ok(Protocol::SimpleString("PONG".to_string())), Cmd::Echo(s) => Ok(Protocol::BulkString(s)), Cmd::Get(k) => get_cmd(server, &k).await, @@ -717,6 +747,7 @@ impl Cmd { // AGE (rage): stateless Cmd::AgeGenEnc => Ok(crate::age::cmd_age_genenc().await), Cmd::AgeGenSign => Ok(crate::age::cmd_age_gensign().await), + Cmd::AgeGenKey => Ok(crate::age::cmd_age_genkey().await), Cmd::AgeEncrypt(recipient, message) => Ok(crate::age::cmd_age_encrypt(&recipient, &message).await), Cmd::AgeDecrypt(identity, ct_b64) => Ok(crate::age::cmd_age_decrypt(&identity, &ct_b64).await), Cmd::AgeSign(secret, message) => Ok(crate::age::cmd_age_sign(&secret, &message).await), @@ -730,13 +761,26 @@ impl Cmd { Cmd::AgeSignName(name, message) => Ok(crate::age::cmd_age_sign_name(server, &name, &message).await), Cmd::AgeVerifyName(name, message, sig_b64) => Ok(crate::age::cmd_age_verify_name(server, &name, &message, &sig_b64).await), Cmd::AgeList => Ok(crate::age::cmd_age_list(server).await), + + // SYM (symmetric): stateless (Phase 1) + Cmd::SymKeygen => Ok(crate::sym::cmd_sym_keygen().await), + Cmd::SymEncrypt(key_b64, message) => Ok(crate::sym::cmd_sym_encrypt(&key_b64, &message).await), + Cmd::SymDecrypt(key_b64, ct_b64) => Ok(crate::sym::cmd_sym_decrypt(&key_b64, &ct_b64).await), + Cmd::Unknow(s) => Ok(Protocol::err(&format!("ERR unknown command `{}`", s))), } } pub fn to_protocol(self) -> Protocol { match self { - Cmd::Select(db) => Protocol::Array(vec![Protocol::BulkString("select".to_string()), Protocol::BulkString(db.to_string())]), + Cmd::Select(db, key) => { + let mut arr = vec![Protocol::BulkString("select".to_string()), Protocol::BulkString(db.to_string())]; + if let Some(k) = key { + arr.push(Protocol::BulkString("key".to_string())); + arr.push(Protocol::BulkString(k)); + } + Protocol::Array(arr) + } Cmd::Ping => Protocol::Array(vec![Protocol::BulkString("ping".to_string())]), Cmd::Echo(s) => Protocol::Array(vec![Protocol::BulkString("echo".to_string()), Protocol::BulkString(s)]), Cmd::Get(k) => Protocol::Array(vec![Protocol::BulkString("get".to_string()), Protocol::BulkString(k)]), @@ -753,9 +797,65 @@ async fn flushdb_cmd(server: &mut Server) -> Result { } } -async fn select_cmd(server: &mut Server, db: u64) -> Result { - // Test if we can access the database (this will create it if needed) +async fn select_cmd(server: &mut Server, db: u64, key: Option) -> Result { + // Authorization and existence checks via admin DB 0 + // DB 0: require KEY admin-secret + if db == 0 { + match key { + Some(k) if k == server.option.admin_secret => { + server.selected_db = 0; + server.current_permissions = Some(crate::rpc::Permissions::ReadWrite); + // Will create encrypted 0.db if missing + match server.current_storage() { + Ok(_) => return Ok(Protocol::SimpleString("OK".to_string())), + Err(e) => return Ok(Protocol::err(&e.0)), + } + } + _ => { + return Ok(Protocol::err("ERR invalid access key")); + } + } + } + + // DB > 0: must exist in admin:dbs + let exists = match crate::admin_meta::db_exists( + &server.option.dir, + server.option.backend.clone(), + &server.option.admin_secret, + db, + ) { + Ok(b) => b, + Err(e) => return Ok(Protocol::err(&e.0)), + }; + + if !exists { + return Ok(Protocol::err(&format!( + "Cannot open database instance {}, as that database instance does not exist.", + db + ))); + } + + // Verify permissions (public => RW; private => use key) + let perms_opt = match crate::admin_meta::verify_access( + &server.option.dir, + server.option.backend.clone(), + &server.option.admin_secret, + db, + key.as_deref(), + ) { + Ok(p) => p, + Err(e) => return Ok(Protocol::err(&e.0)), + }; + + let perms = match perms_opt { + Some(p) => p, + None => return Ok(Protocol::err("ERR invalid access key")), + }; + + // Set selected database and permissions, then open storage server.selected_db = db; + server.current_permissions = Some(perms); + match server.current_storage() { Ok(_) => Ok(Protocol::SimpleString("OK".to_string())), Err(e) => Ok(Protocol::err(&e.0)), @@ -1003,6 +1103,9 @@ async fn brpop_cmd(server: &Server, keys: &[String], timeout_secs: f64) -> Resul } async fn lpush_cmd(server: &Server, key: &str, elements: &[String]) -> Result { + if !server.has_write_permission() { + return Ok(Protocol::err("ERR write permission denied")); + } match server.current_storage()?.lpush(key, elements.to_vec()) { Ok(len) => { // Attempt to deliver to any blocked BLPOP waiters @@ -1134,8 +1237,16 @@ async fn type_cmd(server: &Server, k: &String) -> Result { } async fn del_cmd(server: &Server, k: &str) -> Result { - server.current_storage()?.del(k.to_string())?; - Ok(Protocol::SimpleString("1".to_string())) + if !server.has_write_permission() { + return Ok(Protocol::err("ERR write permission denied")); + } + let storage = server.current_storage()?; + if storage.exists(k)? { + storage.del(k.to_string())?; + Ok(Protocol::SimpleString("1".to_string())) + } else { + Ok(Protocol::SimpleString("0".to_string())) + } } async fn set_ex_cmd( @@ -1159,6 +1270,9 @@ async fn set_px_cmd( } async fn set_cmd(server: &Server, k: &str, v: &str) -> Result { + if !server.has_write_permission() { + return Ok(Protocol::err("ERR write permission denied")); + } server.current_storage()?.set(k.to_string(), v.to_string())?; Ok(Protocol::SimpleString("OK".to_string())) } @@ -1243,6 +1357,9 @@ async fn mset_cmd(server: &Server, pairs: &[(String, String)]) -> Result Result { + if !server.has_write_permission() { + return Ok(Protocol::err("ERR write permission denied")); + } let storage = server.current_storage()?; let mut deleted = 0i64; for k in keys { @@ -1273,6 +1390,9 @@ async fn get_cmd(server: &Server, k: &str) -> Result { // Hash command implementations async fn hset_cmd(server: &Server, key: &str, pairs: &[(String, String)]) -> Result { + if !server.has_write_permission() { + return Ok(Protocol::err("ERR write permission denied")); + } let new_fields = server.current_storage()?.hset(key, pairs.to_vec())?; Ok(Protocol::SimpleString(new_fields.to_string())) } diff --git a/src/lib.rs b/src/lib.rs index 31e69a8..24a3208 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,14 @@ -pub mod age; // NEW +pub mod age; +pub mod sym; pub mod cmd; pub mod crypto; pub mod error; pub mod options; pub mod protocol; +pub mod rpc; +pub mod rpc_server; pub mod server; pub mod storage; -pub mod storage_trait; // Add this -pub mod storage_sled; // Add this +pub mod storage_trait; +pub mod storage_sled; +pub mod admin_meta; diff --git a/src/main.rs b/src/main.rs index dce569b..3a59b09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use tokio::net::TcpListener; use herodb::server; +use herodb::rpc_server; use clap::Parser; @@ -22,18 +23,29 @@ struct Args { #[arg(long)] debug: bool, - - /// Master encryption key for encrypted databases + /// Master encryption key for encrypted databases (deprecated; ignored for data DBs) #[arg(long)] encryption_key: Option, - /// Encrypt the database + /// Encrypt the database (deprecated; ignored for data DBs) #[arg(long)] encrypt: bool, + /// Enable RPC management server + #[arg(long)] + enable_rpc: bool, + + /// RPC server port (default: 8080) + #[arg(long, default_value = "8080")] + rpc_port: u16, + /// Use the sled backend #[arg(long)] sled: bool, + + /// Admin secret used to encrypt DB 0 and authorize admin access (required) + #[arg(long)] + admin_secret: String, } #[tokio::main] @@ -48,9 +60,19 @@ async fn main() { .await .unwrap(); + // deprecation warnings for legacy flags + if args.encrypt || args.encryption_key.is_some() { + eprintln!("warning: --encrypt and --encryption-key are deprecated and ignored for data DBs. Admin DB 0 is always encrypted with --admin-secret."); + } + // basic validation for admin secret + if args.admin_secret.trim().is_empty() { + eprintln!("error: --admin-secret must not be empty"); + std::process::exit(2); + } + // new DB option let option = herodb::options::DBOption { - dir: args.dir, + dir: args.dir.clone(), port, debug: args.debug, encryption_key: args.encryption_key, @@ -60,14 +82,42 @@ async fn main() { } else { herodb::options::BackendType::Redb }, + admin_secret: args.admin_secret.clone(), }; + let backend = option.backend.clone(); + + // Bootstrap admin DB 0 before opening any server storage + if let Err(e) = herodb::admin_meta::ensure_bootstrap(&args.dir, backend.clone(), &args.admin_secret) { + eprintln!("Failed to bootstrap admin DB 0: {}", e.0); + std::process::exit(2); + } + // new server let server = server::Server::new(option).await; // Add a small delay to ensure the port is ready tokio::time::sleep(std::time::Duration::from_millis(100)).await; + // Start RPC server if enabled + let _rpc_handle = if args.enable_rpc { + let rpc_addr = format!("127.0.0.1:{}", args.rpc_port).parse().unwrap(); + let base_dir = args.dir.clone(); + + match rpc_server::start_rpc_server(rpc_addr, base_dir, backend, args.admin_secret.clone()).await { + Ok(handle) => { + println!("RPC management server started on port {}", args.rpc_port); + Some(handle) + } + Err(e) => { + eprintln!("Failed to start RPC server: {}", e); + None + } + } + } else { + None + }; + // accept new connections loop { let stream = listener.accept().await; diff --git a/src/options.rs b/src/options.rs index 067183d..c819ca0 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BackendType { Redb, Sled, @@ -9,7 +9,11 @@ pub struct DBOption { pub dir: String, pub port: u16, pub debug: bool, + // Deprecated for data DBs; retained for backward-compat on CLI parsing pub encrypt: bool, + // Deprecated for data DBs; retained for backward-compat on CLI parsing pub encryption_key: Option, pub backend: BackendType, + // New: required admin secret, used to encrypt DB 0 and authorize admin operations + pub admin_secret: String, } diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..207f45f --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,472 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use jsonrpsee::{core::RpcResult, proc_macros::rpc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::server::Server; +use crate::options::DBOption; +use crate::admin_meta; + +/// Database backend types +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BackendType { + Redb, + Sled, + // Future: InMemory, Custom(String) +} + +/// Database configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseConfig { + pub name: Option, + pub storage_path: Option, + pub max_size: Option, + pub redis_version: Option, +} + +/// Database information returned by metadata queries +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseInfo { + pub id: u64, + pub name: Option, + pub backend: BackendType, + pub encrypted: bool, + pub redis_version: Option, + pub storage_path: Option, + pub size_on_disk: Option, + pub key_count: Option, + pub created_at: u64, + pub last_access: Option, +} + +/// Access permissions for database keys +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Permissions { + Read, + ReadWrite, +} + + + +/// Access key information returned by RPC +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccessKeyInfo { + pub hash: String, + pub permissions: Permissions, + pub created_at: u64, +} + +/// Hash a plaintext key using SHA-256 +pub fn hash_key(key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(key.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// RPC trait for HeroDB management +#[rpc(server, client, namespace = "herodb")] +pub trait Rpc { + /// Create a new database with specified configuration + #[method(name = "createDatabase")] + async fn create_database( + &self, + backend: BackendType, + config: DatabaseConfig, + encryption_key: Option, + ) -> RpcResult; + + /// Set encryption for an existing database (write-only key) + #[method(name = "setEncryption")] + async fn set_encryption(&self, db_id: u64, encryption_key: String) -> RpcResult; + + /// List all managed databases + #[method(name = "listDatabases")] + async fn list_databases(&self) -> RpcResult>; + + /// Get detailed information about a specific database + #[method(name = "getDatabaseInfo")] + async fn get_database_info(&self, db_id: u64) -> RpcResult; + + /// Delete a database + #[method(name = "deleteDatabase")] + async fn delete_database(&self, db_id: u64) -> RpcResult; + + /// Get server statistics + #[method(name = "getServerStats")] + async fn get_server_stats(&self) -> RpcResult>; + + /// Add an access key to a database + #[method(name = "addAccessKey")] + async fn add_access_key(&self, db_id: u64, key: String, permissions: String) -> RpcResult; + + /// Delete an access key from a database + #[method(name = "deleteAccessKey")] + async fn delete_access_key(&self, db_id: u64, key_hash: String) -> RpcResult; + + /// List all access keys for a database + #[method(name = "listAccessKeys")] + async fn list_access_keys(&self, db_id: u64) -> RpcResult>; + + /// Set database public/private status + #[method(name = "setDatabasePublic")] + async fn set_database_public(&self, db_id: u64, public: bool) -> RpcResult; +} + +/// RPC Server implementation +pub struct RpcServerImpl { + /// Base directory for database files + base_dir: String, + /// Managed database servers + servers: Arc>>>, + /// Default backend type + backend: crate::options::BackendType, + /// Admin secret used to encrypt DB 0 and authorize admin access + admin_secret: String, +} + +impl RpcServerImpl { + /// Create a new RPC server instance + pub fn new(base_dir: String, backend: crate::options::BackendType, admin_secret: String) -> Self { + Self { + base_dir, + servers: Arc::new(RwLock::new(HashMap::new())), + backend, + admin_secret, + } + } + + /// Get or create a server instance for the given database ID + async fn get_or_create_server(&self, db_id: u64) -> Result, jsonrpsee::types::ErrorObjectOwned> { + // Check if server already exists + { + let servers = self.servers.read().await; + if let Some(server) = servers.get(&db_id) { + return Ok(server.clone()); + } + } + + // Validate existence via admin DB 0 (metadata), not filesystem presence + let exists = admin_meta::db_exists(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + if !exists { + return Err(jsonrpsee::types::ErrorObjectOwned::owned( + -32000, + format!("Database {} not found", db_id), + None::<()> + )); + } + + // Resolve effective backend for this db from admin meta or filesystem; fallback to default + let meta_backend = admin_meta::get_database_backend(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id) + .ok() + .flatten(); + let db_path = std::path::PathBuf::from(&self.base_dir).join(format!("{}.db", db_id)); + let sniffed_backend = if db_path.exists() { + if db_path.is_file() { + Some(crate::options::BackendType::Redb) + } else if db_path.is_dir() { + Some(crate::options::BackendType::Sled) + } else { + None + } + } else { + None + }; + let effective_backend = meta_backend.clone().or(sniffed_backend).unwrap_or(self.backend.clone()); + if effective_backend != self.backend { + eprintln!( + "notice: get_or_create_server: db {} backend resolved to {:?} (server default {:?})", + db_id, effective_backend, self.backend + ); + } + // If we had to sniff (no meta), persist the resolved backend + if meta_backend.is_none() { + let _ = admin_meta::set_database_backend(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, effective_backend.clone()); + } + + // Create server instance with resolved backend + let db_option = DBOption { + dir: self.base_dir.clone(), + port: 0, // Not used for RPC-managed databases + debug: false, + encryption_key: None, + encrypt: false, + backend: effective_backend, + admin_secret: self.admin_secret.clone(), + }; + + let mut server = Server::new(db_option).await; + + // Set the selected database to the db_id + server.selected_db = db_id; + + // Lazily open/create physical storage according to admin meta (per-db encryption) + let _ = server.current_storage(); + + // Store the server + let mut servers = self.servers.write().await; + servers.insert(db_id, Arc::new(server.clone())); + + Ok(Arc::new(server)) + } + + /// Discover existing database IDs from admin DB 0 + async fn discover_databases(&self) -> Vec { + admin_meta::list_dbs(&self.base_dir, self.backend.clone(), &self.admin_secret) + .unwrap_or_default() + } + + + + + + + /// Build database file path for given server/db_id + fn db_file_path(&self, server: &Server, db_id: u64) -> std::path::PathBuf { + std::path::PathBuf::from(&server.option.dir).join(format!("{}.db", db_id)) + } + + /// Recursively compute size on disk for the database path + fn compute_size_on_disk(&self, path: &std::path::Path) -> Option { + fn dir_size(p: &std::path::Path) -> u64 { + if p.is_file() { + std::fs::metadata(p).map(|m| m.len()).unwrap_or(0) + } else if p.is_dir() { + let mut total = 0u64; + if let Ok(read) = std::fs::read_dir(p) { + for entry in read.flatten() { + total += dir_size(&entry.path()); + } + } + total + } else { + 0 + } + } + Some(dir_size(path)) + } + + /// Extract created and last access times (secs) from a path, with fallbacks + fn get_file_times_secs(path: &std::path::Path) -> (u64, Option) { + let now = std::time::SystemTime::now(); + let created = std::fs::metadata(path) + .and_then(|m| m.created().or_else(|_| m.modified())) + .unwrap_or(now) + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let last_access = std::fs::metadata(path) + .and_then(|m| m.accessed()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs())); + + (created, last_access) + } + + /// Compose a DatabaseInfo by probing storage and filesystem, with admin meta for access key count + async fn build_database_info(&self, db_id: u64, server: &Server) -> DatabaseInfo { + // Probe storage to determine encryption state + let storage = server.current_storage().ok(); + let encrypted = storage.as_ref().map(|s| s.is_encrypted()).unwrap_or(server.option.encrypt); + + // Get actual key count from storage + let key_count = storage.as_ref() + .and_then(|s| s.dbsize().ok()) + .map(|count| count as u64); + + // Get database name from admin meta + let name = admin_meta::get_database_name(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id) + .ok() + .flatten(); + + // Compute size on disk and timestamps from the DB file path + let db_path = self.db_file_path(server, db_id); + let size_on_disk = self.compute_size_on_disk(&db_path); + let (created_at, last_access) = Self::get_file_times_secs(&db_path); + + let backend = match server.option.backend { + crate::options::BackendType::Redb => BackendType::Redb, + crate::options::BackendType::Sled => BackendType::Sled, + }; + + DatabaseInfo { + id: db_id, + name, + backend, + encrypted, + redis_version: Some("7.0".to_string()), + storage_path: Some(server.option.dir.clone()), + size_on_disk, + key_count, + created_at, + last_access, + } + } +} + +#[jsonrpsee::core::async_trait] +impl RpcServer for RpcServerImpl { + async fn create_database( + &self, + backend: BackendType, + config: DatabaseConfig, + encryption_key: Option, + ) -> RpcResult { + // Allocate new ID via admin DB 0 + let db_id = admin_meta::allocate_next_id(&self.base_dir, self.backend.clone(), &self.admin_secret) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + + // Persist per-db encryption key in admin DB 0 if provided + if let Some(ref key) = encryption_key { + admin_meta::set_enc_key(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, key) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + } + + // Persist database name if provided + if let Some(ref name) = config.name { + admin_meta::set_database_name(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, name) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + } + + // Ensure base dir exists + if let Err(e) = std::fs::create_dir_all(&self.base_dir) { + return Err(jsonrpsee::types::ErrorObjectOwned::owned(-32000, format!("Failed to ensure base dir: {}", e), None::<()>)); + } + + // Map RPC backend to options backend and persist it in admin meta for this db id + let opt_backend = match backend { + BackendType::Redb => crate::options::BackendType::Redb, + BackendType::Sled => crate::options::BackendType::Sled, + }; + admin_meta::set_database_backend(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, opt_backend.clone()) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + + // Create server instance using base_dir, chosen backend and admin secret + let option = DBOption { + dir: self.base_dir.clone(), + port: 0, // Not used for RPC-managed databases + debug: false, + encryption_key: None, // per-db key is stored in admin DB 0 + encrypt: false, // encryption decided per-db at open time + backend: opt_backend, + admin_secret: self.admin_secret.clone(), + }; + + let mut server = Server::new(option).await; + server.selected_db = db_id; + + // Initialize storage to create physical .db with proper encryption from admin meta + let _ = server.current_storage(); + + // Store the server in cache + let mut servers = self.servers.write().await; + servers.insert(db_id, Arc::new(server)); + + Ok(db_id) + } + + async fn set_encryption(&self, _db_id: u64, _encryption_key: String) -> RpcResult { + // For now, return false as encryption can only be set during creation + let _servers = self.servers.read().await; + // TODO: Implement encryption setting for existing databases + Ok(false) + } + + async fn list_databases(&self) -> RpcResult> { + let db_ids = self.discover_databases().await; + let mut result = Vec::new(); + + for db_id in db_ids { + if let Ok(server) = self.get_or_create_server(db_id).await { + // Build accurate info from storage/meta/fs + let info = self.build_database_info(db_id, &server).await; + result.push(info); + } + } + + Ok(result) + } + + async fn get_database_info(&self, db_id: u64) -> RpcResult { + let server = self.get_or_create_server(db_id).await?; + // Build accurate info from storage/meta/fs + let info = self.build_database_info(db_id, &server).await; + Ok(info) + } + + async fn delete_database(&self, db_id: u64) -> RpcResult { + let mut servers = self.servers.write().await; + + if let Some(_server) = servers.remove(&db_id) { + // Clean up database files + let db_path = std::path::PathBuf::from(&self.base_dir).join(format!("{}.db", db_id)); + if db_path.exists() { + if db_path.is_dir() { + std::fs::remove_dir_all(&db_path).ok(); + } else { + std::fs::remove_file(&db_path).ok(); + } + } + Ok(true) + } else { + Ok(false) + } + } + + async fn get_server_stats(&self) -> RpcResult> { + let db_ids = self.discover_databases().await; + let mut stats = HashMap::new(); + + stats.insert("total_databases".to_string(), serde_json::json!(db_ids.len())); + stats.insert("uptime".to_string(), serde_json::json!( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + )); + + Ok(stats) + } + + async fn add_access_key(&self, db_id: u64, key: String, permissions: String) -> RpcResult { + let perms = match permissions.to_lowercase().as_str() { + "read" => Permissions::Read, + "readwrite" => Permissions::ReadWrite, + _ => return Err(jsonrpsee::types::ErrorObjectOwned::owned( + -32000, + "Invalid permissions: use 'read' or 'readwrite'", + None::<()> + )), + }; + + admin_meta::add_access_key(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, &key, perms) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + Ok(true) + } + + async fn delete_access_key(&self, db_id: u64, key_hash: String) -> RpcResult { + let ok = admin_meta::delete_access_key(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, &key_hash) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + Ok(ok) + } + + async fn list_access_keys(&self, db_id: u64) -> RpcResult> { + let pairs = admin_meta::list_access_keys(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + let keys: Vec = pairs.into_iter().map(|(hash, perm, ts)| AccessKeyInfo { + hash, + permissions: perm, + created_at: ts, + }).collect(); + Ok(keys) + } + + async fn set_database_public(&self, db_id: u64, public: bool) -> RpcResult { + admin_meta::set_database_public(&self.base_dir, self.backend.clone(), &self.admin_secret, db_id, public) + .map_err(|e| jsonrpsee::types::ErrorObjectOwned::owned(-32000, e.0, None::<()>))?; + Ok(true) + } +} \ No newline at end of file diff --git a/src/rpc_server.rs b/src/rpc_server.rs new file mode 100644 index 0000000..eaabc5b --- /dev/null +++ b/src/rpc_server.rs @@ -0,0 +1,49 @@ +use std::net::SocketAddr; +use jsonrpsee::server::{ServerBuilder, ServerHandle}; +use jsonrpsee::RpcModule; + +use crate::rpc::{RpcServer, RpcServerImpl}; + +/// Start the RPC server on the specified address +pub async fn start_rpc_server(addr: SocketAddr, base_dir: String, backend: crate::options::BackendType, admin_secret: String) -> Result> { + // Create the RPC server implementation + let rpc_impl = RpcServerImpl::new(base_dir, backend, admin_secret); + + // Create the RPC module + let mut module = RpcModule::new(()); + module.merge(RpcServer::into_rpc(rpc_impl))?; + + // Build the server with both HTTP and WebSocket support + let server = ServerBuilder::default() + .build(addr) + .await?; + + // Start the server + let handle = server.start(module); + + println!("RPC server started on {}", addr); + + Ok(handle) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test] + async fn test_rpc_server_startup() { + let addr = "127.0.0.1:0".parse().unwrap(); // Use port 0 for auto-assignment + let base_dir = "/tmp/test_rpc".to_string(); + let backend = crate::options::BackendType::Redb; // Default for test + + let handle = start_rpc_server(addr, base_dir, backend, "test-admin".to_string()).await.unwrap(); + + // Give the server a moment to start + tokio::time::sleep(Duration::from_millis(100)).await; + + // Stop the server + handle.stop().unwrap(); + handle.stopped().await; + } +} \ No newline at end of file diff --git a/src/server.rs b/src/server.rs index a6e43e2..f02f065 100644 --- a/src/server.rs +++ b/src/server.rs @@ -11,9 +11,8 @@ use crate::cmd::Cmd; use crate::error::DBError; use crate::options; use crate::protocol::Protocol; -use crate::storage::Storage; -use crate::storage_sled::SledStorage; use crate::storage_trait::StorageBackend; +use crate::admin_meta; #[derive(Clone)] pub struct Server { @@ -22,6 +21,7 @@ pub struct Server { pub client_name: Option, pub selected_db: u64, // Changed from usize to u64 pub queued_cmd: Option>, + pub current_permissions: Option, // BLPOP waiter registry: per (db_index, key) FIFO of waiters pub list_waiters: Arc>>>>, @@ -48,6 +48,7 @@ impl Server { client_name: None, selected_db: 0, queued_cmd: None, + current_permissions: None, list_waiters: Arc::new(Mutex::new(HashMap::new())), waiter_seq: Arc::new(AtomicU64::new(1)), @@ -56,49 +57,42 @@ impl Server { pub fn current_storage(&self) -> Result, DBError> { let mut cache = self.db_cache.write().unwrap(); - + if let Some(storage) = cache.get(&self.selected_db) { return Ok(storage.clone()); } - - - // Create new database file - let db_file_path = std::path::PathBuf::from(self.option.dir.clone()) - .join(format!("{}.db", self.selected_db)); - - // Ensure the directory exists before creating the database file - if let Some(parent_dir) = db_file_path.parent() { - std::fs::create_dir_all(parent_dir).map_err(|e| { - DBError(format!("Failed to create directory {}: {}", parent_dir.display(), e)) - })?; - } - - println!("Creating new db file: {}", db_file_path.display()); - - let storage: Arc = match self.option.backend { - options::BackendType::Redb => { - Arc::new(Storage::new( - db_file_path, - self.should_encrypt_db(self.selected_db), - self.option.encryption_key.as_deref() - )?) - } - options::BackendType::Sled => { - Arc::new(SledStorage::new( - db_file_path, - self.should_encrypt_db(self.selected_db), - self.option.encryption_key.as_deref() - )?) - } + + // Use process-wide shared handles to avoid sled/reDB double-open lock contention. + let storage = if self.selected_db == 0 { + // Admin DB 0: always via singleton + admin_meta::open_admin_storage( + &self.option.dir, + self.option.backend.clone(), + &self.option.admin_secret, + )? + } else { + // Data DBs: via global registry keyed by id + admin_meta::open_data_storage( + &self.option.dir, + self.option.backend.clone(), + &self.option.admin_secret, + self.selected_db, + )? }; - + cache.insert(self.selected_db, storage.clone()); Ok(storage) } - fn should_encrypt_db(&self, db_index: u64) -> bool { - // DB 0-9 are non-encrypted, DB 10+ are encrypted - self.option.encrypt && db_index >= 10 + + /// Check if current permissions allow read operations + pub fn has_read_permission(&self) -> bool { + matches!(self.current_permissions, Some(crate::rpc::Permissions::Read) | Some(crate::rpc::Permissions::ReadWrite)) + } + + /// Check if current permissions allow write operations + pub fn has_write_permission(&self) -> bool { + matches!(self.current_permissions, Some(crate::rpc::Permissions::ReadWrite)) } // ----- BLPOP waiter helpers ----- diff --git a/src/sym.rs b/src/sym.rs new file mode 100644 index 0000000..a4e0d5a --- /dev/null +++ b/src/sym.rs @@ -0,0 +1,123 @@ +//! sym.rs — Stateless symmetric encryption (Phase 1) +//! +//! Commands implemented (RESP): +//! - SYM KEYGEN +//! - SYM ENCRYPT +//! - SYM DECRYPT +//! +//! Notes: +//! - Raw key: exactly 32 bytes, provided as Base64 in commands. +//! - Cipher: XChaCha20-Poly1305 (AEAD) without AAD in Phase 1 +//! - Ciphertext binary layout: [version:1][nonce:24][ciphertext||tag] +//! - Encoding for wire I/O: Base64 + +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use chacha20poly1305::{ + aead::{Aead, KeyInit, OsRng}, + XChaCha20Poly1305, XNonce, +}; +use rand::RngCore; + +use crate::protocol::Protocol; + +const VERSION: u8 = 1; +const NONCE_LEN: usize = 24; +const TAG_LEN: usize = 16; + +#[derive(Debug)] +pub enum SymWireError { + InvalidKey, + BadEncoding, + BadFormat, + BadVersion(u8), + Crypto, +} + +impl SymWireError { + fn to_protocol(self) -> Protocol { + match self { + SymWireError::InvalidKey => Protocol::err("ERR sym: invalid key"), + SymWireError::BadEncoding => Protocol::err("ERR sym: bad encoding"), + SymWireError::BadFormat => Protocol::err("ERR sym: bad format"), + SymWireError::BadVersion(v) => Protocol::err(&format!("ERR sym: unsupported version {}", v)), + SymWireError::Crypto => Protocol::err("ERR sym: auth failed"), + } + } +} + +fn decode_key_b64(s: &str) -> Result { + let bytes = B64.decode(s.as_bytes()).map_err(|_| SymWireError::BadEncoding)?; + if bytes.len() != 32 { + return Err(SymWireError::InvalidKey); + } + Ok(chacha20poly1305::Key::from_slice(&bytes).to_owned()) +} + +fn encrypt_blob(key: &chacha20poly1305::Key, plaintext: &[u8]) -> Result, SymWireError> { + let cipher = XChaCha20Poly1305::new(key); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = XNonce::from_slice(&nonce_bytes); + + let mut out = Vec::with_capacity(1 + NONCE_LEN + plaintext.len() + TAG_LEN); + out.push(VERSION); + out.extend_from_slice(&nonce_bytes); + + let ct = cipher.encrypt(nonce, plaintext).map_err(|_| SymWireError::Crypto)?; + out.extend_from_slice(&ct); + Ok(out) +} + +fn decrypt_blob(key: &chacha20poly1305::Key, blob: &[u8]) -> Result, SymWireError> { + if blob.len() < 1 + NONCE_LEN + TAG_LEN { + return Err(SymWireError::BadFormat); + } + let ver = blob[0]; + if ver != VERSION { + return Err(SymWireError::BadVersion(ver)); + } + let nonce = XNonce::from_slice(&blob[1..1 + NONCE_LEN]); + let ct = &blob[1 + NONCE_LEN..]; + + let cipher = XChaCha20Poly1305::new(key); + cipher.decrypt(nonce, ct).map_err(|_| SymWireError::Crypto) +} + +// ---------- Command handlers (RESP) ---------- + +pub async fn cmd_sym_keygen() -> Protocol { + let mut key_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut key_bytes); + let key_b64 = B64.encode(key_bytes); + Protocol::BulkString(key_b64) +} + +pub async fn cmd_sym_encrypt(key_b64: &str, message: &str) -> Protocol { + let key = match decode_key_b64(key_b64) { + Ok(k) => k, + Err(e) => return e.to_protocol(), + }; + match encrypt_blob(&key, message.as_bytes()) { + Ok(blob) => Protocol::BulkString(B64.encode(blob)), + Err(e) => e.to_protocol(), + } +} + +pub async fn cmd_sym_decrypt(key_b64: &str, ct_b64: &str) -> Protocol { + let key = match decode_key_b64(key_b64) { + Ok(k) => k, + Err(e) => return e.to_protocol(), + }; + let blob = match B64.decode(ct_b64.as_bytes()) { + Ok(b) => b, + Err(_) => return SymWireError::BadEncoding.to_protocol(), + }; + match decrypt_blob(&key, &blob) { + Ok(pt) => match String::from_utf8(pt) { + Ok(s) => Protocol::BulkString(s), + Err(_) => Protocol::err("ERR sym: invalid UTF-8 plaintext"), + }, + Err(e) => e.to_protocol(), + } +} \ No newline at end of file diff --git a/tests/debug_hset.rs b/tests/debug_hset.rs index 7930be8..44cd39b 100644 --- a/tests/debug_hset.rs +++ b/tests/debug_hset.rs @@ -28,6 +28,7 @@ async fn debug_hset_simple() { encrypt: false, encryption_key: None, backend: herodb::options::BackendType::Redb, + admin_secret: "test-admin".to_string(), }; let mut server = Server::new(option).await; @@ -48,6 +49,12 @@ async fn debug_hset_simple() { sleep(Duration::from_millis(200)).await; let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap(); + // Acquire ReadWrite permissions on this connection + let resp = send_command( + &mut stream, + "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n", + ).await; + assert!(resp.contains("OK"), "Failed SELECT handshake: {}", resp); // Test simple HSET println!("Testing HSET..."); diff --git a/tests/debug_hset_simple.rs b/tests/debug_hset_simple.rs index 356e704..fe99f0f 100644 --- a/tests/debug_hset_simple.rs +++ b/tests/debug_hset_simple.rs @@ -19,6 +19,7 @@ async fn debug_hset_return_value() { encrypt: false, encryption_key: None, backend: herodb::options::BackendType::Redb, + admin_secret: "test-admin".to_string(), }; let mut server = Server::new(option).await; @@ -40,12 +41,19 @@ async fn debug_hset_return_value() { // Connect and test HSET let mut stream = TcpStream::connect("127.0.0.1:16390").await.unwrap(); + + // Acquire ReadWrite permissions for this new connection + let handshake = "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n"; + stream.write_all(handshake.as_bytes()).await.unwrap(); + let mut buffer = [0; 1024]; + let n = stream.read(&mut buffer).await.unwrap(); + let resp = String::from_utf8_lossy(&buffer[..n]); + assert!(resp.contains("OK"), "Failed SELECT handshake: {}", resp); // Send HSET command let cmd = "*4\r\n$4\r\nHSET\r\n$4\r\nhash\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n"; stream.write_all(cmd.as_bytes()).await.unwrap(); - let mut buffer = [0; 1024]; let n = stream.read(&mut buffer).await.unwrap(); let response = String::from_utf8_lossy(&buffer[..n]); diff --git a/tests/redis_integration_tests.rs b/tests/redis_integration_tests.rs index 47033e1..a017780 100644 --- a/tests/redis_integration_tests.rs +++ b/tests/redis_integration_tests.rs @@ -12,7 +12,15 @@ fn get_redis_connection(port: u16) -> Connection { match client.get_connection() { Ok(mut conn) => { if redis::cmd("PING").query::(&mut conn).is_ok() { - return conn; + // Acquire ReadWrite permissions on this connection + let sel: RedisResult = redis::cmd("SELECT") + .arg(0) + .arg("KEY") + .arg("test-admin") + .query(&mut conn); + if sel.is_ok() { + return conn; + } } } Err(e) => { @@ -78,6 +86,8 @@ fn setup_server() -> (ServerProcessGuard, u16) { "--port", &port.to_string(), "--debug", + "--admin-secret", + "test-admin", ]) .spawn() .expect("Failed to start server process"); diff --git a/tests/redis_tests.rs b/tests/redis_tests.rs index f6e8a13..724704c 100644 --- a/tests/redis_tests.rs +++ b/tests/redis_tests.rs @@ -23,18 +23,29 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { encrypt: false, encryption_key: None, backend: herodb::options::BackendType::Redb, + admin_secret: "test-admin".to_string(), }; let server = Server::new(option).await; (server, port) } -// Helper function to connect to the test server + // Helper function to connect to the test server async fn connect_to_server(port: u16) -> TcpStream { let mut attempts = 0; loop { match TcpStream::connect(format!("127.0.0.1:{}", port)).await { - Ok(stream) => return stream, + Ok(mut stream) => { + // Obtain ReadWrite permissions for this connection by selecting DB 0 with admin key + let resp = send_command( + &mut stream, + "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n", + ).await; + if !resp.contains("OK") { + panic!("Failed to acquire write permissions via SELECT 0 KEY test-admin: {}", resp); + } + return stream; + } Err(_) if attempts < 10 => { attempts += 1; sleep(Duration::from_millis(100)).await; diff --git a/tests/rpc_tests.rs b/tests/rpc_tests.rs new file mode 100644 index 0000000..8f8e6ce --- /dev/null +++ b/tests/rpc_tests.rs @@ -0,0 +1,85 @@ +use herodb::rpc::{BackendType, DatabaseConfig}; +use herodb::admin_meta; +use herodb::options::BackendType as OptionsBackendType; + +#[tokio::test] +async fn test_rpc_server_basic() { + // This test would require starting the RPC server in a separate thread + // For now, we'll just test that the types compile correctly + + // Test serialization of types + let backend = BackendType::Redb; + let config = DatabaseConfig { + name: Some("test_db".to_string()), + storage_path: Some("/tmp/test".to_string()), + max_size: Some(1024 * 1024), + redis_version: Some("7.0".to_string()), + }; + + let backend_json = serde_json::to_string(&backend).unwrap(); + let config_json = serde_json::to_string(&config).unwrap(); + + assert_eq!(backend_json, "\"Redb\""); + assert!(config_json.contains("test_db")); +} + +#[tokio::test] +async fn test_database_config_serialization() { + let config = DatabaseConfig { + name: Some("my_db".to_string()), + storage_path: None, + max_size: Some(1000000), + redis_version: Some("7.0".to_string()), + }; + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["name"], "my_db"); + assert_eq!(json["max_size"], 1000000); + assert_eq!(json["redis_version"], "7.0"); +} + +#[tokio::test] +async fn test_backend_type_serialization() { + // Test that both Redb and Sled backends serialize correctly + let redb_backend = BackendType::Redb; + let sled_backend = BackendType::Sled; + + let redb_json = serde_json::to_string(&redb_backend).unwrap(); + let sled_json = serde_json::to_string(&sled_backend).unwrap(); + + assert_eq!(redb_json, "\"Redb\""); + assert_eq!(sled_json, "\"Sled\""); + + // Test deserialization + let redb_deserialized: BackendType = serde_json::from_str(&redb_json).unwrap(); + let sled_deserialized: BackendType = serde_json::from_str(&sled_json).unwrap(); + + assert!(matches!(redb_deserialized, BackendType::Redb)); + assert!(matches!(sled_deserialized, BackendType::Sled)); +} + +#[tokio::test] +async fn test_database_name_persistence() { + let base_dir = "/tmp/test_db_name_persistence"; + let admin_secret = "test-admin-secret"; + let backend = OptionsBackendType::Redb; + let db_id = 1; + let test_name = "test-database-name"; + + // Clean up any existing test data + let _ = std::fs::remove_dir_all(base_dir); + + // Set the database name + admin_meta::set_database_name(base_dir, backend.clone(), admin_secret, db_id, test_name) + .expect("Failed to set database name"); + + // Retrieve the database name + let retrieved_name = admin_meta::get_database_name(base_dir, backend, admin_secret, db_id) + .expect("Failed to get database name"); + + // Verify the name matches + assert_eq!(retrieved_name, Some(test_name.to_string())); + + // Clean up + let _ = std::fs::remove_dir_all(base_dir); +} diff --git a/tests/simple_integration_test.rs b/tests/simple_integration_test.rs index 42269df..706c9cb 100644 --- a/tests/simple_integration_test.rs +++ b/tests/simple_integration_test.rs @@ -25,6 +25,7 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { encrypt: false, encryption_key: None, backend: herodb::options::BackendType::Redb, + admin_secret: "test-admin".to_string(), }; let server = Server::new(option).await; @@ -34,9 +35,16 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { // Helper function to send Redis command and get response async fn send_redis_command(port: u16, command: &str) -> String { let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap(); + + // Acquire ReadWrite permissions on this new connection + let handshake = "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n"; + stream.write_all(handshake.as_bytes()).await.unwrap(); + let mut buffer = [0; 1024]; + let _ = stream.read(&mut buffer).await.unwrap(); // Read and ignore the OK for handshake + + // Now send the intended command stream.write_all(command.as_bytes()).await.unwrap(); - let mut buffer = [0; 1024]; let n = stream.read(&mut buffer).await.unwrap(); String::from_utf8_lossy(&buffer[..n]).to_string() } @@ -184,12 +192,19 @@ async fn test_transaction_operations() { sleep(Duration::from_millis(100)).await; - // Use a single connection for the transaction + // Use a single connection for the transaction let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await.unwrap(); + // Acquire write permissions for this connection + let handshake = "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n"; + stream.write_all(handshake.as_bytes()).await.unwrap(); + let mut buffer = [0; 1024]; + let n = stream.read(&mut buffer).await.unwrap(); + let resp = String::from_utf8_lossy(&buffer[..n]); + assert!(resp.contains("OK")); + // Test MULTI stream.write_all("*1\r\n$5\r\nMULTI\r\n".as_bytes()).await.unwrap(); - let mut buffer = [0; 1024]; let n = stream.read(&mut buffer).await.unwrap(); let response = String::from_utf8_lossy(&buffer[..n]); assert!(response.contains("OK")); diff --git a/tests/simple_redis_test.rs b/tests/simple_redis_test.rs index 8afb304..cd8c0a7 100644 --- a/tests/simple_redis_test.rs +++ b/tests/simple_redis_test.rs @@ -23,6 +23,7 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { encrypt: false, encryption_key: None, backend: herodb::options::BackendType::Redb, + admin_secret: "test-admin".to_string(), }; let server = Server::new(option).await; @@ -38,12 +39,22 @@ async fn send_command(stream: &mut TcpStream, command: &str) -> String { String::from_utf8_lossy(&buffer[..n]).to_string() } -// Helper function to connect to the test server + // Helper function to connect to the test server async fn connect_to_server(port: u16) -> TcpStream { let mut attempts = 0; loop { match TcpStream::connect(format!("127.0.0.1:{}", port)).await { - Ok(stream) => return stream, + Ok(mut stream) => { + // Acquire ReadWrite permissions for this connection + let resp = send_command( + &mut stream, + "*4\r\n$6\r\nSELECT\r\n$1\r\n0\r\n$3\r\nKEY\r\n$10\r\ntest-admin\r\n", + ).await; + if !resp.contains("OK") { + panic!("Failed to acquire write permissions via SELECT 0 KEY test-admin: {}", resp); + } + return stream; + } Err(_) if attempts < 10 => { attempts += 1; sleep(Duration::from_millis(100)).await; @@ -97,14 +108,21 @@ async fn test_hset_clean_db() { sleep(Duration::from_millis(200)).await; let mut stream = connect_to_server(port).await; - - // Test HSET - should return 1 for new field - let response = send_command(&mut stream, "*4\r\n$4\r\nHSET\r\n$4\r\nhash\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n").await; + + // Ensure clean DB state (admin DB 0 may be shared due to global singleton) + let flush = send_command(&mut stream, "*1\r\n$7\r\nFLUSHDB\r\n").await; + assert!(flush.contains("OK"), "Failed to FLUSHDB: {}", flush); + + // Test HSET - should return 1 for new field (use a unique key name to avoid collisions) + let key = "hash_clean"; + let hset_cmd = format!("*4\r\n$4\r\nHSET\r\n${}\r\n{}\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n", key.len(), key); + let response = send_command(&mut stream, &hset_cmd).await; println!("HSET response: {}", response); assert!(response.contains("1"), "Expected HSET to return 1, got: {}", response); // Test HGET - let response = send_command(&mut stream, "*3\r\n$4\r\nHGET\r\n$4\r\nhash\r\n$6\r\nfield1\r\n").await; + let hget_cmd = format!("*3\r\n$4\r\nHGET\r\n${}\r\n{}\r\n$6\r\nfield1\r\n", key.len(), key); + let response = send_command(&mut stream, &hget_cmd).await; println!("HGET response: {}", response); assert!(response.contains("value1")); } diff --git a/tests/usage_suite.rs b/tests/usage_suite.rs index 9a1af17..3754906 100644 --- a/tests/usage_suite.rs +++ b/tests/usage_suite.rs @@ -23,6 +23,7 @@ async fn start_test_server(test_name: &str) -> (Server, u16) { encrypt: false, encryption_key: None, backend: herodb::options::BackendType::Redb, + admin_secret: "test-admin".to_string(), }; let server = Server::new(option).await; @@ -61,7 +62,17 @@ async fn connect(port: u16) -> TcpStream { let mut attempts = 0; loop { match TcpStream::connect(format!("127.0.0.1:{}", port)).await { - Ok(s) => return s, + Ok(mut s) => { + // Acquire ReadWrite permissions for this connection using admin DB 0 + let resp = send_cmd(&mut s, &["SELECT", "0", "KEY", "test-admin"]).await; + assert_contains(&resp, "OK", "SELECT 0 KEY test-admin handshake"); + + // Ensure clean slate per test on DB 0 + let fl = send_cmd(&mut s, &["FLUSHDB"]).await; + assert_contains(&fl, "OK", "FLUSHDB after handshake"); + + return s; + } Err(_) if attempts < 30 => { attempts += 1; sleep(Duration::from_millis(100)).await; @@ -246,9 +257,9 @@ async fn test_01_connection_and_info() { let getname = send_cmd(&mut s, &["CLIENT", "GETNAME"]).await; assert_contains(&getname, "myapp", "CLIENT GETNAME"); - // SELECT db - let sel = send_cmd(&mut s, &["SELECT", "0"]).await; - assert_contains(&sel, "OK", "SELECT 0"); + // SELECT db (requires key on DB 0) + let sel = send_cmd(&mut s, &["SELECT", "0", "KEY", "test-admin"]).await; + assert_contains(&sel, "OK", "SELECT 0 with key"); // QUIT should close connection after sending OK let quit = send_cmd(&mut s, &["QUIT"]).await; @@ -279,7 +290,11 @@ async fn test_02_strings_and_expiry() { let ex0 = send_cmd(&mut s, &["EXISTS", "user:1"]).await; assert_contains(&ex0, "0", "EXISTS after DEL"); - + + // DEL non-existent should return 0 + let del0 = send_cmd(&mut s, &["DEL", "user:1"]).await; + assert_contains(&del0, "0", "DEL user:1 when not exists -> 0"); + // INCR behavior let i1 = send_cmd(&mut s, &["INCR", "count"]).await; assert_contains(&i1, "1", "INCR new key -> 1"); @@ -591,7 +606,7 @@ async fn test_08_age_persistent_named_suite() { // AGE LIST let lst = send_cmd(&mut s, &["AGE", "LIST"]).await; - assert_contains(&lst, "encpub", "AGE LIST label encpub"); + // After flattening, LIST returns a flat array of managed key names assert_contains(&lst, "app1", "AGE LIST includes app1"); }