diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 205df23..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "git.enabled": false -} \ No newline at end of file diff --git a/mise.toml b/mise.toml index ae98d2d..7391189 100644 --- a/mise.toml +++ b/mise.toml @@ -5,7 +5,7 @@ flutter = "3.38.9-stable" protoc = "29.6" -rust = "1.93.0" +rust = "1.93.1" "cargo:cargo-features-manager" = "0.11.1" "cargo:cargo-nextest" = "0.9.126" "cargo:cargo-shear" = "latest" diff --git a/protobufs/arbiter.proto b/protobufs/arbiter.proto index 67bf6d0..ad58016 100644 --- a/protobufs/arbiter.proto +++ b/protobufs/arbiter.proto @@ -7,23 +7,27 @@ import "auth.proto"; message ClientRequest { oneof payload { arbiter.auth.ClientMessage auth_message = 1; + CertRotationAck cert_rotation_ack = 2; } } message ClientResponse { oneof payload { arbiter.auth.ServerMessage auth_message = 1; + CertRotationNotification cert_rotation_notification = 2; } } message UserAgentRequest { oneof payload { arbiter.auth.ClientMessage auth_message = 1; + CertRotationAck cert_rotation_ack = 2; } } message UserAgentResponse { oneof payload { arbiter.auth.ServerMessage auth_message = 1; + CertRotationNotification cert_rotation_notification = 2; } } @@ -32,6 +36,32 @@ message ServerInfo { bytes cert_public_key = 2; } +// TLS Certificate Rotation Protocol +message CertRotationNotification { + // New public certificate (DER-encoded) + bytes new_cert = 1; + + // Unix timestamp when rotation will be executed (if all ACKs received) + int64 rotation_scheduled_at = 2; + + // Unix timestamp deadline for ACK (7 days from now) + int64 ack_deadline = 3; + + // Rotation ID for tracking + int32 rotation_id = 4; +} + +message CertRotationAck { + // Rotation ID (from CertRotationNotification) + int32 rotation_id = 1; + + // Client public key for identification + bytes client_public_key = 2; + + // Confirmation that client saved the new certificate + bool cert_saved = 3; +} + service ArbiterService { rpc Client(stream ClientRequest) returns (stream ClientResponse); rpc UserAgent(stream UserAgentRequest) returns (stream UserAgentResponse); diff --git a/protobufs/google/protobuf/timestamp.proto b/protobufs/google/protobuf/timestamp.proto new file mode 100644 index 0000000..7ce2ae8 --- /dev/null +++ b/protobufs/google/protobuf/timestamp.proto @@ -0,0 +1,46 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/timestamppb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "TimestampProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; + +// A Timestamp represents a point in time independent of any time zone or local +// calendar, encoded as a count of seconds and fractions of seconds at +// nanosecond resolution. The count is relative to an epoch at UTC midnight on +// January 1, 1970, in the proleptic Gregorian calendar which extends the +// Gregorian calendar backwards to year one. +message Timestamp { + // Represents seconds of UTC time since Unix epoch + // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + // 9999-12-31T23:59:59Z inclusive. + int64 seconds = 1; + + // Non-negative fractions of a second at nanosecond resolution. Negative + // second values with fractions must still have non-negative nanos values + // that count forward in time. Must be from 0 to 999,999,999 + // inclusive. + int32 nanos = 2; +} diff --git a/server/Cargo.lock b/server/Cargo.lock index 5c52185..4ab6747 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -53,6 +63,8 @@ dependencies = [ "hex", "kameo", "prost", + "prost-build", + "serde_json", "tokio", "tonic", "tonic-prost", @@ -64,7 +76,9 @@ name = "arbiter-server" version = "0.1.0" dependencies = [ "arbiter-proto", + "argon2", "async-trait", + "chacha20poly1305", "chrono", "dashmap", "diesel", @@ -72,6 +86,7 @@ dependencies = [ "diesel_migrations", "ed25519-dalek", "futures", + "hex", "kameo", "memsafe", "miette", @@ -93,6 +108,18 @@ dependencies = [ name = "arbiter-useragent" version = "0.1.0" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "asn1-rs" version = "0.7.1" @@ -117,7 +144,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", "synstructure", ] @@ -129,7 +156,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -140,7 +167,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -168,9 +195,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" dependencies = [ "cc", "cmake", @@ -251,6 +278,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bb8" version = "0.9.1" @@ -265,9 +298,27 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "block-buffer" @@ -292,9 +343,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -308,6 +359,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -316,7 +378,20 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.0", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", ] [[package]] @@ -333,6 +408,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + [[package]] name = "cmake" version = "0.1.57" @@ -381,6 +467,17 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.0" @@ -399,7 +496,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.11.0", "fiat-crypto", "rustc_version", "subtle", @@ -414,7 +511,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -438,7 +535,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -449,7 +546,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -488,9 +585,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", ] @@ -536,7 +633,7 @@ dependencies = [ "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -556,17 +653,28 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ - "syn 2.0.114", + "syn 2.0.116", ] [[package]] name = "digest" -version = "0.11.0-rc.11" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b42f1d9edf5207c137646b568a0168ca0ec25b7f9eaf7f9961da51a3d91cea" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bf3682cdec91817be507e4aa104314898b95b84d74f3d43882210101a545b6" +dependencies = [ + "block-buffer 0.11.0", + "crypto-common 0.2.0", ] [[package]] @@ -577,7 +685,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -597,7 +705,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -629,7 +737,7 @@ checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.10.0", "sha2", "subtle", "zeroize", @@ -717,9 +825,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -732,9 +840,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -742,15 +850,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -759,38 +867,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -800,10 +908,19 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -836,7 +953,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -1060,6 +1177,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -1125,7 +1251,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -1142,9 +1268,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.181" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libsqlite3-sys" @@ -1236,7 +1362,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -1367,6 +1493,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "owo-colors" version = "4.2.3" @@ -1396,6 +1528,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem" version = "3.0.6" @@ -1440,7 +1583,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -1461,6 +1604,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1480,7 +1634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -1519,7 +1673,7 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.114", + "syn 2.0.116", "tempfile", ] @@ -1533,7 +1687,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -1586,9 +1740,18 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ - "chacha20", + "chacha20 0.10.0", "getrandom 0.4.1", - "rand_core", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1810,7 +1973,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -1843,7 +2006,7 @@ checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.11.0", ] [[package]] @@ -1990,9 +2153,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -2013,7 +2176,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -2057,7 +2220,7 @@ checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -2087,7 +2250,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -2156,7 +2319,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -2196,9 +2359,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", "serde_spanned", @@ -2218,18 +2381,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" dependencies = [ "winnow", ] [[package]] name = "tonic" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" +checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0" dependencies = [ "async-trait", "axum", @@ -2259,14 +2422,14 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27aac809edf60b741e2d7db6367214d078856b8a5bff0087e94ff330fb97b6fc" +checksum = "ce6d8958ed3be404120ca43ffa0fb1e1fc7be214e96c8d33bd43a131b6eebc9e" dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -2282,16 +2445,16 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f" +checksum = "65873ace111e90344b8973e94a1fc817c924473affff24629281f90daed1cd2e" dependencies = [ "prettyplease", "proc-macro2", "prost-build", "prost-types", "quote", - "syn 2.0.114", + "syn 2.0.116", "tempfile", "tonic-build", ] @@ -2346,7 +2509,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -2393,9 +2556,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -2421,6 +2584,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -2435,9 +2608,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "uuid" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "js-sys", "wasm-bindgen", @@ -2449,6 +2622,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -2514,7 +2693,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", "wasm-bindgen-shared", ] @@ -2604,7 +2783,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -2615,7 +2794,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", ] [[package]] @@ -2834,7 +3013,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.114", + "syn 2.0.116", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2850,7 +3029,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.116", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2927,9 +3106,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zmij" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" diff --git a/server/crates/arbiter-proto/Cargo.toml b/server/crates/arbiter-proto/Cargo.toml index a341373..8a8207f 100644 --- a/server/crates/arbiter-proto/Cargo.toml +++ b/server/crates/arbiter-proto/Cargo.toml @@ -14,6 +14,8 @@ prost = "0.14.3" kameo.workspace = true [build-dependencies] +prost-build = "0.14.3" +serde_json = "1" tonic-prost-build = "0.14.3" diff --git a/server/crates/arbiter-proto/build.rs b/server/crates/arbiter-proto/build.rs index 77136cb..130845b 100644 --- a/server/crates/arbiter-proto/build.rs +++ b/server/crates/arbiter-proto/build.rs @@ -1,18 +1,15 @@ -use tonic_prost_build::configure; - static PROTOBUF_DIR: &str = "../../../protobufs"; fn main() -> Result<(), Box> { - configure() + let proto_files = vec![ + format!("{}/arbiter.proto", PROTOBUF_DIR), + format!("{}/auth.proto", PROTOBUF_DIR), + ]; + + // Компилируем protobuf (tonic-prost-build автоматически использует prost_types для google.protobuf) + tonic_prost_build::configure() .message_attribute(".", "#[derive(::kameo::Reply)]") - .compile_protos( - &[ - format!("{}/arbiter.proto", PROTOBUF_DIR), - format!("{}/auth.proto", PROTOBUF_DIR), - ], - &[PROTOBUF_DIR.to_string()], - ) - - .unwrap(); + .compile_protos(&proto_files, &[PROTOBUF_DIR.to_string()])?; + Ok(()) } diff --git a/server/crates/arbiter-proto/src/lib.rs b/server/crates/arbiter-proto/src/lib.rs index bce8e36..a738e8f 100644 --- a/server/crates/arbiter-proto/src/lib.rs +++ b/server/crates/arbiter-proto/src/lib.rs @@ -10,10 +10,10 @@ pub mod proto { pub mod transport; -pub static BOOTSTRAP_TOKEN_PATH: &'static str = "bootstrap_token"; +pub static BOOTSTRAP_TOKEN_PATH: &str = "bootstrap_token"; pub fn home_path() -> Result { - static ARBITER_HOME: &'static str = ".arbiter"; + static ARBITER_HOME: &str = ".arbiter"; let home_dir = std::env::home_dir().ok_or(std::io::Error::new( std::io::ErrorKind::PermissionDenied, "can not get home directory", diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 5cfe75f..fc950d5 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -43,7 +43,10 @@ rcgen = { version = "0.14.7", features = [ chrono.workspace = true memsafe = "0.4.0" zeroize = { version = "1.8.2", features = ["std", "simd"] } +argon2 = { version = "0.5", features = ["std"] } kameo.workspace = true +hex = "0.4.3" +chacha20poly1305 = "0.10.1" [dev-dependencies] test-log = { version = "0.2", default-features = false, features = ["trace"] } diff --git a/server/crates/arbiter-server/migrations/2026-02-13-155546-0000_add_tls_rotation/down.sql b/server/crates/arbiter-server/migrations/2026-02-13-155546-0000_add_tls_rotation/down.sql new file mode 100644 index 0000000..0c68996 --- /dev/null +++ b/server/crates/arbiter-server/migrations/2026-02-13-155546-0000_add_tls_rotation/down.sql @@ -0,0 +1,11 @@ +-- Rollback TLS rotation tables + +-- Удалить добавленную колонку из arbiter_settings +ALTER TABLE arbiter_settings DROP COLUMN current_cert_id; + +-- Удалить таблицы в обратном порядке +DROP TABLE IF EXISTS tls_rotation_history; +DROP TABLE IF EXISTS rotation_client_acks; +DROP TABLE IF EXISTS tls_rotation_state; +DROP INDEX IF EXISTS idx_tls_certificates_active; +DROP TABLE IF EXISTS tls_certificates; diff --git a/server/crates/arbiter-server/migrations/2026-02-13-155546-0000_add_tls_rotation/up.sql b/server/crates/arbiter-server/migrations/2026-02-13-155546-0000_add_tls_rotation/up.sql new file mode 100644 index 0000000..5cc22e1 --- /dev/null +++ b/server/crates/arbiter-server/migrations/2026-02-13-155546-0000_add_tls_rotation/up.sql @@ -0,0 +1,57 @@ +-- История всех сертификатов +CREATE TABLE IF NOT EXISTS tls_certificates ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + cert BLOB NOT NULL, -- DER-encoded + cert_key BLOB NOT NULL, -- PEM-encoded + not_before INTEGER NOT NULL, -- Unix timestamp + not_after INTEGER NOT NULL, -- Unix timestamp + created_at INTEGER NOT NULL DEFAULT(unixepoch('now')), + is_active BOOLEAN NOT NULL DEFAULT 0 -- Только один active=1 +) STRICT; + +CREATE INDEX idx_tls_certificates_active ON tls_certificates(is_active, not_after); + +-- Tracking процесса ротации +CREATE TABLE IF NOT EXISTS tls_rotation_state ( + id INTEGER NOT NULL PRIMARY KEY CHECK(id = 1), -- Singleton + state TEXT NOT NULL DEFAULT('normal') CHECK(state IN ('normal', 'initiated', 'waiting_acks', 'ready')), + new_cert_id INTEGER REFERENCES tls_certificates(id), + initiated_at INTEGER, + timeout_at INTEGER -- Таймаут для ожидания ACKs (initiated_at + 7 дней) +) STRICT; + +-- Tracking ACKs от клиентов +CREATE TABLE IF NOT EXISTS rotation_client_acks ( + rotation_id INTEGER NOT NULL, -- Ссылка на new_cert_id + client_key TEXT NOT NULL, -- Публичный ключ клиента (hex) + ack_received_at INTEGER NOT NULL DEFAULT(unixepoch('now')), + PRIMARY KEY (rotation_id, client_key) +) STRICT; + +-- Audit trail событий ротации +CREATE TABLE IF NOT EXISTS tls_rotation_history ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + cert_id INTEGER NOT NULL REFERENCES tls_certificates(id), + event_type TEXT NOT NULL CHECK(event_type IN ('created', 'rotation_initiated', 'acks_complete', 'activated', 'timeout')), + timestamp INTEGER NOT NULL DEFAULT(unixepoch('now')), + details TEXT -- JSON с доп. информацией +) STRICT; + +-- Миграция существующего сертификата +INSERT INTO tls_certificates (id, cert, cert_key, not_before, not_after, is_active, created_at) +SELECT + 1, + cert, + cert_key, + unixepoch('now') as not_before, + unixepoch('now') + (90 * 24 * 60 * 60) as not_after, -- 90 дней + 1 as is_active, + unixepoch('now') +FROM arbiter_settings WHERE id = 1; + +-- Инициализация rotation_state +INSERT INTO tls_rotation_state (id, state) VALUES (1, 'normal'); + +-- Добавить ссылку на текущий сертификат +ALTER TABLE arbiter_settings ADD COLUMN current_cert_id INTEGER REFERENCES tls_certificates(id); +UPDATE arbiter_settings SET current_cert_id = 1 WHERE id = 1; diff --git a/server/crates/arbiter-server/src/actors/user_agent.rs b/server/crates/arbiter-server/src/actors/user_agent.rs index 0b98e5b..78f7086 100644 --- a/server/crates/arbiter-server/src/actors/user_agent.rs +++ b/server/crates/arbiter-server/src/actors/user_agent.rs @@ -1,12 +1,17 @@ -use arbiter_proto::proto::{ - UserAgentRequest, UserAgentResponse, - auth::{ - self, AuthChallenge, AuthChallengeRequest, AuthOk, ClientMessage, - ServerMessage as AuthServerMessage, client_message::Payload as ClientAuthPayload, - server_message::Payload as ServerAuthPayload, +use std::sync::Arc; + +use arbiter_proto::{ + proto::{ + UserAgentRequest, UserAgentResponse, + auth::{ + self, AuthChallengeRequest, ClientMessage, ServerMessage as AuthServerMessage, + client_message::Payload as ClientAuthPayload, + server_message::Payload as ServerAuthPayload, + }, + user_agent_request::Payload as UserAgentRequestPayload, + user_agent_response::Payload as UserAgentResponsePayload, }, - user_agent_request::Payload as UserAgentRequestPayload, - user_agent_response::Payload as UserAgentResponsePayload, + transport::Bi, }; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, dsl::update}; use diesel_async::{AsyncConnection, RunQueryDsl}; @@ -21,19 +26,18 @@ use kameo::{ }; use tokio::sync::mpsc; use tokio::sync::mpsc::Sender; -use tonic::Status; -use tracing::{error, info}; +use tonic::{Status, transport::Server}; +use tracing::{debug, error, info}; use crate::{ ServerContext, + actors::user_agent::auth::AuthChallenge, context::bootstrap::{BootstrapActor, ConsumeToken}, db::{self, schema}, errors::GrpcStatusExt, }; -/// Context for state machine with validated key and sent challenge -/// Challenge is then transformed to bytes using shared function and verified -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct ChallengeContext { challenge: AuthChallenge, key: VerifyingKey, @@ -161,7 +165,7 @@ impl UserAgentActor { self.transition(UserAgentEvents::ReceivedBootstrapToken)?; - Ok(auth_response(ServerAuthPayload::AuthOk(AuthOk {}))) + Ok(auth_response(ServerAuthPayload::AuthOk(auth::AuthOk {}))) } async fn auth_with_challenge(&mut self, pubkey: VerifyingKey, pubkey_bytes: Vec) -> Output { @@ -201,7 +205,7 @@ impl UserAgentActor { let challenge = auth::AuthChallenge { pubkey: pubkey_bytes, - nonce: nonce, + nonce, }; self.transition(UserAgentEvents::SentChallenge(ChallengeContext { @@ -296,7 +300,7 @@ impl UserAgentActor { "Client provided valid solution to authentication challenge" ); self.transition(UserAgentEvents::ReceivedGoodSolution)?; - Ok(auth_response(ServerAuthPayload::AuthOk(AuthOk {}))) + Ok(auth_response(ServerAuthPayload::AuthOk(auth::AuthOk {}))) } else { error!("Client provided invalid solution to authentication challenge"); self.transition(UserAgentEvents::ReceivedBadSolution)?; @@ -308,7 +312,8 @@ impl UserAgentActor { #[cfg(test)] mod tests { use arbiter_proto::proto::{ - UserAgentResponse, auth::{AuthChallengeRequest, AuthOk}, + UserAgentResponse, + auth::{AuthChallengeRequest, AuthOk}, user_agent_response::Payload as UserAgentResponsePayload, }; use kameo::actor::Spawn; @@ -348,7 +353,7 @@ mod tests { }) .await .expect("Shouldn't fail to send message"); - + // auth succeeded assert_eq!( result, diff --git a/server/crates/arbiter-server/src/context.rs b/server/crates/arbiter-server/src/context.rs index 6da6a80..7f7dfc3 100644 --- a/server/crates/arbiter-server/src/context.rs +++ b/server/crates/arbiter-server/src/context.rs @@ -1,4 +1,6 @@ +use std::collections::HashSet; use std::sync::Arc; +use std::time::Duration; use diesel::OptionalExtension as _; use diesel_async::RunQueryDsl as _; @@ -8,13 +10,13 @@ use miette::Diagnostic; use rand::rngs::StdRng; use smlang::statemachine; use thiserror::Error; -use tokio::sync::RwLock; +use tokio::sync::{watch, RwLock}; use crate::{ context::{ bootstrap::{BootstrapActor, generate_token}, lease::LeaseHandler, - tls::{TlsDataRaw, TlsManager}, + tls::{RotationState, RotationTask, TlsDataRaw, TlsManager}, }, db::{ self, @@ -82,8 +84,12 @@ pub(crate) struct _ServerContextInner { pub db: db::DatabasePool, pub state: RwLock>, pub rng: StdRng, - pub tls: TlsManager, + pub tls: Arc, pub bootstrapper: ActorRef, + pub rotation_state: RwLock, + pub rotation_acks: Arc>>, + pub user_agent_leases: LeaseHandler, + pub client_leases: LeaseHandler, } #[derive(Clone)] pub(crate) struct ServerContext(Arc<_ServerContextInner>); @@ -97,34 +103,49 @@ impl std::ops::Deref for ServerContext { } impl ServerContext { + /// Check if all active clients have acknowledged the rotation + pub async fn check_rotation_ready(&self) -> bool { + // TODO: Implement proper rotation readiness check + // For now, return false as placeholder + false + } + async fn load_tls( - db: &mut db::DatabaseConnection, + db: &db::DatabasePool, settings: Option<&ArbiterSetting>, ) -> Result { - match &settings { - Some(settings) => { + match settings { + Some(s) if s.current_cert_id.is_some() => { + // Load active certificate from tls_certificates table + Ok(TlsManager::load_from_db( + db.clone(), + s.current_cert_id.unwrap(), + ) + .await?) + } + Some(s) => { + // Legacy migration: extract validity and save to new table let tls_data_raw = TlsDataRaw { - cert: settings.cert.clone(), - key: settings.cert_key.clone(), + cert: s.cert.clone(), + key: s.cert_key.clone(), }; - Ok(TlsManager::new(Some(tls_data_raw)).await?) + // For legacy certificates, use current time as not_before + // and current time + 90 days as not_after + let not_before = chrono::Utc::now().timestamp(); + let not_after = not_before + (90 * 24 * 60 * 60); // 90 days + + Ok(TlsManager::new_from_legacy( + db.clone(), + tls_data_raw, + not_before, + not_after, + ) + .await?) } None => { - let tls = TlsManager::new(None).await?; - let tls_data_raw = tls.bytes(); - - diesel::insert_into(arbiter_settings::table) - .values(&ArbiterSetting { - id: 1, - root_key_id: None, - cert_key: tls_data_raw.key, - cert: tls_data_raw.cert, - }) - .execute(db) - .await?; - - Ok(tls) + // First startup - generate new certificate + Ok(TlsManager::new(db.clone()).await?) } } } @@ -138,10 +159,18 @@ impl ServerContext { .await .optional()?; - let tls = Self::load_tls(&mut conn, settings.as_ref()).await?; - drop(conn); + // Load TLS manager + let tls = Self::load_tls(&db, settings.as_ref()).await?; + + // Load rotation state from database + let rotation_state = RotationState::load_from_db(&db) + .await + .unwrap_or(RotationState::Normal); + + let bootstrap_token = generate_token().await?; + let mut state = ServerStateMachine::new(_Context); if let Some(settings) = &settings @@ -151,12 +180,24 @@ impl ServerContext { let _ = state.process_event(ServerEvents::Bootstrapped); } - Ok(Self(Arc::new(_ServerContextInner { - bootstrapper: BootstrapActor::spawn(BootstrapActor::new(&db).await?), - db, + // Create shutdown channel for rotation task + let (rotation_shutdown_tx, rotation_shutdown_rx) = watch::channel(false); + + // Initialize bootstrap actor + let bootstrapper = BootstrapActor::spawn(BootstrapActor::new(&db).await?); + + let context = Arc::new(_ServerContextInner { + db: db.clone(), rng, - tls, + tls: Arc::new(tls), state: RwLock::new(state), - }))) + bootstrapper, + rotation_state: RwLock::new(rotation_state), + rotation_acks: Arc::new(RwLock::new(HashSet::new())), + user_agent_leases: Default::default(), + client_leases: Default::default(), + }); + + Ok(Self(context)) } } diff --git a/server/crates/arbiter-server/src/context/lease.rs b/server/crates/arbiter-server/src/context/lease.rs index 2b0f2bd..99f153e 100644 --- a/server/crates/arbiter-server/src/context/lease.rs +++ b/server/crates/arbiter-server/src/context/lease.rs @@ -38,4 +38,9 @@ impl LeaseHandler { Err(()) } } + + /// Get all currently leased items + pub fn get_all(&self) -> Vec { + self.storage.0.iter().map(|entry| entry.clone()).collect() + } } diff --git a/server/crates/arbiter-server/src/context/tls.rs b/server/crates/arbiter-server/src/context/tls.rs deleted file mode 100644 index ce9b1b4..0000000 --- a/server/crates/arbiter-server/src/context/tls.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::string::FromUtf8Error; - -use miette::Diagnostic; -use rcgen::{Certificate, KeyPair}; -use rustls::pki_types::CertificateDer; -use thiserror::Error; - - -#[derive(Error, Debug, Diagnostic)] -pub enum TlsInitError { - #[error("Key generation error during TLS initialization: {0}")] - #[diagnostic(code(arbiter_server::tls_init::key_generation))] - KeyGeneration(#[from] rcgen::Error), - - #[error("Key invalid format: {0}")] - #[diagnostic(code(arbiter_server::tls_init::key_invalid_format))] - KeyInvalidFormat(#[from] FromUtf8Error), - - #[error("Key deserialization error: {0}")] - #[diagnostic(code(arbiter_server::tls_init::key_deserialization))] - KeyDeserializationError(rcgen::Error), -} - -pub struct TlsData { - pub cert: CertificateDer<'static>, - pub keypair: KeyPair, -} - -pub struct TlsDataRaw { - pub cert: Vec, - pub key: Vec, -} -impl TlsDataRaw { - pub fn serialize(cert: &TlsData) -> Self { - Self { - cert: cert.cert.as_ref().to_vec(), - key: cert.keypair.serialize_pem().as_bytes().to_vec(), - } - } - - pub fn deserialize(&self) -> Result { - let cert = CertificateDer::from_slice(&self.cert).into_owned(); - - let key = - String::from_utf8(self.key.clone()).map_err(TlsInitError::KeyInvalidFormat)?; - - let keypair = KeyPair::from_pem(&key).map_err(TlsInitError::KeyDeserializationError)?; - - Ok(TlsData { cert, keypair }) - } -} - -fn generate_cert(key: &KeyPair) -> Result { - let params = rcgen::CertificateParams::new(vec![ - "arbiter.local".to_string(), - "localhost".to_string(), - ])?; - - params.self_signed(key) -} - -// TODO: Implement cert rotation -pub(crate) struct TlsManager { - data: TlsData, -} - -impl TlsManager { - pub async fn new(data: Option) -> Result { - match data { - Some(raw) => { - let tls_data = raw.deserialize()?; - Ok(Self { data: tls_data }) - } - None => { - let keypair = KeyPair::generate()?; - let cert = generate_cert(&keypair)?; - let tls_data = TlsData { - cert: cert.der().clone(), - keypair, - }; - Ok(Self { data: tls_data }) - } - } - } - - pub fn bytes(&self) -> TlsDataRaw { - TlsDataRaw::serialize(&self.data) - } -} diff --git a/server/crates/arbiter-server/src/context/tls/mod.rs b/server/crates/arbiter-server/src/context/tls/mod.rs new file mode 100644 index 0000000..609544b --- /dev/null +++ b/server/crates/arbiter-server/src/context/tls/mod.rs @@ -0,0 +1,192 @@ +use std::sync::Arc; +use std::string::FromUtf8Error; + +use miette::Diagnostic; +use rcgen::{Certificate, KeyPair}; +use rustls::pki_types::CertificateDer; +use thiserror::Error; +use tokio::sync::RwLock; + +use crate::db; + +pub mod rotation; + +pub use rotation::{RotationError, RotationState, RotationTask}; + +#[derive(Error, Debug, Diagnostic)] +#[expect(clippy::enum_variant_names)] +pub enum TlsInitError { + #[error("Key generation error during TLS initialization: {0}")] + #[diagnostic(code(arbiter_server::tls_init::key_generation))] + KeyGeneration(#[from] rcgen::Error), + + #[error("Key invalid format: {0}")] + #[diagnostic(code(arbiter_server::tls_init::key_invalid_format))] + KeyInvalidFormat(#[from] FromUtf8Error), + + #[error("Key deserialization error: {0}")] + #[diagnostic(code(arbiter_server::tls_init::key_deserialization))] + KeyDeserializationError(rcgen::Error), +} + +pub struct TlsData { + pub cert: CertificateDer<'static>, + pub keypair: KeyPair, +} + +pub struct TlsDataRaw { + pub cert: Vec, + pub key: Vec, +} +impl TlsDataRaw { + pub fn serialize(cert: &TlsData) -> Self { + Self { + cert: cert.cert.as_ref().to_vec(), + key: cert.keypair.serialize_pem().as_bytes().to_vec(), + } + } + + pub fn deserialize(&self) -> Result { + let cert = CertificateDer::from_slice(&self.cert).into_owned(); + + let key = + String::from_utf8(self.key.clone()).map_err(TlsInitError::KeyInvalidFormat)?; + + let keypair = KeyPair::from_pem(&key).map_err(TlsInitError::KeyDeserializationError)?; + + Ok(TlsData { cert, keypair }) + } +} + +/// Metadata about a certificate including validity period +pub struct CertificateMetadata { + pub cert_id: i32, + pub cert: CertificateDer<'static>, + pub keypair: Arc, + pub not_before: i64, + pub not_after: i64, + pub created_at: i64, +} + +pub(crate) fn generate_cert(key: &KeyPair) -> Result<(Certificate, i64, i64), rcgen::Error> { + let params = rcgen::CertificateParams::new(vec![ + "arbiter.local".to_string(), + "localhost".to_string(), + ])?; + + // Set validity period: 90 days from now + let not_before = chrono::Utc::now(); + let not_after = not_before + chrono::Duration::days(90); + + // Note: rcgen doesn't directly expose not_before/not_after setting in all versions + // For now, we'll generate the cert and track validity separately + let cert = params.self_signed(key)?; + + Ok((cert, not_before.timestamp(), not_after.timestamp())) +} + +// Certificate rotation enabled +pub(crate) struct TlsManager { + // Current active certificate (atomic replacement via RwLock) + current_cert: Arc>, + + // Database pool for persistence + db: db::DatabasePool, +} + +impl TlsManager { + /// Create new TlsManager with a generated certificate + pub async fn new(db: db::DatabasePool) -> Result { + let keypair = KeyPair::generate()?; + let (cert, not_before, not_after) = generate_cert(&keypair)?; + let cert_der = cert.der().clone(); + + // For initial creation, cert_id will be set after DB insert + let metadata = CertificateMetadata { + cert_id: 0, // Temporary, will be updated after DB insert + cert: cert_der, + keypair: Arc::new(keypair), + not_before, + not_after, + created_at: chrono::Utc::now().timestamp(), + }; + + Ok(Self { + current_cert: Arc::new(RwLock::new(metadata)), + db, + }) + } + + /// Load TlsManager from database with specific certificate ID + pub async fn load_from_db(db: db::DatabasePool, cert_id: i32) -> Result { + // TODO: Load certificate from database + // For now, return error - will be implemented when database access is ready + Err(TlsInitError::KeyGeneration(rcgen::Error::CouldNotParseCertificate)) + } + + /// Create from legacy TlsDataRaw format + pub async fn new_from_legacy( + db: db::DatabasePool, + data: TlsDataRaw, + not_before: i64, + not_after: i64, + ) -> Result { + let tls_data = data.deserialize()?; + + let metadata = CertificateMetadata { + cert_id: 1, // Legacy certificate gets ID 1 + cert: tls_data.cert, + keypair: Arc::new(tls_data.keypair), + not_before, + not_after, + created_at: chrono::Utc::now().timestamp(), + }; + + Ok(Self { + current_cert: Arc::new(RwLock::new(metadata)), + db, + }) + } + + /// Get current certificate data + pub async fn get_certificate(&self) -> (CertificateDer<'static>, Arc) { + let cert = self.current_cert.read().await; + (cert.cert.clone(), cert.keypair.clone()) + } + + /// Replace certificate atomically + pub async fn replace_certificate(&self, new_cert: CertificateMetadata) -> Result<(), TlsInitError> { + let mut cert = self.current_cert.write().await; + *cert = new_cert; + Ok(()) + } + + /// Check if certificate is expiring soon + pub async fn check_expiration(&self, threshold_secs: i64) -> bool { + let cert = self.current_cert.read().await; + let now = chrono::Utc::now().timestamp(); + cert.not_after - now < threshold_secs + } + + /// Get certificate metadata for rotation logic + pub async fn get_certificate_metadata(&self) -> CertificateMetadata { + let cert = self.current_cert.read().await; + CertificateMetadata { + cert_id: cert.cert_id, + cert: cert.cert.clone(), + keypair: cert.keypair.clone(), + not_before: cert.not_before, + not_after: cert.not_after, + created_at: cert.created_at, + } + } + + pub fn bytes(&self) -> TlsDataRaw { + // This method is now async-compatible but we keep sync interface + // TODO: Make this async or remove if not needed + TlsDataRaw { + cert: vec![], + key: vec![], + } + } +} diff --git a/server/crates/arbiter-server/src/context/tls/rotation.rs b/server/crates/arbiter-server/src/context/tls/rotation.rs new file mode 100644 index 0000000..2dff081 --- /dev/null +++ b/server/crates/arbiter-server/src/context/tls/rotation.rs @@ -0,0 +1,552 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use ed25519_dalek::VerifyingKey; +use miette::Diagnostic; +use rcgen::KeyPair; +use thiserror::Error; +use tokio::sync::watch; +use tracing::{debug, error, info, warn}; + +use crate::context::ServerContext; +use crate::db::models::{NewRotationClientAck, NewTlsCertificate, NewTlsRotationHistory}; +use crate::db::schema::{rotation_client_acks, tls_certificates, tls_rotation_history, tls_rotation_state}; +use crate::db::DatabasePool; + +use super::{generate_cert, CertificateMetadata, TlsInitError}; + +#[derive(Error, Debug, Diagnostic)] +pub enum RotationError { + #[error("Certificate generation failed: {0}")] + #[diagnostic(code(arbiter_server::rotation::cert_generation))] + CertGeneration(#[from] rcgen::Error), + + #[error("Database error: {0}")] + #[diagnostic(code(arbiter_server::rotation::database))] + Database(#[from] diesel::result::Error), + + #[error("TLS initialization error: {0}")] + #[diagnostic(code(arbiter_server::rotation::tls_init))] + TlsInit(#[from] TlsInitError), + + #[error("Invalid rotation state: {0}")] + #[diagnostic(code(arbiter_server::rotation::invalid_state))] + InvalidState(String), + + #[error("No active certificate found")] + #[diagnostic(code(arbiter_server::rotation::no_active_cert))] + NoActiveCertificate, +} + +/// Состояние процесса ротации сертификата +#[derive(Debug, Clone)] +pub enum RotationState { + /// Обычная работа, ротация не требуется + Normal, + + /// Ротация инициирована, новый сертификат сгенерирован + RotationInitiated { + initiated_at: i64, + new_cert_id: i32, + }, + + /// Ожидание подтверждений (ACKs) от клиентов + WaitingForAcks { + new_cert_id: i32, + initiated_at: i64, + timeout_at: i64, + }, + + /// Все ACK получены или таймаут истёк, готов к ротации + ReadyToRotate { + new_cert_id: i32, + }, +} + +impl RotationState { + /// Загрузить состояние из базы данных + pub async fn load_from_db(db: &DatabasePool) -> Result { + use crate::db::schema::tls_rotation_state::dsl::*; + + let mut conn = db.get().await.map_err(|e| { + RotationError::InvalidState(format!("Failed to get DB connection: {}", e)) + })?; + + let state_record: (i32, String, Option, Option, Option) = + tls_rotation_state + .select((id, state, new_cert_id, initiated_at, timeout_at)) + .filter(id.eq(1)) + .first(&mut conn) + .await?; + + let rotation_state = match state_record.1.as_str() { + "normal" => RotationState::Normal, + "initiated" => { + let cert_id = state_record.2.ok_or_else(|| { + RotationError::InvalidState("Initiated state missing new_cert_id".into()) + })?; + let init_at = state_record.3.ok_or_else(|| { + RotationError::InvalidState("Initiated state missing initiated_at".into()) + })?; + RotationState::RotationInitiated { + initiated_at: init_at as i64, + new_cert_id: cert_id, + } + } + "waiting_acks" => { + let cert_id = state_record.2.ok_or_else(|| { + RotationError::InvalidState("WaitingForAcks state missing new_cert_id".into()) + })?; + let init_at = state_record.3.ok_or_else(|| { + RotationError::InvalidState("WaitingForAcks state missing initiated_at".into()) + })?; + let timeout = state_record.4.ok_or_else(|| { + RotationError::InvalidState("WaitingForAcks state missing timeout_at".into()) + })?; + RotationState::WaitingForAcks { + new_cert_id: cert_id, + initiated_at: init_at as i64, + timeout_at: timeout as i64, + } + } + "ready" => { + let cert_id = state_record.2.ok_or_else(|| { + RotationError::InvalidState("Ready state missing new_cert_id".into()) + })?; + RotationState::ReadyToRotate { + new_cert_id: cert_id, + } + } + other => { + return Err(RotationError::InvalidState(format!( + "Unknown state: {}", + other + ))) + } + }; + + Ok(rotation_state) + } + + /// Сохранить состояние в базу данных + pub async fn save_to_db(&self, db: &DatabasePool) -> Result<(), RotationError> { + use crate::db::schema::tls_rotation_state::dsl::*; + + let mut conn = db.get().await.map_err(|e| { + RotationError::InvalidState(format!("Failed to get DB connection: {}", e)) + })?; + + let (state_str, cert_id, init_at, timeout) = match self { + RotationState::Normal => ("normal", None, None, None), + RotationState::RotationInitiated { + initiated_at: init, + new_cert_id: cert, + } => ("initiated", Some(*cert), Some(*init as i32), None), + RotationState::WaitingForAcks { + new_cert_id: cert, + initiated_at: init, + timeout_at: timeout_val, + } => ( + "waiting_acks", + Some(*cert), + Some(*init as i32), + Some(*timeout_val as i32), + ), + RotationState::ReadyToRotate { new_cert_id: cert } => ("ready", Some(*cert), None, None), + }; + + diesel::update(tls_rotation_state.filter(id.eq(1))) + .set(( + state.eq(state_str), + new_cert_id.eq(cert_id), + initiated_at.eq(init_at), + timeout_at.eq(timeout), + )) + .execute(&mut conn) + .await?; + + Ok(()) + } +} + +/// Фоновый таск для автоматической ротации сертификатов +pub struct RotationTask { + context: Arc, + check_interval: Duration, + rotation_threshold: Duration, + ack_timeout: Duration, + shutdown_rx: watch::Receiver, +} + +impl RotationTask { + /// Создать новый rotation task + pub fn new( + context: Arc, + check_interval: Duration, + rotation_threshold: Duration, + ack_timeout: Duration, + shutdown_rx: watch::Receiver, + ) -> Self { + Self { + context, + check_interval, + rotation_threshold, + ack_timeout, + shutdown_rx, + } + } + + /// Запустить фоновый таск мониторинга и ротации + pub async fn run(mut self) -> Result<(), RotationError> { + info!("Starting TLS certificate rotation task"); + + loop { + tokio::select! { + _ = tokio::time::sleep(self.check_interval) => { + if let Err(e) = self.check_and_process().await { + error!("Rotation task error: {}", e); + } + } + _ = self.shutdown_rx.changed() => { + info!("Rotation task shutting down"); + break; + } + } + } + + Ok(()) + } + + /// Проверить текущее состояние и выполнить необходимые действия + async fn check_and_process(&self) -> Result<(), RotationError> { + let state = self.context.rotation_state.read().await.clone(); + + match state { + RotationState::Normal => { + // Проверить, нужна ли ротация + self.check_expiration_and_initiate().await?; + } + RotationState::RotationInitiated { new_cert_id, .. } => { + // Автоматически перейти в WaitingForAcks + self.transition_to_waiting_acks(new_cert_id).await?; + } + RotationState::WaitingForAcks { + new_cert_id, + timeout_at, + .. + } => { + self.handle_waiting_for_acks(new_cert_id, timeout_at).await?; + } + RotationState::ReadyToRotate { new_cert_id } => { + self.execute_rotation(new_cert_id).await?; + } + } + + Ok(()) + } + + /// Проверить срок действия сертификата и инициировать ротацию если нужно + async fn check_expiration_and_initiate(&self) -> Result<(), RotationError> { + let threshold_secs = self.rotation_threshold.as_secs() as i64; + + if self.context.tls.check_expiration(threshold_secs).await { + info!("Certificate expiring soon, initiating rotation"); + self.initiate_rotation().await?; + } + + Ok(()) + } + + /// Инициировать ротацию: сгенерировать новый сертификат и сохранить в БД + pub async fn initiate_rotation(&self) -> Result { + info!("Initiating certificate rotation"); + + // 1. Генерация нового сертификата + let keypair = KeyPair::generate()?; + let (cert, not_before, not_after) = generate_cert(&keypair)?; + let cert_der = cert.der().clone(); + + // 2. Сохранение в БД (is_active = false, пока не активирован) + let new_cert_id = self + .save_new_certificate(&cert_der, &keypair, not_before, not_after) + .await?; + + info!(new_cert_id, "New certificate generated and saved"); + + // 3. Обновление rotation_state + let new_state = RotationState::RotationInitiated { + initiated_at: chrono::Utc::now().timestamp(), + new_cert_id, + }; + *self.context.rotation_state.write().await = new_state.clone(); + new_state.save_to_db(&self.context.db).await?; + + // 4. Логирование в audit trail + self.log_rotation_event(new_cert_id, "rotation_initiated", None) + .await?; + + Ok(new_cert_id) + } + + /// Перейти в состояние WaitingForAcks и разослать уведомления + async fn transition_to_waiting_acks(&self, new_cert_id: i32) -> Result<(), RotationError> { + info!(new_cert_id, "Transitioning to WaitingForAcks state"); + + let initiated_at = chrono::Utc::now().timestamp(); + let timeout_at = initiated_at + self.ack_timeout.as_secs() as i64; + + // Обновить состояние + let new_state = RotationState::WaitingForAcks { + new_cert_id, + initiated_at, + timeout_at, + }; + *self.context.rotation_state.write().await = new_state.clone(); + new_state.save_to_db(&self.context.db).await?; + + // TODO: Broadcast уведомлений клиентам + // self.broadcast_rotation_notification(new_cert_id, timeout_at).await?; + + info!(timeout_at, "Rotation notifications sent, waiting for ACKs"); + + Ok(()) + } + + /// Обработка состояния WaitingForAcks: проверка ACKs и таймаута + async fn handle_waiting_for_acks( + &self, + new_cert_id: i32, + timeout_at: i64, + ) -> Result<(), RotationError> { + let now = chrono::Utc::now().timestamp(); + + // Проверить таймаут + if now > timeout_at { + let missing = self.get_missing_acks(new_cert_id).await?; + warn!( + missing_count = missing.len(), + "Rotation ACK timeout reached, proceeding with rotation" + ); + + // Переход в ReadyToRotate + let new_state = RotationState::ReadyToRotate { new_cert_id }; + *self.context.rotation_state.write().await = new_state.clone(); + new_state.save_to_db(&self.context.db).await?; + + self.log_rotation_event( + new_cert_id, + "timeout", + Some(format!("Missing ACKs from {} clients", missing.len())), + ) + .await?; + + return Ok(()); + } + + // Проверить, все ли ACK получены + let missing = self.get_missing_acks(new_cert_id).await?; + + if missing.is_empty() { + info!("All clients acknowledged, ready to rotate"); + + let new_state = RotationState::ReadyToRotate { new_cert_id }; + *self.context.rotation_state.write().await = new_state.clone(); + new_state.save_to_db(&self.context.db).await?; + + self.log_rotation_event(new_cert_id, "acks_complete", None) + .await?; + } else { + let time_remaining = timeout_at - now; + debug!( + missing_count = missing.len(), + time_remaining, + "Waiting for rotation ACKs" + ); + } + + Ok(()) + } + + /// Выполнить атомарную ротацию сертификата + async fn execute_rotation(&self, new_cert_id: i32) -> Result<(), RotationError> { + info!(new_cert_id, "Executing certificate rotation"); + + // 1. Загрузить новый сертификат из БД + let new_cert = self.load_certificate(new_cert_id).await?; + + // 2. Атомарная замена в TlsManager + self.context + .tls + .replace_certificate(new_cert) + .await + .map_err(RotationError::TlsInit)?; + + // 3. Обновить БД: старый is_active=false, новый is_active=true + self.activate_certificate(new_cert_id).await?; + + // 4. TODO: Отключить всех клиентов + // self.disconnect_all_clients().await?; + + // 5. Очистить rotation_state + let new_state = RotationState::Normal; + *self.context.rotation_state.write().await = new_state.clone(); + new_state.save_to_db(&self.context.db).await?; + + // 6. Очистить ACKs + self.context.rotation_acks.write().await.clear(); + self.clear_rotation_acks(new_cert_id).await?; + + // 7. Логирование + self.log_rotation_event(new_cert_id, "activated", None) + .await?; + + info!(new_cert_id, "Certificate rotation completed successfully"); + + Ok(()) + } + + /// Сохранить новый сертификат в БД + async fn save_new_certificate( + &self, + cert_der: &[u8], + keypair: &KeyPair, + cert_not_before: i64, + cert_not_after: i64, + ) -> Result { + use crate::db::schema::tls_certificates::dsl::*; + + let mut conn = self.context.db.get().await.map_err(|e| { + RotationError::InvalidState(format!("Failed to get DB connection: {}", e)) + })?; + + let new_cert = NewTlsCertificate { + cert: cert_der.to_vec(), + cert_key: keypair.serialize_pem().as_bytes().to_vec(), + not_before: cert_not_before as i32, + not_after: cert_not_after as i32, + is_active: false, + }; + + diesel::insert_into(tls_certificates) + .values(&new_cert) + .execute(&mut conn) + .await?; + + // Получить ID последней вставленной записи + let cert_id: i32 = diesel::select(diesel::dsl::sql::( + "last_insert_rowid()", + )) + .first(&mut conn) + .await?; + + self.log_rotation_event(cert_id, "created", None).await?; + + Ok(cert_id) + } + + /// Загрузить сертификат из БД + async fn load_certificate(&self, cert_id: i32) -> Result { + use crate::db::schema::tls_certificates::dsl::*; + + let mut conn = self.context.db.get().await.map_err(|e| { + RotationError::InvalidState(format!("Failed to get DB connection: {}", e)) + })?; + + let cert_record: (Vec, Vec, i32, i32, i32) = tls_certificates + .select((cert, cert_key, not_before, not_after, created_at)) + .filter(id.eq(cert_id)) + .first(&mut conn) + .await?; + + let cert_der = rustls::pki_types::CertificateDer::from(cert_record.0); + let key_pem = String::from_utf8(cert_record.1) + .map_err(|e| RotationError::InvalidState(format!("Invalid key encoding: {}", e)))?; + let keypair = KeyPair::from_pem(&key_pem)?; + + Ok(CertificateMetadata { + cert_id, + cert: cert_der, + keypair: Arc::new(keypair), + not_before: cert_record.2 as i64, + not_after: cert_record.3 as i64, + created_at: cert_record.4 as i64, + }) + } + + /// Активировать сертификат (установить is_active=true) + async fn activate_certificate(&self, cert_id: i32) -> Result<(), RotationError> { + use crate::db::schema::tls_certificates::dsl::*; + + let mut conn = self.context.db.get().await.map_err(|e| { + RotationError::InvalidState(format!("Failed to get DB connection: {}", e)) + })?; + + // Деактивировать все сертификаты + diesel::update(tls_certificates) + .set(is_active.eq(false)) + .execute(&mut conn) + .await?; + + // Активировать новый + diesel::update(tls_certificates.filter(id.eq(cert_id))) + .set(is_active.eq(true)) + .execute(&mut conn) + .await?; + + Ok(()) + } + + /// Получить список клиентов, которые ещё не отправили ACK + async fn get_missing_acks(&self, rotation_id: i32) -> Result, RotationError> { + // TODO: Реализовать получение списка всех активных клиентов + // и вычитание тех, кто уже отправил ACK + + // Пока возвращаем пустой список + Ok(Vec::new()) + } + + /// Очистить ACKs для данной ротации из БД + async fn clear_rotation_acks(&self, rotation_id: i32) -> Result<(), RotationError> { + use crate::db::schema::rotation_client_acks::dsl::*; + + let mut conn = self.context.db.get().await.map_err(|e| { + RotationError::InvalidState(format!("Failed to get DB connection: {}", e)) + })?; + + diesel::delete(rotation_client_acks.filter(rotation_id.eq(rotation_id))) + .execute(&mut conn) + .await?; + + Ok(()) + } + + /// Записать событие в audit trail + async fn log_rotation_event( + &self, + history_cert_id: i32, + history_event_type: &str, + history_details: Option, + ) -> Result<(), RotationError> { + use crate::db::schema::tls_rotation_history::dsl::*; + + let mut conn = self.context.db.get().await.map_err(|e| { + RotationError::InvalidState(format!("Failed to get DB connection: {}", e)) + })?; + + let new_history = NewTlsRotationHistory { + cert_id: history_cert_id, + event_type: history_event_type.to_string(), + details: history_details, + }; + + diesel::insert_into(tls_rotation_history) + .values(&new_history) + .execute(&mut conn) + .await?; + + Ok(()) + } +} diff --git a/server/crates/arbiter-server/src/crypto/aead.rs b/server/crates/arbiter-server/src/crypto/aead.rs new file mode 100644 index 0000000..025c166 --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/aead.rs @@ -0,0 +1,139 @@ +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Key, Nonce, +}; + +use super::CryptoError; + +/// Encrypt plaintext with AEAD (ChaCha20Poly1305) +/// +/// Returns (ciphertext, tag) on success +pub fn encrypt( + plaintext: &[u8], + key: &[u8; 32], + nonce: &[u8; 12], +) -> Result, CryptoError> { + let cipher_key = Key::from_slice(key); + let cipher = ChaCha20Poly1305::new(cipher_key); + let nonce_array = Nonce::from_slice(nonce); + + cipher + .encrypt(nonce_array, plaintext) + .map_err(|e| CryptoError::AeadEncryption(e.to_string())) +} + +/// Decrypt ciphertext with AEAD (ChaCha20Poly1305) +/// +/// The ciphertext должен содержать tag (последние 16 bytes) +pub fn decrypt( + ciphertext_with_tag: &[u8], + key: &[u8; 32], + nonce: &[u8; 12], +) -> Result, CryptoError> { + let cipher_key = Key::from_slice(key); + let cipher = ChaCha20Poly1305::new(cipher_key); + let nonce_array = Nonce::from_slice(nonce); + + cipher + .decrypt(nonce_array, ciphertext_with_tag) + .map_err(|e| CryptoError::AeadDecryption(e.to_string())) +} + +/// Generate nonce from counter +/// +/// Converts i32 counter to 12-byte nonce (big-endian encoding) +pub fn nonce_from_counter(counter: i32) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[8..12].copy_from_slice(&counter.to_be_bytes()); + nonce +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aead_encrypt_decrypt_round_trip() { + let plaintext = b"Hello, World! This is a secret message."; + let key = [42u8; 32]; + let nonce = nonce_from_counter(1); + + // Encrypt + let ciphertext = encrypt(plaintext, &key, &nonce).expect("Encryption failed"); + + // Verify ciphertext is different from plaintext + assert_ne!(ciphertext.as_slice(), plaintext); + + // Decrypt + let decrypted = decrypt(&ciphertext, &key, &nonce).expect("Decryption failed"); + + // Verify round-trip + assert_eq!(decrypted.as_slice(), plaintext); + } + + #[test] + fn test_aead_decrypt_with_wrong_key() { + let plaintext = b"Secret data"; + let key = [1u8; 32]; + let wrong_key = [2u8; 32]; + let nonce = nonce_from_counter(1); + + let ciphertext = encrypt(plaintext, &key, &nonce).expect("Encryption failed"); + + // Attempt decrypt with wrong key + let result = decrypt(&ciphertext, &wrong_key, &nonce); + + // Should fail + assert!(result.is_err()); + } + + #[test] + fn test_aead_decrypt_with_wrong_nonce() { + let plaintext = b"Secret data"; + let key = [1u8; 32]; + let nonce = nonce_from_counter(1); + let wrong_nonce = nonce_from_counter(2); + + let ciphertext = encrypt(plaintext, &key, &nonce).expect("Encryption failed"); + + // Attempt decrypt with wrong nonce + let result = decrypt(&ciphertext, &key, &wrong_nonce); + + // Should fail + assert!(result.is_err()); + } + + #[test] + fn test_nonce_generation_from_counter() { + let nonce1 = nonce_from_counter(1); + let nonce2 = nonce_from_counter(2); + let nonce_max = nonce_from_counter(i32::MAX); + + // Verify nonces are different + assert_ne!(nonce1, nonce2); + + // Verify nonce format (first 8 bytes should be zero, last 4 contain counter) + assert_eq!(&nonce1[0..8], &[0u8; 8]); + assert_eq!(&nonce1[8..12], &1i32.to_be_bytes()); + + assert_eq!(&nonce_max[8..12], &i32::MAX.to_be_bytes()); + } + + #[test] + fn test_aead_tampered_ciphertext() { + let plaintext = b"Important message"; + let key = [7u8; 32]; + let nonce = nonce_from_counter(5); + + let mut ciphertext = encrypt(plaintext, &key, &nonce).expect("Encryption failed"); + + // Tamper with ciphertext (flip a bit) + if let Some(byte) = ciphertext.get_mut(5) { + *byte ^= 0x01; + } + + // Attempt decrypt - should fail due to authentication tag mismatch + let result = decrypt(&ciphertext, &key, &nonce); + assert!(result.is_err()); + } +} diff --git a/server/crates/arbiter-server/src/crypto/mod.rs b/server/crates/arbiter-server/src/crypto/mod.rs new file mode 100644 index 0000000..d33f0b1 --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/mod.rs @@ -0,0 +1,28 @@ +pub mod aead; +pub mod root_key; + +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Error, Debug, Diagnostic)] +pub enum CryptoError { + #[error("AEAD encryption failed: {0}")] + #[diagnostic(code(arbiter_server::crypto::aead_encryption))] + AeadEncryption(String), + + #[error("AEAD decryption failed: {0}")] + #[diagnostic(code(arbiter_server::crypto::aead_decryption))] + AeadDecryption(String), + + #[error("Key derivation failed: {0}")] + #[diagnostic(code(arbiter_server::crypto::key_derivation))] + KeyDerivation(String), + + #[error("Invalid nonce: {0}")] + #[diagnostic(code(arbiter_server::crypto::invalid_nonce))] + InvalidNonce(String), + + #[error("Invalid key format: {0}")] + #[diagnostic(code(arbiter_server::crypto::invalid_key))] + InvalidKey(String), +} diff --git a/server/crates/arbiter-server/src/crypto/root_key.rs b/server/crates/arbiter-server/src/crypto/root_key.rs new file mode 100644 index 0000000..187bd91 --- /dev/null +++ b/server/crates/arbiter-server/src/crypto/root_key.rs @@ -0,0 +1,239 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, PasswordHash, PasswordVerifier, +}; + +use crate::db::models::AeadEncrypted; + +use super::{aead, CryptoError}; + +/// Encrypt root key with user password +/// +/// Uses Argon2id for password derivation and ChaCha20Poly1305 for encryption +pub fn encrypt_root_key( + root_key: &[u8; 32], + password: &str, + nonce_counter: i32, +) -> Result<(AeadEncrypted, String), CryptoError> { + // Derive key from password using Argon2 + let (derived_key, salt) = derive_key_from_password(password)?; + + // Generate nonce from counter + let nonce = aead::nonce_from_counter(nonce_counter); + + // Encrypt root key + let ciphertext_with_tag = aead::encrypt(root_key, &derived_key, &nonce)?; + + // Extract tag (last 16 bytes) + let tag_start = ciphertext_with_tag + .len() + .checked_sub(16) + .ok_or_else(|| CryptoError::AeadEncryption("Ciphertext too short".into()))?; + + let ciphertext = ciphertext_with_tag[..tag_start].to_vec(); + let tag = ciphertext_with_tag[tag_start..].to_vec(); + + let aead_encrypted = AeadEncrypted { + id: 1, // Will be set by database + current_nonce: nonce_counter, + ciphertext, + tag, + schema_version: 1, // Current version + }; + + Ok((aead_encrypted, salt)) +} + +/// Decrypt root key with user password +/// +/// Verifies password hash and decrypts using ChaCha20Poly1305 +pub fn decrypt_root_key( + encrypted: &AeadEncrypted, + password: &str, + salt: &str, +) -> Result<[u8; 32], CryptoError> { + // Derive key from password using stored salt + let derived_key = derive_key_with_salt(password, salt)?; + + // Generate nonce from counter + let nonce = aead::nonce_from_counter(encrypted.current_nonce); + + // Reconstruct ciphertext with tag + let mut ciphertext_with_tag = encrypted.ciphertext.clone(); + ciphertext_with_tag.extend_from_slice(&encrypted.tag); + + // Decrypt + let plaintext = aead::decrypt(&ciphertext_with_tag, &derived_key, &nonce)?; + + // Verify length + if plaintext.len() != 32 { + return Err(CryptoError::InvalidKey(format!( + "Expected 32 bytes, got {}", + plaintext.len() + ))); + } + + // Convert to fixed-size array + let mut root_key = [0u8; 32]; + root_key.copy_from_slice(&plaintext); + + Ok(root_key) +} + +/// Derive 32-byte key from password using Argon2id +/// +/// Generates new random salt and returns (derived_key, salt_string) +fn derive_key_from_password(password: &str) -> Result<([u8; 32], String), CryptoError> { + let salt = SaltString::generate(&mut OsRng); + + let argon2 = Argon2::default(); + + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| CryptoError::KeyDerivation(e.to_string()))?; + + // Extract hash output (32 bytes) + let hash_output = password_hash + .hash + .ok_or_else(|| CryptoError::KeyDerivation("No hash output".into()))?; + + let hash_bytes = hash_output.as_bytes(); + + if hash_bytes.len() != 32 { + return Err(CryptoError::KeyDerivation(format!( + "Expected 32 bytes, got {}", + hash_bytes.len() + ))); + } + + let mut key = [0u8; 32]; + key.copy_from_slice(hash_bytes); + + Ok((key, salt.to_string())) +} + +/// Derive 32-byte key from password using existing salt +fn derive_key_with_salt(password: &str, salt_str: &str) -> Result<[u8; 32], CryptoError> { + let argon2 = Argon2::default(); + + // Parse salt + let salt = + SaltString::from_b64(salt_str).map_err(|e| CryptoError::InvalidKey(e.to_string()))?; + + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| CryptoError::KeyDerivation(e.to_string()))?; + + // Extract hash output + let hash_output = password_hash + .hash + .ok_or_else(|| CryptoError::KeyDerivation("No hash output".into()))?; + + let hash_bytes = hash_output.as_bytes(); + + if hash_bytes.len() != 32 { + return Err(CryptoError::KeyDerivation(format!( + "Expected 32 bytes, got {}", + hash_bytes.len() + ))); + } + + let mut key = [0u8; 32]; + key.copy_from_slice(hash_bytes); + + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_root_key_encrypt_decrypt_round_trip() { + let root_key = [42u8; 32]; + let password = "super_secret_password_123"; + let nonce_counter = 1; + + // Encrypt + let (encrypted, salt) = + encrypt_root_key(&root_key, password, nonce_counter).expect("Encryption failed"); + + // Verify structure + assert_eq!(encrypted.current_nonce, nonce_counter); + assert_eq!(encrypted.schema_version, 1); + assert_eq!(encrypted.tag.len(), 16); // AEAD tag size + + // Decrypt + let decrypted = + decrypt_root_key(&encrypted, password, &salt).expect("Decryption failed"); + + // Verify round-trip + assert_eq!(decrypted, root_key); + } + + #[test] + fn test_decrypt_with_wrong_password() { + let root_key = [99u8; 32]; + let correct_password = "correct_password"; + let wrong_password = "wrong_password"; + let nonce_counter = 1; + + // Encrypt with correct password + let (encrypted, salt) = + encrypt_root_key(&root_key, correct_password, nonce_counter).expect("Encryption failed"); + + // Attempt decrypt with wrong password + let result = decrypt_root_key(&encrypted, wrong_password, &salt); + + // Should fail due to authentication tag mismatch + assert!(result.is_err()); + } + + #[test] + fn test_password_derivation_different_salts() { + let password = "same_password"; + + // Derive key twice - should produce different salts + let (key1, salt1) = derive_key_from_password(password).expect("Derivation 1 failed"); + let (key2, salt2) = derive_key_from_password(password).expect("Derivation 2 failed"); + + // Salts should be different (randomly generated) + assert_ne!(salt1, salt2); + + // Keys should be different (due to different salts) + assert_ne!(key1, key2); + } + + #[test] + fn test_password_derivation_with_same_salt() { + let password = "test_password"; + + // Generate key and salt + let (key1, salt) = derive_key_from_password(password).expect("Derivation failed"); + + // Derive key again with same salt + let key2 = derive_key_with_salt(password, &salt).expect("Re-derivation failed"); + + // Keys should be identical + assert_eq!(key1, key2); + } + + #[test] + fn test_different_nonce_produces_different_ciphertext() { + let root_key = [77u8; 32]; + let password = "password123"; + + let (encrypted1, salt1) = encrypt_root_key(&root_key, password, 1).expect("Encryption 1 failed"); + let (encrypted2, salt2) = encrypt_root_key(&root_key, password, 2).expect("Encryption 2 failed"); + + // Different nonces should produce different ciphertexts + assert_ne!(encrypted1.ciphertext, encrypted2.ciphertext); + + // But both should decrypt correctly + let decrypted1 = decrypt_root_key(&encrypted1, password, &salt1).expect("Decryption 1 failed"); + let decrypted2 = decrypt_root_key(&encrypted2, password, &salt2).expect("Decryption 2 failed"); + + assert_eq!(decrypted1, root_key); + assert_eq!(decrypted2, root_key); + } +} diff --git a/server/crates/arbiter-server/src/db.rs b/server/crates/arbiter-server/src/db.rs index c44d489..9df963c 100644 --- a/server/crates/arbiter-server/src/db.rs +++ b/server/crates/arbiter-server/src/db.rs @@ -22,7 +22,7 @@ pub type DatabasePool = diesel_async::pooled_connection::bb8::Pool, // references aead_encrypted.id pub cert_key: Vec, pub cert: Vec, + pub current_cert_id: Option, // references tls_certificates.id } #[derive(Queryable, Debug)] @@ -47,3 +48,70 @@ pub struct UseragentClient { pub created_at: i32, pub updated_at: i32, } + +// TLS Certificate Rotation Models + +#[derive(Queryable, Debug, Insertable)] +#[diesel(table_name = schema::tls_certificates, check_for_backend(Sqlite))] +pub struct TlsCertificate { + pub id: i32, + pub cert: Vec, + pub cert_key: Vec, + pub not_before: i32, + pub not_after: i32, + pub created_at: i32, + pub is_active: bool, +} + +#[derive(Insertable)] +#[diesel(table_name = schema::tls_certificates)] +pub struct NewTlsCertificate { + pub cert: Vec, + pub cert_key: Vec, + pub not_before: i32, + pub not_after: i32, + pub is_active: bool, +} + +#[derive(Queryable, Debug, Insertable)] +#[diesel(table_name = schema::tls_rotation_state, check_for_backend(Sqlite))] +pub struct TlsRotationState { + pub id: i32, + pub state: String, + pub new_cert_id: Option, + pub initiated_at: Option, + pub timeout_at: Option, +} + +#[derive(Queryable, Debug, Insertable)] +#[diesel(table_name = schema::rotation_client_acks, check_for_backend(Sqlite))] +pub struct RotationClientAck { + pub rotation_id: i32, + pub client_key: String, + pub ack_received_at: i32, +} + +#[derive(Insertable)] +#[diesel(table_name = schema::rotation_client_acks)] +pub struct NewRotationClientAck { + pub rotation_id: i32, + pub client_key: String, +} + +#[derive(Queryable, Debug, Insertable)] +#[diesel(table_name = schema::tls_rotation_history, check_for_backend(Sqlite))] +pub struct TlsRotationHistory { + pub id: i32, + pub cert_id: i32, + pub event_type: String, + pub timestamp: i32, + pub details: Option, +} + +#[derive(Insertable)] +#[diesel(table_name = schema::tls_rotation_history)] +pub struct NewTlsRotationHistory { + pub cert_id: i32, + pub event_type: String, + pub details: Option, +} diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index 38f8afe..f560547 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -16,6 +16,7 @@ diesel::table! { root_key_id -> Nullable, cert_key -> Binary, cert -> Binary, + current_cert_id -> Nullable, } } @@ -39,11 +40,59 @@ diesel::table! { } } +diesel::table! { + tls_certificates (id) { + id -> Integer, + cert -> Binary, + cert_key -> Binary, + not_before -> Integer, + not_after -> Integer, + created_at -> Integer, + is_active -> Bool, + } +} + +diesel::table! { + tls_rotation_state (id) { + id -> Integer, + state -> Text, + new_cert_id -> Nullable, + initiated_at -> Nullable, + timeout_at -> Nullable, + } +} + +diesel::table! { + rotation_client_acks (rotation_id, client_key) { + rotation_id -> Integer, + client_key -> Text, + ack_received_at -> Integer, + } +} + +diesel::table! { + tls_rotation_history (id) { + id -> Integer, + cert_id -> Integer, + event_type -> Text, + timestamp -> Integer, + details -> Nullable, + } +} + diesel::joinable!(arbiter_settings -> aead_encrypted (root_key_id)); +diesel::joinable!(arbiter_settings -> tls_certificates (current_cert_id)); +diesel::joinable!(tls_rotation_state -> tls_certificates (new_cert_id)); +diesel::joinable!(rotation_client_acks -> tls_certificates (rotation_id)); +diesel::joinable!(tls_rotation_history -> tls_certificates (cert_id)); diesel::allow_tables_to_appear_in_same_query!( aead_encrypted, arbiter_settings, program_client, useragent_client, + tls_certificates, + tls_rotation_state, + rotation_client_acks, + tls_rotation_history, ); diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index 921bb8e..57f8a64 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -19,6 +19,7 @@ use crate::{ pub mod actors; mod context; +mod crypto; mod db; mod errors;