diff --git a/Cargo.lock b/Cargo.lock index 29546f0..bdd36ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,12 +162,24 @@ dependencies = [ "syn", ] +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -268,6 +280,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cast" version = "0.3.0" @@ -411,6 +429,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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" @@ -441,6 +479,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -538,6 +588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -584,6 +635,20 @@ dependencies = [ "syn", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -615,6 +680,34 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -683,6 +776,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -725,12 +828,33 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -740,12 +864,34 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -759,7 +905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -800,6 +949,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -850,6 +1000,36 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -908,6 +1088,122 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "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 = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1078,6 +1374,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1126,6 +1438,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1199,6 +1514,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minicov" version = "0.3.8" @@ -1219,6 +1540,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -1229,6 +1561,23 @@ dependencies = [ "pxfm", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1244,12 +1593,48 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1357,12 +1742,94 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1397,6 +1864,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1409,6 +1885,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -1519,6 +2006,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1670,13 +2166,13 @@ dependencies = [ "clap_complete", "data-encoding", "dirs", - "ed25519-dalek", "hex", "image", "predicates", "qrcode", "rand", "relicario-core", + "reqwest", "rpassword", "rqrr", "serde", @@ -1707,6 +2203,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "ssh-key", "tar", "thiserror 2.0.18", "unicode-normalization", @@ -1716,6 +2213,21 @@ dependencies = [ "zxcvbn", ] +[[package]] +name = "relicario-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "predicates", + "regex", + "relicario-core", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "relicario-wasm" version = "0.2.0" @@ -1725,6 +2237,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "image", + "once_cell", "rand", "relicario-core", "serde", @@ -1735,6 +2248,72 @@ dependencies = [ "zeroize", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rpassword" version = "7.4.0" @@ -1757,6 +2336,27 @@ dependencies = [ "lru", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -1789,6 +2389,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1810,12 +2443,58 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1876,6 +2555,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1910,6 +2601,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core", ] @@ -1931,6 +2623,22 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" @@ -1941,6 +2649,48 @@ dependencies = [ "der", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1970,6 +2720,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1981,6 +2740,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tar" version = "0.4.45" @@ -2108,6 +2888,123 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +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 = "typenum" version = "1.19.0" @@ -2145,6 +3042,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.8" @@ -2170,6 +3073,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2195,6 +3104,15 @@ 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" @@ -2413,6 +3331,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2440,6 +3369,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[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" diff --git a/crates/relicario-cli/src/device.rs b/crates/relicario-cli/src/device.rs index 6ec92e5..25cf775 100644 --- a/crates/relicario-cli/src/device.rs +++ b/crates/relicario-cli/src/device.rs @@ -91,6 +91,7 @@ pub fn store_device_keys( } /// Load the signing private key for a device. +#[allow(dead_code)] pub fn load_signing_key(name: &str) -> Result> { let path = device_dir(name)?.join("signing.key"); let key = fs::read_to_string(&path) @@ -99,6 +100,7 @@ pub fn load_signing_key(name: &str) -> Result> { } /// Load the deploy private key for a device. +#[allow(dead_code)] pub fn load_deploy_key(name: &str) -> Result> { let path = device_dir(name)?.join("deploy.key"); let key = fs::read_to_string(&path) @@ -115,6 +117,7 @@ pub fn load_gitea_key_id(name: &str) -> Result { } /// Delete the local key directory for a device. +#[allow(dead_code)] pub fn delete_device_keys(name: &str) -> Result<()> { let dir = device_dir(name)?; if dir.exists() { diff --git a/crates/relicario-cli/src/gitea.rs b/crates/relicario-cli/src/gitea.rs index 29173b4..2c26993 100644 --- a/crates/relicario-cli/src/gitea.rs +++ b/crates/relicario-cli/src/gitea.rs @@ -21,7 +21,9 @@ struct CreateKeyRequest<'a> { #[derive(Debug, Deserialize)] pub struct DeployKey { pub id: u64, + #[allow(dead_code)] pub title: String, + #[allow(dead_code)] pub key: String, } @@ -89,6 +91,7 @@ impl GiteaClient { } /// List all deploy keys. + #[allow(dead_code)] pub fn list_deploy_keys(&self) -> Result> { let url = format!( "{}/repos/{}/{}/keys", diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index 5bc36a0..d3d373d 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -34,6 +34,7 @@ pub fn vault_dir() -> Result { } /// Path to the `.relicario/` configuration directory within the vault. +#[allow(dead_code)] pub fn relicario_dir() -> Result { Ok(vault_dir()?.join(".relicario")) } @@ -88,19 +89,21 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } } /// /// **Plaintext leak:** group names land on disk in cleartext alongside the /// vault directory. This is intentional — the file feeds shell completion, -/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1` -/// to suppress the write. +/// which cannot prompt for a passphrase. In debug builds, set +/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write. pub fn groups_cache_path(vault_dir: &Path) -> PathBuf { vault_dir.join(".relicario").join("groups.cache") } /// Write the sorted set of group names to `/.relicario/groups.cache`, -/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set. +/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE` +/// suppresses the write (developer debugging tool). In release builds the env +/// var is ignored. pub fn write_groups_cache( vault_dir: &Path, groups: &std::collections::BTreeSet, ) -> std::io::Result<()> { - if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { + if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { return Ok(()); } let path = groups_cache_path(vault_dir); diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index cf44257..6cb1d05 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -170,7 +170,7 @@ enum Commands { /// /// For `--group ` autocomplete, the bash/zsh/fish scripts read /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file, - /// which the CLI refreshes on every manifest read. Set + /// which the CLI refreshes on every manifest read. In debug builds, set /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion /// will fall back to no value enumeration). /// @@ -540,7 +540,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { }; let carrier = fs::read(&image) .with_context(|| format!("failed to read carrier image {}", image.display()))?; - let stego = imgsecret::embed(&carrier, &*image_secret)?; + let stego = imgsecret::embed(&carrier, &image_secret)?; fs::write(&output, &stego) .with_context(|| format!("failed to write reference image {}", output.display()))?; @@ -550,7 +550,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; // Derive master key, then persist an empty Manifest + default VaultSettings. - let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?; + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?; fs::create_dir_all(&relicario_dir)?; fs::create_dir_all(root.join("items"))?; @@ -645,6 +645,7 @@ fn cmd_add(kind: AddKind) -> Result<()> { // (for attachment-cap settings + writing the encrypted blob alongside // the item). +#[allow(clippy::too_many_arguments)] fn build_login_item( title: Option, username: Option, @@ -860,6 +861,7 @@ fn build_document_item( Ok(item) } +#[allow(clippy::too_many_arguments)] fn build_totp_item( title: Option, issuer: Option, @@ -924,7 +926,7 @@ fn prompt_optional(label: &str) -> Result> { fn parse_month_year(s: &str) -> Result { // Accepts MM/YYYY or MM-YYYY or MM/YY. - let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-') + let (m_str, y_str) = s.split_once(['/', '-']) .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?; let month: u8 = m_str.parse().context("invalid month")?; let year: u16 = if y_str.len() == 2 { @@ -998,12 +1000,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { if let Some(u) = &l.url { println!("URL: {u}"); } if let Some(t) = &l.totp { if show { - println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret)); + println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret)); } else { println!("TOTP: **** (use --show to reveal)"); } } - if let Some(p) = &l.password { Some(p.clone()) } else { None } + l.password.clone() } ItemCore::SecureNote(n) => { if show { println!("Body:\n{}", n.body.as_str()); } @@ -1125,8 +1127,8 @@ fn cmd_list( Some(t) => e.r#type == t, None => true, }) - .filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str()))) - .filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t))) + .filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str()))) + .filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t))) .collect(); entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); @@ -1135,7 +1137,7 @@ fn cmd_list( return Ok(()); } - println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE"); + println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV"); for e in entries { let fav = if e.favorite { " *" } else { "" }; println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); @@ -1718,9 +1720,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { // .git/ history. if let Some(tar_bytes) = &unpacked.git_archive { - let mut archive = tar::Archive::new(tar_bytes.as_slice()); - archive.unpack(target.join(".git")) - .with_context(|| "failed to untar .git/")?; + // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower. + let cap = std::cmp::min( + (tar_bytes.len() as u64).saturating_mul(100), + relicario_core::DEFAULT_MAX_UNCOMPRESSED, + ); + let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap) + .with_context(|| "failed to safely unpack .git/ archive")?; + let git_dir = target.join(".git"); + for (rel_path, body) in entries { + let dest = git_dir.join(&rel_path); + // Paranoid OS-level check even after textual validation in core. + if !dest.starts_with(&git_dir) { + anyhow::bail!( + "tar entry {} resolved outside .git/ (path traversal blocked)", + rel_path.display() + ); + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("create parent {}", parent.display()) + })?; + } + fs::write(&dest, &body).with_context(|| { + format!("write {}", dest.display()) + })?; + } } else { // No history bundled — start a fresh git repo. let status = crate::helpers::git_command(&target, &["init"]).status()?; @@ -1950,7 +1975,7 @@ fn cmd_attachments(query: String) -> Result<()> { let entry = resolve_query(&manifest, &query)?; let item = vault.load_item(&entry.id)?; if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } - println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME"); + println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME"); for a in &item.attachments { println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); } @@ -2518,7 +2543,7 @@ fn cmd_device(action: DeviceAction) -> Result<()> { return Ok(()); } - println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)"); + println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED"); println!("{}", "-".repeat(72)); for d in &devices { let marker = if d.name == current { " *" } else { "" }; diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index a8a36b7..ec42f1b 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -50,7 +50,7 @@ impl UnlockedVault { let master_key = derive_master_key( passphrase.as_bytes(), - &*image_secret, + &image_secret, &salt, ¶ms, )?; diff --git a/crates/relicario-cli/tests/attachments.rs b/crates/relicario-cli/tests/attachments.rs index 2deaa2a..fd83361 100644 --- a/crates/relicario-cli/tests/attachments.rs +++ b/crates/relicario-cli/tests/attachments.rs @@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() { // Encrypted blob file is gone. let blob_path = v.path() .join("attachments") - .join(stdout.lines().nth(1).is_some().then_some("").unwrap_or("")); + .join(""); let item_attach_dir = std::fs::read_dir(v.path().join("attachments")) .unwrap().next().unwrap().unwrap().path(); let blob = item_attach_dir.join(format!("{aid}.enc")); diff --git a/crates/relicario-cli/tests/common/mod.rs b/crates/relicario-cli/tests/common/mod.rs index 1e5ed10..4bfbb0d 100644 --- a/crates/relicario-cli/tests/common/mod.rs +++ b/crates/relicario-cli/tests/common/mod.rs @@ -78,6 +78,7 @@ impl TestVault { cmd.output().unwrap() } + #[allow(dead_code)] pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output { let mut cmd = Command::cargo_bin("relicario").unwrap(); cmd.current_dir(self.dir.path()) @@ -91,6 +92,7 @@ impl TestVault { cmd.output().unwrap() } + #[allow(dead_code)] pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output { let mut cmd = Command::cargo_bin("relicario").unwrap(); cmd.current_dir(self.dir.path()) diff --git a/crates/relicario-core/src/crypto.rs b/crates/relicario-core/src/crypto.rs index c895ffb..d28b0bb 100644 --- a/crates/relicario-core/src/crypto.rs +++ b/crates/relicario-core/src/crypto.rs @@ -408,7 +408,7 @@ mod tests { blob.extend_from_slice(&[0u8; 16]); let key = Zeroizing::new([0u8; 32]); - let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt"); + let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt"); match err { RelicarioError::UnsupportedFormatVersion { found, expected } => { assert_eq!(found, 0x01); diff --git a/crates/relicario-core/src/device.rs b/crates/relicario-core/src/device.rs index 4d779fc..f2ce780 100644 --- a/crates/relicario-core/src/device.rs +++ b/crates/relicario-core/src/device.rs @@ -106,6 +106,16 @@ pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Res Ok(verifying_key.verify(data, &signature).is_ok()) } +/// Compute the OpenSSH SHA-256 fingerprint of a public key. +/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`: +/// `SHA256:<43-char base64 without padding>`. +pub fn fingerprint(public_key_openssh: &str) -> Result { + use ssh_key::HashAlg; + let public = PublicKey::from_openssh(public_key_openssh) + .map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?; + Ok(public.fingerprint(HashAlg::Sha256).to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -132,4 +142,27 @@ mod tests { let sig = sign(&private, b"hello").unwrap(); assert!(!verify(&other_public, b"hello", &sig).unwrap()); } + + #[test] + fn fingerprint_matches_ssh_keygen_format() { + let (_, public) = generate_keypair().unwrap(); + let fp = fingerprint(&public).unwrap(); + assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}"); + let body = fp.strip_prefix("SHA256:").unwrap(); + assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)"); + assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')); + } + + #[test] + fn fingerprint_is_deterministic() { + let (_, public) = generate_keypair().unwrap(); + assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap()); + } + + #[test] + fn fingerprint_differs_per_key() { + let (_, p1) = generate_keypair().unwrap(); + let (_, p2) = generate_keypair().unwrap(); + assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap()); + } } diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index f2be80d..5b3d131 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -51,6 +51,10 @@ pub enum RelicarioError { #[error("backup envelope schema v{found}; this Relicario reads v{expected}")] BackupSchemaMismatch { found: u32, expected: u32 }, + /// An error during backup restore (e.g., tar safety validation failure). + #[error("backup restore: {0}")] + BackupRestore(String), + /// CSV header doesn't match the LastPass column layout. #[error("unrecognized CSV header — expected LastPass export format ({0})")] ImportCsvHeader(String), diff --git a/crates/relicario-core/src/imgsecret.rs b/crates/relicario-core/src/imgsecret.rs index ab30a5a..60030ae 100644 --- a/crates/relicario-core/src/imgsecret.rs +++ b/crates/relicario-core/src/imgsecret.rs @@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len() /// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret. /// ceil(256 / 12) = 22 blocks per copy. -const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22 +const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22 /// Mid-frequency DCT coefficient positions for embedding, specified as /// (row, col) indices into the 8x8 DCT coefficient matrix. @@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> { return None; } let mut block = [[0.0f64; 8]; 8]; - for row in 0..8 { - for col in 0..8 { - block[row][col] = y.get(px + col, py + row); + for (row, block_row) in block.iter_mut().enumerate() { + for (col, cell) in block_row.iter_mut().enumerate() { + *cell = y.get(px + col, py + row); } } Some(block) @@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64 fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) { let start_x = region.x_offset + bx * BLOCK_SIZE; let start_y = region.y_offset + by * BLOCK_SIZE; - for row in 0..8 { - for col in 0..8 { - y.set(start_x + col, start_y + row, block[row][col]); + for (row, block_row) in block.iter().enumerate() { + for (col, &cell) in block_row.iter().enumerate() { + y.set(start_x + col, start_y + row, cell); } } } @@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo /// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0. fn dct1d(input: &[f64; 8]) -> [f64; 8] { let mut output = [0.0f64; 8]; - for k in 0..8 { + for (k, out_k) in output.iter_mut().enumerate() { let ck = if k == 0 { (1.0 / 8.0_f64).sqrt() } else { (2.0 / 8.0_f64).sqrt() }; let mut sum = 0.0; - for i in 0..8 { - sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); + for (i, &x) in input.iter().enumerate() { + sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); } - output[k] = ck * sum; + *out_k = ck * sum; } output } @@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] { /// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16) fn idct1d(input: &[f64; 8]) -> [f64; 8] { let mut output = [0.0f64; 8]; - for i in 0..8 { + for (i, out_i) in output.iter_mut().enumerate() { let mut sum = 0.0; - for k in 0..8 { + for (k, &x) in input.iter().enumerate() { let ck = if k == 0 { (1.0 / 8.0_f64).sqrt() } else { (2.0 / 8.0_f64).sqrt() }; - sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); + sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); } - output[i] = sum; + *out_i = sum; } output } @@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec { /// /// Pads the last byte with zeros if the bit count is not a multiple of 8. fn bits_to_bytes(bits: &[u8]) -> Vec { - let mut bytes = Vec::with_capacity((bits.len() + 7) / 8); + let mut bytes = Vec::with_capacity(bits.len().div_ceil(8)); for chunk in bits.chunks(8) { let mut byte = 0u8; for (i, &bit) in chunk.iter().enumerate() { diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 9fefd0e..83c1052 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -52,18 +52,15 @@ pub enum TotpAlgorithm { Sha512, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum TotpKind { + #[default] Totp, Hotp { counter: u64 }, Steam, } -impl Default for TotpKind { - fn default() -> Self { TotpKind::Totp } -} - /// Compute a TOTP/Steam code for `config` at the given Unix timestamp. /// /// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`. diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 4ae324e..e7b49a1 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -85,4 +85,7 @@ pub mod import_lastpass; pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; pub mod device; -pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; +pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; + +pub mod tar_safe; +pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED}; diff --git a/crates/relicario-core/src/tar_safe.rs b/crates/relicario-core/src/tar_safe.rs new file mode 100644 index 0000000..c78d5d0 --- /dev/null +++ b/crates/relicario-core/src/tar_safe.rs @@ -0,0 +1,138 @@ +//! Safe tar unpacking for backup restore. +//! +//! The standard `tar::Archive::unpack` has no guards against path traversal, +//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it +//! with `safe_unpack_git_archive`, which validates every entry before returning +//! `(relative_path, bytes)` pairs to the caller. + +use std::io::Read; +use std::path::{Component, PathBuf}; + +use tar::EntryType; + +use crate::error::{RelicarioError, Result}; + +/// Default cap on total uncompressed bytes extracted in one restore (1 GiB). +pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024; + +/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for +/// regular files only. +/// +/// # Errors +/// +/// Returns `Err(RelicarioError::BackupRestore(...))` if: +/// +/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked". +/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked". +/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked". +/// - An entry is a symlink or hardlink — "symlink/link rejected". +/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded". +/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded". +/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type". +pub fn safe_unpack_git_archive( + tar_bytes: &[u8], + max_uncompressed_bytes: u64, +) -> Result)>> { + let mut archive = tar::Archive::new(tar_bytes); + let entries = archive + .entries() + .map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?; + + let mut result: Vec<(PathBuf, Vec)> = Vec::new(); + let mut cumulative: u64 = 0; + + for entry in entries { + let mut entry = entry.map_err(|e| { + RelicarioError::BackupRestore(format!("failed to read tar entry: {e}")) + })?; + + let header = entry.header(); + let entry_type = header.entry_type(); + + // Reject symlinks and hardlinks. + match entry_type { + EntryType::Symlink => { + return Err(RelicarioError::BackupRestore( + "symlink entry rejected".to_string(), + )); + } + EntryType::Link => { + return Err(RelicarioError::BackupRestore( + "hardlink entry rejected".to_string(), + )); + } + EntryType::Directory => { + // Directories are implicit — skip without reading body. + continue; + } + EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => { + // These are normal file types; fall through to path checks. + } + _ => { + return Err(RelicarioError::BackupRestore(format!( + "unexpected entry type: {:?}", + entry_type + ))); + } + } + + // Validate the path. + let path = entry.path().map_err(|e| { + RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}")) + })?; + let path = path.into_owned(); + + for component in path.components() { + match component { + Component::ParentDir => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry contains '..' component".to_string(), + )); + } + Component::RootDir => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry has absolute path".to_string(), + )); + } + Component::Prefix(_) => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry has Windows drive prefix".to_string(), + )); + } + Component::Normal(_) | Component::CurDir => { + // Acceptable components. + } + } + } + + // Check declared size before reading body. + let claimed = header.size().map_err(|e| { + RelicarioError::BackupRestore(format!("could not read entry size: {e}")) + })?; + + if claimed > max_uncompressed_bytes { + return Err(RelicarioError::BackupRestore(format!( + "size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})" + ))); + } + + let new_total = cumulative.saturating_add(claimed); + if new_total > max_uncompressed_bytes { + return Err(RelicarioError::BackupRestore(format!( + "size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})" + ))); + } + + // Read the file body. + let mut body = Vec::with_capacity(claimed as usize); + entry.read_to_end(&mut body).map_err(|e| { + RelicarioError::BackupRestore(format!("failed to read entry body: {e}")) + })?; + + cumulative += body.len() as u64; + + result.push((path, body)); + } + + Ok(result) +} diff --git a/crates/relicario-core/src/time.rs b/crates/relicario-core/src/time.rs index 979df76..7ca263f 100644 --- a/crates/relicario-core/src/time.rs +++ b/crates/relicario-core/src/time.rs @@ -19,7 +19,7 @@ impl MonthYear { if !(1..=12).contains(&month) { return Err("month must be 1..=12"); } - if year < 2000 || year > 2099 { + if !(2000..=2099).contains(&year) { return Err("year must be 2000..=2099"); } Ok(Self { month, year }) diff --git a/crates/relicario-core/tests/safe_unpack.rs b/crates/relicario-core/tests/safe_unpack.rs new file mode 100644 index 0000000..d5ba73c --- /dev/null +++ b/crates/relicario-core/tests/safe_unpack.rs @@ -0,0 +1,187 @@ +use std::path::PathBuf; +use tar::{Builder, Header, EntryType}; +use relicario_core::safe_unpack_git_archive; + +/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes. +/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header +/// manually to produce truly malicious archives. +fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec { + let mut buf = vec![0u8; 512]; // one header block + + // Bytes 0-99: name field (null-padded) + let name_len = raw_path.len().min(100); + buf[..name_len].copy_from_slice(&raw_path[..name_len]); + + // Bytes 100-107: mode = "0000644\0" + buf[100..108].copy_from_slice(b"0000644\0"); + + // Bytes 108-115: uid + buf[108..116].copy_from_slice(b"0000000\0"); + + // Bytes 116-123: gid + buf[116..124].copy_from_slice(b"0000000\0"); + + // Bytes 124-135: size (octal, 11 digits + null) + let size_str = format!("{:011o}\0", content.len()); + buf[124..136].copy_from_slice(size_str.as_bytes()); + + // Bytes 136-147: mtime + buf[136..148].copy_from_slice(b"00000000000\0"); + + // Bytes 148-155: checksum placeholder (spaces during compute) + buf[148..156].copy_from_slice(b" "); + + // Byte 156: typeflag = '0' (regular file) + buf[156] = b'0'; + + // Bytes 257-262: magic "ustar\0" + buf[257..263].copy_from_slice(b"ustar\0"); + // Bytes 263-264: version "00" + buf[263..265].copy_from_slice(b"00"); + + // Compute checksum (sum of all bytes, checksum field treated as spaces). + let checksum: u32 = buf.iter().map(|&b| b as u32).sum(); + let cksum_str = format!("{:06o}\0 ", checksum); + buf[148..156].copy_from_slice(cksum_str.as_bytes()); + + // Append padded content blocks. + let mut out = buf; + if !content.is_empty() { + out.extend_from_slice(content); + // Pad to 512-byte boundary. + let remainder = content.len() % 512; + if remainder != 0 { + out.extend(vec![0u8; 512 - remainder]); + } + } + + // Two zero blocks = end-of-archive. + out.extend(vec![0u8; 1024]); + out +} + +/// Build a tar with a raw symlink entry (typeflag = '2'). +fn raw_symlink_tar() -> Vec { + let mut buf = vec![0u8; 512]; + + // name + buf[..9].copy_from_slice(b"evil_link"); + // mode + buf[100..108].copy_from_slice(b"0000755\0"); + // uid/gid + buf[108..116].copy_from_slice(b"0000000\0"); + buf[116..124].copy_from_slice(b"0000000\0"); + // size = 0 + buf[124..136].copy_from_slice(b"00000000000\0"); + // mtime + buf[136..148].copy_from_slice(b"00000000000\0"); + // checksum placeholder + buf[148..156].copy_from_slice(b" "); + // typeflag = '2' (symlink) + buf[156] = b'2'; + // linkname + let target = b"/etc/passwd"; + buf[157..157 + target.len()].copy_from_slice(target); + // magic + buf[257..263].copy_from_slice(b"ustar\0"); + buf[263..265].copy_from_slice(b"00"); + + // Compute checksum. + let checksum: u32 = buf.iter().map(|&b| b as u32).sum(); + let cksum_str = format!("{:06o}\0 ", checksum); + buf[148..156].copy_from_slice(cksum_str.as_bytes()); + + let mut out = buf; + out.extend(vec![0u8; 1024]); // end-of-archive + out +} + +fn build_normal_tar() -> Vec { + let mut buf = Vec::new(); + { + let mut builder = Builder::new(&mut buf); + let content = b"hello"; + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "subdir/hello.txt", content.as_ref()) + .unwrap(); + builder.finish().unwrap(); + } + buf +} + +fn build_oversize_tar() -> Vec { + // Actual 2048-byte body; test will use cap=1024 + let mut buf = Vec::new(); + { + let mut builder = Builder::new(&mut buf); + let content = vec![0u8; 2048]; + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "bigfile.bin", content.as_slice()) + .unwrap(); + builder.finish().unwrap(); + } + buf +} + +#[test] +fn restore_rejects_path_traversal() { + // Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths). + let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content"); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("path traversal") || msg.contains(".."), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_absolute_path() { + // Craft a tar with "/etc/escaped.txt" using raw bytes. + let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content"); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("path traversal") || msg.contains("absolute"), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_symlink() { + let bytes = raw_symlink_tar(); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("symlink") || msg.contains("link"), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_size_bomb() { + let bytes = build_oversize_tar(); // actual 2048-byte entry + let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes + let msg = format!("{err:#}"); + assert!( + msg.contains("size") || msg.contains("cap") || msg.contains("too large"), + "got: {msg}" + ); +} + +#[test] +fn restore_accepts_normal_files() { + let buf = build_normal_tar(); + let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt")); + assert_eq!(entries[0].1, b"hello"); +} diff --git a/crates/relicario-server/Cargo.toml b/crates/relicario-server/Cargo.toml index 75a4e5d..6455bbc 100644 --- a/crates/relicario-server/Cargo.toml +++ b/crates/relicario-server/Cargo.toml @@ -9,3 +9,10 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" +regex = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" diff --git a/crates/relicario-server/src/main.rs b/crates/relicario-server/src/main.rs index 06dcef6..91d861a 100644 --- a/crates/relicario-server/src/main.rs +++ b/crates/relicario-server/src/main.rs @@ -1,5 +1,6 @@ //! relicario-server -- pre-receive hook for signature verification. +use std::fs; use std::process::Command; use anyhow::{Context, Result}; @@ -34,49 +35,120 @@ fn main() -> Result<()> { } fn verify_commit(commit: &str) -> Result<()> { - // Get devices.json at this commit let devices_json = match git_show(commit, ".relicario/devices.json") { Ok(json) => json, Err(_) => { - // No devices.json yet -- bootstrap mode, allow unsigned - eprintln!("OK: commit {} (bootstrap - no devices.json)", commit); + eprintln!("OK: commit {commit} (bootstrap - no devices.json)"); return Ok(()); } }; let devices: Vec = serde_json::from_str(&devices_json) .context("parse devices.json")?; - // Bootstrap: if devices.json is empty, allow unsigned - if devices.is_empty() { - eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit); - return Ok(()); - } - - // Get revoked.json (may not exist) let revoked: Vec = git_show(commit, ".relicario/revoked.json") .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); - // Get commit signature + // True bootstrap: no devices ever registered and none revoked. + if devices.is_empty() && revoked.is_empty() { + eprintln!("OK: commit {commit} (bootstrap - no devices registered)"); + return Ok(()); + } + + // Build temp allowed-signers file from registered devices. + let tmp = tempfile::tempdir().context("create tempdir")?; + let allowed_path = tmp.path().join("allowed_signers"); + let mut allowed_body = String::new(); + for d in &devices { + allowed_body.push_str("relicario "); + allowed_body.push_str(d.public_key.trim()); + allowed_body.push('\n'); + } + fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?; + + // Run git verify-commit --raw. Capture both exit code and stderr. + // NOTE: we do NOT short-circuit on non-zero exit here because even for + // unregistered keys git still outputs "Good ... key SHA256:..." on stderr. let output = Command::new("git") .args(["verify-commit", "--raw", commit]) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile") + .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str()) .output() .context("git verify-commit")?; - // Check if signed let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") { - eprintln!("REJECT: commit {} is not signed by a registered device", commit); + + // Parse the SHA-256 fingerprint from stderr. + // SSH signature output: "Good "git" signature ... with ED25519 key SHA256:" + let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex"); + let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) { + Some(m) => m.as_str().to_string(), + None => { + // No fingerprint in stderr = unsigned or completely malformed signature. + eprintln!( + "REJECT: commit {commit} — no valid signature found (stderr: {})", + stderr.trim() + ); + std::process::exit(1); + } + }; + + // Build fingerprint → entry maps. + let mut device_by_fp: std::collections::HashMap = + std::collections::HashMap::new(); + for d in &devices { + if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) { + device_by_fp.insert(fp, d); + } + } + + let mut revoked_by_fp: std::collections::HashMap = + std::collections::HashMap::new(); + for r in &revoked { + if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) { + revoked_by_fp.insert(fp, r); + } + } + + // Get committer date (NOT author date). + let ct_out = Command::new("git") + .args(["show", "-s", "--format=%ct", commit]) + .output() + .context("git show committer date")?; + let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout) + .trim() + .parse() + .context("parse committer timestamp")?; + + // Check revocation FIRST (revoked entries may not be in devices anymore). + if let Some(r) = revoked_by_fp.get(&signing_fp) { + if committer_ts >= r.revoked_at { + eprintln!( + "REJECT: commit {commit} — signed by revoked device '{}' \ + (committer ts {committer_ts} >= revoked_at {})", + r.name, r.revoked_at + ); + std::process::exit(1); + } + // Historical commit: committer_ts < revoked_at → was valid when signed. + eprintln!( + "OK: commit {commit} — historical commit signed by '{}' before revocation", + r.name + ); + return Ok(()); + } + + // Not revoked — must be in active devices. + if !device_by_fp.contains_key(&signing_fp) { + eprintln!( + "REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})" + ); std::process::exit(1); } - // Ensure the signing key is not revoked. - // The allowed-signers file approach means git verify-commit already checks - // against the list; we additionally guard against revoked.json entries. - let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers - - eprintln!("OK: commit {} verified", commit); + eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name); Ok(()) } diff --git a/crates/relicario-server/tests/verify_commit.rs b/crates/relicario-server/tests/verify_commit.rs new file mode 100644 index 0000000..ce6e72f --- /dev/null +++ b/crates/relicario-server/tests/verify_commit.rs @@ -0,0 +1,230 @@ +//! Acceptance tests for `relicario-server verify-commit`. +//! +//! Four scenarios from audit S1: +//! 1. Registered non-revoked key → exit 0 +//! 2. Unregistered key → exit 1 (stderr contains "unregistered") +//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked") +//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use assert_cmd::Command as AssertCommand; +use predicates::prelude::*; +use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry}; +use tempfile::TempDir; + +fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) { + let (priv_pem, pub_line) = generate_keypair().expect("generate keypair"); + let priv_path = dir.join(format!("{name}.key")); + let pub_path = dir.join(format!("{name}.pub")); + fs::write(&priv_path, priv_pem.as_str()).unwrap(); + fs::write(&pub_path, &pub_line).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap(); + } + (priv_path, pub_path, pub_line) +} + +fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) { + let mut cmd = Command::new("git"); + cmd.current_dir(repo).args(args); + for (k, v) in extra_env { + cmd.env(k, v); + } + let status = cmd.status().expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); +} + +fn init_repo(repo: &Path) { + git(repo, &["init", "-q", "-b", "main"], &[]); + git(repo, &["config", "user.email", "test@test"], &[]); + git(repo, &["config", "user.name", "test"], &[]); + git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]); +} + +fn sign_commit( + repo: &Path, + signing_key: &Path, + allowed_signers: &Path, + committer_unix: i64, + msg: &str, + file_path: &str, + file_content: &str, +) -> String { + fs::write(repo.join(file_path), file_content).unwrap(); + git(repo, &["add", file_path], &[]); + let date = format!("@{committer_unix} +0000"); + git( + repo, + &[ + "-c", "gpg.format=ssh", + "-c", &format!("user.signingkey={}", signing_key.display()), + "-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()), + "commit", "-S", "-q", "-m", msg, + ], + &[ + ("GIT_AUTHOR_DATE", &date), + ("GIT_COMMITTER_DATE", &date), + ], + ); + let out = Command::new("git") + .current_dir(repo) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) { + let dir = repo.join(".relicario"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap(); + fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap(); + git(repo, &["add", ".relicario"], &[]); + git(repo, &["commit", "-q", "-m", "device files"], &[]); +} + +#[test] +fn registered_non_revoked_key_accepted() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + write_device_files( + repo, + &[DeviceEntry { + name: "alice".into(), + public_key: pub_a.clone(), + added_at: 1_700_000_000, + added_by: "bootstrap".into(), + }], + &[], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .success(); +} + +#[test] +fn unregistered_key_rejected() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (_, _, pub_a) = write_keypair(repo, "alice"); + let (priv_evil, _, pub_evil) = write_keypair(repo, "evil"); + + // Only Alice is registered. + write_device_files( + repo, + &[DeviceEntry { + name: "alice".into(), + public_key: pub_a.clone(), + added_at: 1_700_000_000, + added_by: "bootstrap".into(), + }], + &[], + ); + + // Evil signs against a file containing both keys so git commit signing works, + // but the binary's allowed-signers (from devices.json) only has Alice. + let allowed = repo.join("test_allowed_signers"); + fs::write( + &allowed, + format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()), + ) + .unwrap(); + + let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .failure() + .stderr(predicate::str::contains("unregistered")); +} + +#[test] +fn revoked_key_after_revoked_at_rejected() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + + // Alice's entry is only in revoked.json (was removed from devices.json after revocation). + write_device_files( + repo, + &[], + &[RevokedEntry { + name: "alice".into(), + public_key: pub_a.clone(), + revoked_at: 1_705_000_000, + revoked_by: "admin".into(), + }], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + // Commit dated AFTER revocation. + let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .failure() + .stderr(predicate::str::contains("revoked")); +} + +#[test] +fn revoked_key_before_revoked_at_accepted_historical() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path(); + init_repo(repo); + + let (priv_a, _, pub_a) = write_keypair(repo, "alice"); + + // Same as above: Alice only in revoked.json. + write_device_files( + repo, + &[], + &[RevokedEntry { + name: "alice".into(), + public_key: pub_a.clone(), + revoked_at: 1_705_000_000, + revoked_by: "admin".into(), + }], + ); + + let allowed = repo.join("test_allowed_signers"); + fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap(); + + // Commit dated BEFORE revocation -- historical case must pass. + let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi"); + + AssertCommand::cargo_bin("relicario-server") + .unwrap() + .current_dir(repo) + .args(["verify-commit", &sha]) + .assert() + .success(); +} diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 972375e..8676e9f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -71,3 +71,34 @@ Without device authentication, access control is transport-layer only: - **Extension**: Git credentials in browser storage Device registration is optional but recommended for shared vaults. + +## Configuration env vars + +Relicario reads the following environment variables. Each is a trust +boundary: an attacker who can set them in the user's environment can +influence Relicario's behavior. They are listed here for security +reviewers to audit the surface in one place. + +### User-facing (active in all builds) + +| Variable | Purpose | Trust | +|---|---|---| +| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. | +| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. | +| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. | +| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. | +| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. | + +### Debug-only (compiled out of `cargo build --release`) + +The following variables are gated behind `cfg(debug_assertions)` and +are **no-ops** in release builds. The env-var lookup is removed by the +optimiser from any binary built without debug assertions (i.e. the +standard `--release` profile). + +| Variable | Purpose | +|---|---| +| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. | +| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. | +| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. | +| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |