diff --git a/Cargo.lock b/Cargo.lock index 2c8a744c..1a1b0e1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,51 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aegis" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78412fa53e6da95324e8902c3641b3ff32ab45258582ea997eb9169c68ffa219" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -77,9 +122,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.14" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -118,9 +163,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ "rustversion", ] @@ -391,9 +436,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.97.0" +version = "1.96.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567" +checksum = "f64a6eded248c6b453966e915d32aeddb48ea63ad17932682774eb026fbef5b1" dependencies = [ "aws-credential-types", "aws-runtime", @@ -415,9 +460,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.99.0" +version = "1.98.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1342a7db8f358d3de0aed2007a0b54e875458e39848d54cc1d46700b2bfcb0a8" +checksum = "db96d720d3c622fcbe08bae1c4b04a72ce6257d8b0584cb5418da00ae20a344f" dependencies = [ "aws-credential-types", "aws-runtime", @@ -439,9 +484,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.101.0" +version = "1.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a" +checksum = "fafbdda43b93f57f699c5dfe8328db590b967b8a820a13ccdd6687355dfcc7ca" dependencies = [ "aws-credential-types", "aws-runtime", @@ -612,9 +657,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.4.7" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +checksum = "d2b1117b3b2bbe166d11199b540ceed0d0f7676e36e7b962b5a437a9971eac75" dependencies = [ "base64-simd", "bytes", @@ -1011,6 +1056,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -1047,9 +1102,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cmake" @@ -1089,9 +1144,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" @@ -1148,9 +1203,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.22" +version = "0.15.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" dependencies = [ "async-trait", "convert_case 0.6.0", @@ -1161,19 +1216,20 @@ dependencies = [ "serde-untagged", "serde_core", "serde_json", - "toml 1.0.7+spec-1.1.0", - "winnow 1.0.0", + "toml 0.9.12+spec-1.1.0", + "winnow 0.7.15", "yaml-rust2", ] [[package]] name = "console" -version = "0.16.3" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" dependencies = [ "encode_unicode", "libc", + "once_cell", "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -1330,6 +1386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1343,6 +1400,15 @@ dependencies = [ "phf", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.14.4" @@ -1724,9 +1790,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.14" +version = "0.22.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" dependencies = [ "num-traits", ] @@ -2185,6 +2251,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2621,11 +2697,20 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" -version = "0.3.12" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ "darling 0.23.0", "indoc", @@ -2673,9 +2758,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.18" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jobserver" @@ -2720,9 +2805,9 @@ dependencies = [ [[package]] name = "kasuari" -version = "0.4.12" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" dependencies = [ "hashbrown 0.16.1", "portable-atomic", @@ -2749,9 +2834,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libmimalloc-sys" @@ -3265,9 +3350,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.4" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" @@ -3275,6 +3360,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3592,6 +3683,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3668,7 +3771,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.5+spec-1.1.0", + "toml_edit 0.25.4+spec-1.1.0", ] [[package]] @@ -3789,7 +3892,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools", "log", "multimap", @@ -3902,9 +4005,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", "getrandom 0.3.4", @@ -4610,7 +4713,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "toml 1.0.7+spec-1.1.0", + "toml 1.0.4+spec-1.1.0", "tower-http", "tracing", "tracing-subscriber", @@ -4622,7 +4725,10 @@ dependencies = [ name = "s2-common" version = "0.30.0" dependencies = [ + "aegis", + "aes-gcm", "axum", + "base64ct", "blake3", "bytes", "clap", @@ -4631,8 +4737,10 @@ dependencies = [ "enumset", "http 1.4.0", "proptest", + "rand 0.10.0", "rkyv", "rstest", + "secrecy", "serde", "serde_json", "strum 0.28.0", @@ -4750,9 +4858,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ "windows-sys 0.61.2", ] @@ -5152,14 +5260,20 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] +[[package]] +name = "softaes" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef461faaeb36c340b6c887167a9054a034f6acfc50a014ead26a02b4356b3de" + [[package]] name = "spin" version = "0.9.8" @@ -5436,9 +5550,9 @@ dependencies = [ [[package]] name = "test-context" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3c85fe78076e2c55ed2dbeffd7a093effc1f906f8f55d17eb07736c2babcc95" +checksum = "7d94db16dc1c321805ce55f286c4023fa58a2c9c742568f95c5cfe2e95d250d7" dependencies = [ "futures", "test-context-macros", @@ -5446,9 +5560,9 @@ dependencies = [ [[package]] name = "test-context-macros" -version = "0.5.5" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0279b838142e2c96bf194427ec2af755fb6a86ff2ae0fa99ac7da287d0066f9" +checksum = "aabcca9d2cad192cfe258cd3562b7584516191a5c9b6a0002a6bb8b75ee7d21d" dependencies = [ "proc-macro2", "quote", @@ -5577,9 +5691,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -5715,17 +5829,17 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.7+spec-1.1.0" +version = "1.0.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +checksum = "c94c3321114413476740df133f0d8862c61d87c8d26f04c6841e033c8c80db47" dependencies = [ "indexmap", "serde_core", "serde_spanned 1.0.4", - "toml_datetime 1.0.1+spec-1.1.0", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.0", + "winnow 0.7.15", ] [[package]] @@ -5748,9 +5862,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] @@ -5771,23 +5885,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.5+spec-1.1.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", - "toml_datetime 1.0.1+spec-1.1.0", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", - "winnow 1.0.0", + "winnow 0.7.15", ] [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "winnow 1.0.0", + "winnow 0.7.15", ] [[package]] @@ -5798,9 +5912,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.7+spec-1.1.0" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" @@ -5900,9 +6014,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.23" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -6028,6 +6142,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6750,15 +6874,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -6934,18 +7049,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index b83e5be7..7947ac21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ repository = "https://github.com/s2-streamstore/s2" homepage = "https://s2.dev" [workspace.dependencies] +aegis = "0.9" +aes-gcm = "0.10" async-stream = "0.3" async-trait = "0.1" aws-config = "1" @@ -31,6 +33,7 @@ enumset = "1.1" eyre = "0.6" flate2 = "1.1" futures = "0.3" +hex = "0.4" http = "1.4" humantime = "2.3" indexmap = "2.13" @@ -55,6 +58,7 @@ s2-common = { path = "common", version = "0.30" } s2-lite = { path = "lite", version = "0.29" } s2-sdk = { path = "sdk", version = "0.26" } schemars = "1.2" +secrecy = "0.10.3" serde = "1.0" serde_json = "1.0" slatedb = "0.11.2" diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 05e181c9..c5584cbe 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -12,7 +12,7 @@ use crate::{ parse_records_output_source, }, types::{ - AccessTokenMatcher, BasinConfig, BasinMatcher, Interval, Operation, + AccessTokenMatcher, BasinConfig, BasinMatcher, EncryptionAlgorithm, Interval, Operation, PermittedOperationGroups, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, StreamMatcher, }, @@ -464,6 +464,55 @@ pub struct AppendArgs { /// How long to wait for more records before flushing a batch. #[arg(long, default_value = "5ms")] pub linger: humantime::Duration, + + #[command(flatten)] + pub encryption: EncryptionArgs, +} + +#[derive(Args, Debug, Clone, Default)] +#[group(multiple = true)] +pub struct EncryptionArgs { + /// Base64-encoded 32-byte encryption key. Alternatively, set S2_ENCRYPTION_KEY env var. + #[arg( + long, + env = "S2_ENCRYPTION_KEY", + hide_env_values = true, + group = "encryption_key_source", + requires = "encryption_algorithm" + )] + pub encryption_key: Option, + + /// Read encryption key from file. + #[arg( + long, + conflicts_with = "encryption_key", + group = "encryption_key_source", + requires = "encryption_algorithm" + )] + pub encryption_key_file: Option, + + /// Encryption algorithm. + #[arg(long, value_enum, requires = "encryption_key_source")] + pub encryption_algorithm: Option, + + /// Attest client-side encryption. + #[arg(long, conflicts_with_all = ["encryption_key", "encryption_key_file", "encryption_algorithm"])] + pub encryption_attest: bool, +} + +#[derive(Args, Debug, Clone, Default)] +pub struct DecryptionArgs { + /// Base64-encoded 32-byte decryption key. Alternatively, set S2_ENCRYPTION_KEY env var. + #[arg( + long = "encryption-key", + env = "S2_ENCRYPTION_KEY", + hide_env_values = true + )] + pub encryption_key: Option, + + /// Read decryption key from file. + #[arg(long = "encryption-key-file", conflicts_with = "encryption_key")] + pub encryption_key_file: Option, } #[derive(Args, Debug)] @@ -515,6 +564,9 @@ pub struct ReadArgs { /// Use "-" to write to stdout. #[arg(short = 'o', long, value_parser = parse_records_output_source, default_value = "-")] pub output: RecordsOut, + + #[command(flatten)] + pub encryption: DecryptionArgs, } #[derive(Args, Debug)] @@ -539,6 +591,9 @@ pub struct TailArgs { /// Use "-" to write to stdout. #[arg(short = 'o', long, value_parser = parse_records_output_source, default_value = "-")] pub output: RecordsOut, + + #[command(flatten)] + pub encryption: DecryptionArgs, } #[derive(Args, Debug)] diff --git a/cli/src/error.rs b/cli/src/error.rs index a7be6d7f..d4e8f7f6 100644 --- a/cli/src/error.rs +++ b/cli/src/error.rs @@ -66,6 +66,10 @@ pub enum CliError { ))] OperationWithTokenSource(OpKind, #[source] S2Error, TokenSource), + #[error("Invalid encryption key: {0}")] + #[diagnostic(help("Key must be a base64-encoded 32-byte value."))] + InvalidEncryptionKey(String), + #[error("S2 Lite server error: {0}")] #[diagnostic(help("{}", HELP))] LiteServer(String), diff --git a/cli/src/main.rs b/cli/src/main.rs index a6e2d77f..8f0bcd16 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -32,9 +32,9 @@ use record_format::{ use s2_sdk::{ S2, types::{ - AppendRetryPolicy, CreateStreamInput, DeleteOnEmptyConfig, DeleteStreamInput, MeteredBytes, - Metric, RetentionPolicy, RetryConfig, StreamConfig as SdkStreamConfig, StreamName, - TimestampingConfig, TimestampingMode, + AppendRetryPolicy, CreateStreamInput, DeleteOnEmptyConfig, DeleteStreamInput, + EncryptionConfig, MeteredBytes, Metric, RetentionPolicy, RetryConfig, + StreamConfig as SdkStreamConfig, StreamName, TimestampingConfig, TimestampingMode, }, }; use strum::VariantNames; @@ -151,7 +151,10 @@ async fn run() -> Result<(), CliError> { } let cli_config = load_cli_config()?; - let sdk_config = sdk_config(&cli_config)?; + let mut sdk_config = sdk_config(&cli_config)?; + if let Some(enc) = resolve_command_encryption(&command)? { + sdk_config = sdk_config.with_encryption(enc); + } let s2 = S2::new(sdk_config.clone()).map_err(CliError::SdkInit)?; let token_source = access_token_source(&cli_config); let result: Result<(), CliError> = (async { @@ -796,3 +799,78 @@ fn print_metrics(metrics: &[Metric]) { } } } + +fn resolve_command_encryption(command: &Command) -> Result, CliError> { + match command { + Command::Append(a) => resolve_encryption(&a.encryption), + Command::Read(a) => resolve_decryption(&a.encryption), + Command::Tail(a) => resolve_decryption(&a.encryption), + _ => Ok(None), + } +} + +fn resolve_key( + key: &Option, + key_file: &Option, +) -> Result, CliError> { + match (key, key_file) { + (Some(k), _) => Ok(Some(k.clone())), + (_, Some(path)) => { + let contents = std::fs::read_to_string(path).map_err(|e| { + CliError::InvalidEncryptionKey(format!("cannot read key file: {e}")) + })?; + Ok(Some( + contents.lines().next().unwrap_or("").trim().to_owned(), + )) + } + _ => Ok(None), + } +} + +fn validate_base64_key(key: &str) -> Result<(), CliError> { + use base64ct::{Base64, Encoding}; + let bytes = Base64::decode_vec(key) + .map_err(|_| CliError::InvalidEncryptionKey("key is not valid base64".to_owned()))?; + if bytes.len() != 32 { + return Err(CliError::InvalidEncryptionKey(format!( + "key must be exactly 32 bytes, got {} bytes", + bytes.len() + ))); + } + Ok(()) +} + +fn resolve_encryption(args: &cli::EncryptionArgs) -> Result, CliError> { + resolve_encryption_config( + args.encryption_attest, + &args.encryption_key, + &args.encryption_key_file, + args.encryption_algorithm.map(Into::into), + ) +} + +fn resolve_decryption(args: &cli::DecryptionArgs) -> Result, CliError> { + resolve_encryption_config(false, &args.encryption_key, &args.encryption_key_file, None) +} + +fn resolve_encryption_config( + attest: bool, + key: &Option, + key_file: &Option, + alg: Option, +) -> Result, CliError> { + if attest { + return Ok(Some(EncryptionConfig::Attest)); + } + + let Some(key) = resolve_key(key, key_file)? else { + return Ok(None); + }; + + validate_base64_key(&key)?; + + Ok(Some(EncryptionConfig::Key { + alg, + key: key.into(), + })) +} diff --git a/cli/src/tui/app.rs b/cli/src/tui/app.rs index 7469462a..b29bc8f1 100644 --- a/cli/src/tui/app.rs +++ b/cli/src/tui/app.rs @@ -4607,6 +4607,7 @@ impl App { until: None, format: RecordFormat::default(), output: RecordsOut::Stdout, + encryption: Default::default(), }; match ops::read(&s2, &args).await { @@ -4679,6 +4680,7 @@ impl App { until: None, format: RecordFormat::default(), output: RecordsOut::Stdout, + encryption: Default::default(), }; match ops::read(&s2, &args).await { @@ -4842,6 +4844,7 @@ impl App { until, format: record_format, output: output.clone(), + encryption: Default::default(), }; // Open file writer if output file is specified diff --git a/cli/src/types.rs b/cli/src/types.rs index e828ca0f..bfc22805 100644 --- a/cli/src/types.rs +++ b/cli/src/types.rs @@ -169,6 +169,33 @@ impl StreamConfig { } } +#[derive(ValueEnum, Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum EncryptionAlgorithm { + #[value(name = "aegis-256")] + Aegis256, + #[value(name = "aes-256-gcm")] + Aes256Gcm, +} + +impl From for sdk::types::EncryptionAlgorithm { + fn from(alg: EncryptionAlgorithm) -> Self { + match alg { + EncryptionAlgorithm::Aegis256 => Self::Aegis256, + EncryptionAlgorithm::Aes256Gcm => Self::Aes256Gcm, + } + } +} + +impl From for EncryptionAlgorithm { + fn from(alg: sdk::types::EncryptionAlgorithm) -> Self { + match alg { + sdk::types::EncryptionAlgorithm::Aegis256 => Self::Aegis256, + sdk::types::EncryptionAlgorithm::Aes256Gcm => Self::Aes256Gcm, + } + } +} + #[derive(ValueEnum, Debug, Clone, Serialize)] #[serde(rename_all = "kebab-case")] pub enum StorageClass { diff --git a/common/Cargo.toml b/common/Cargo.toml index 9a5df828..e24e5417 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -15,7 +15,10 @@ rkyv = ["dep:rkyv", "compact_str/rkyv"] utoipa = ["dep:utoipa"] [dependencies] +aegis = { workspace = true } +aes-gcm = { workspace = true } axum = { workspace = true, optional = true } +base64ct = { workspace = true, features = ["alloc"] } blake3 = { workspace = true } bytes = { workspace = true } clap = { workspace = true, optional = true, features = ["derive"] } @@ -23,7 +26,9 @@ compact_str = { workspace = true, features = ["serde"] } enum-ordinalize = { workspace = true } enumset = { workspace = true } http = { workspace = true } +rand = { workspace = true } rkyv = { workspace = true, optional = true } +secrecy = { workspace = true } serde = { workspace = true, features = ["derive"] } strum = { workspace = true, features = ["derive"] } thiserror = { workspace = true } diff --git a/common/src/encryption.rs b/common/src/encryption.rs new file mode 100644 index 00000000..812bf5b5 --- /dev/null +++ b/common/src/encryption.rs @@ -0,0 +1,681 @@ +//! # Record encryption format +//! +//! AAD = stream_id bytes +//! +//! ```text +//! [version: 1 byte] [alg_id: 1 byte] [nonce] [ciphertext] [tag] +//! ``` +//! +//! | alg_id | Algorithm | Nonce | Tag | +//! |--------|-------------|--------|------| +//! | 0x01 | AEGIS-256 | 32 B | 32 B | +//! | 0x02 | AES-256-GCM | 12 B | 16 B | + +use core::str::FromStr; + +use aegis::aegis256::Aegis256; +use aes_gcm::{ + Aes256Gcm, KeyInit, + aead::{Aead, Payload}, +}; +use bytes::{BufMut, Bytes, BytesMut}; +use http::{HeaderMap, HeaderValue}; +use rand::random; +use secrecy::{CloneableSecret, ExposeSecret, SecretBox}; + +pub use crate::types::config::EncryptionAlgorithm; +use crate::{ + bash::Bash, + record::{self, Encodable as _, EnvelopeRecord, Header, Metered, Record}, + types::{ + self, + stream::{AppendInput, AppendRecord, AppendRecordBatch, AppendRecordParts}, + }, +}; + +pub const S2_ENCRYPTION_HEADER: &str = "s2-encryption"; + +const CIPHERTEXT_V1: u8 = 0x01; + +const ALG_ID_AEGIS256: u8 = 0x01; +const ALG_ID_AES256GCM: u8 = 0x02; + +const NONCE_BYTES_AEGIS256: usize = 32; +const TAG_BYTES_AEGIS256: usize = 32; + +const NONCE_BYTES_AES256GCM: usize = 12; +const TAG_BYTES_AES256GCM: usize = 16; + +#[derive(Clone)] +pub struct KeyBytes(pub [u8; 32]); + +impl secrecy::zeroize::Zeroize for KeyBytes { + fn zeroize(&mut self) { + self.0.iter_mut().for_each(|b| *b = 0); + } +} + +impl CloneableSecret for KeyBytes {} + +pub type EncryptionKey = SecretBox; + +fn make_key(bytes: [u8; 32]) -> EncryptionKey { + SecretBox::new(Box::new(KeyBytes(bytes))) +} + +/// Parsed `s2-encryption` request directive. +#[derive(Clone, Debug)] +pub enum EncryptionDirective { + /// Algorithm and key for encryption, or key-only for decryption. + Key { + /// Encryption algorithm. Required for appends, ignored for reads. + alg: Option, + /// Decoded 32-byte encryption key. + key: EncryptionKey, + }, + /// Attest mode. + Attest, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum EncryptionError { + #[error("Malformed S2-Encryption header: {0}")] + MalformedHeader(String), + #[error("Unsupported ciphertext version: {0:#04x}")] + UnsupportedVersion(u8), + #[error("Decryption failed")] + DecryptionFailed, + #[error("Record encoding error: {0}")] + EncodingFailed(String), +} + +impl TryFrom<&HeaderValue> for EncryptionDirective { + type Error = EncryptionError; + + fn try_from(value: &HeaderValue) -> Result { + value + .to_str() + .map_err(|_| EncryptionError::MalformedHeader("header is not valid UTF-8".to_owned()))? + .parse() + } +} + +impl FromStr for EncryptionDirective { + type Err = EncryptionError; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + if s == "attest" { + return Ok(Self::Attest); + } + + let mut alg_str = None; + let mut key_b64 = None; + for part in s.split(';') { + let (name, value) = part.split_once('=').ok_or_else(|| { + EncryptionError::MalformedHeader("expected 'alg=...; key=...'".to_owned()) + })?; + let name = name.trim(); + let value = value.trim(); + match name { + "alg" => { + if alg_str.replace(value).is_some() { + return Err(EncryptionError::MalformedHeader( + "duplicate 'alg=' parameter".to_owned(), + )); + } + } + "key" => { + if key_b64.replace(value).is_some() { + return Err(EncryptionError::MalformedHeader( + "duplicate 'key=' parameter".to_owned(), + )); + } + } + _ => { + return Err(EncryptionError::MalformedHeader(format!( + "unknown parameter {name:?}; expected 'alg' or 'key'" + ))); + } + } + } + + let key_b64 = key_b64.ok_or_else(|| { + EncryptionError::MalformedHeader("missing 'key=' parameter".to_owned()) + })?; + let alg = alg_str + .map(|s| { + s.parse::().map_err(|_| { + EncryptionError::MalformedHeader(format!( + "unknown algorithm {s:?}; expected 'aegis-256' or 'aes-256-gcm'" + )) + }) + }) + .transpose()?; + let key = parse_encryption_key(key_b64)?; + Ok(Self::Key { alg, key }) + } +} + +pub fn parse_s2_encryption_header( + headers: &HeaderMap, +) -> Result, EncryptionError> { + headers + .get(S2_ENCRYPTION_HEADER) + .map(EncryptionDirective::try_from) + .transpose() +} + +fn parse_encryption_key(key_b64: &str) -> Result { + use base64ct::{Base64, Encoding}; + + let mut key_bytes: Vec = Base64::decode_vec(key_b64) + .map_err(|e| EncryptionError::MalformedHeader(format!("key is not valid base64: {e}")))?; + + let key_array: [u8; 32] = match key_bytes.as_slice().try_into() { + Ok(arr) => { + secrecy::zeroize::Zeroize::zeroize(&mut key_bytes); + arr + } + Err(_) => { + let len = key_bytes.len(); + secrecy::zeroize::Zeroize::zeroize(&mut key_bytes); + return Err(EncryptionError::MalformedHeader(format!( + "key must be exactly 32 bytes, got {len} bytes" + ))); + } + }; + Ok(make_key(key_array)) +} + +pub fn encode_record_plaintext( + headers: Vec
, + body: Bytes, +) -> Result { + EnvelopeRecord::try_from_parts(headers, body) + .map(|r| r.to_bytes()) + .map_err(|e| EncryptionError::EncodingFailed(e.to_string())) +} + +pub fn decode_record_plaintext(bytes: Bytes) -> Result<(Vec
, Bytes), EncryptionError> { + EnvelopeRecord::try_from(bytes) + .map(|r| r.into_parts()) + .map_err(|e| EncryptionError::EncodingFailed(e.to_string())) +} + +pub fn stream_id_aad( + basin: &(impl AsRef<[u8]> + ?Sized), + stream: &(impl AsRef<[u8]> + ?Sized), +) -> [u8; 32] { + *Bash::delimited(&[basin.as_ref(), stream.as_ref()], 0).as_bytes() +} + +pub fn encrypt_record( + plaintext: &[u8], + alg: EncryptionAlgorithm, + key: &EncryptionKey, + aad: &[u8], +) -> Result { + match alg { + EncryptionAlgorithm::Aegis256 => { + let nonce: [u8; NONCE_BYTES_AEGIS256] = random(); + let (ciphertext, tag) = + Aegis256::::new(&key.expose_secret().0, &nonce) + .encrypt(plaintext, aad); + + let mut out = BytesMut::with_capacity( + 2 + NONCE_BYTES_AEGIS256 + ciphertext.len() + TAG_BYTES_AEGIS256, + ); + out.put_u8(CIPHERTEXT_V1); + out.put_u8(ALG_ID_AEGIS256); + out.put_slice(&nonce); + out.put_slice(&ciphertext); + out.put_slice(&tag); + Ok(out.freeze()) + } + EncryptionAlgorithm::Aes256Gcm => { + let nonce: [u8; NONCE_BYTES_AES256GCM] = random(); + let cipher = Aes256Gcm::new_from_slice(&key.expose_secret().0).map_err(|_| { + EncryptionError::EncodingFailed("invalid AES key length".to_owned()) + })?; + let nonce_generic = aes_gcm::Nonce::from_slice(&nonce); + let ciphertext_with_tag = cipher + .encrypt( + nonce_generic, + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| { + EncryptionError::EncodingFailed("AES-256-GCM encryption failed".to_owned()) + })?; + + let mut out = + BytesMut::with_capacity(2 + NONCE_BYTES_AES256GCM + ciphertext_with_tag.len()); + out.put_u8(CIPHERTEXT_V1); + out.put_u8(ALG_ID_AES256GCM); + out.put_slice(&nonce); + out.put_slice(&ciphertext_with_tag); + Ok(out.freeze()) + } + } +} + +pub fn decrypt_record( + body: &[u8], + key: &EncryptionKey, + aad: &[u8], +) -> Result { + let (&version, after_version) = body + .split_first() + .ok_or(EncryptionError::DecryptionFailed)?; + + match version { + CIPHERTEXT_V1 => decrypt_record_v1(after_version, key, aad), + v => Err(EncryptionError::UnsupportedVersion(v)), + } +} + +fn decrypt_record_v1( + body: &[u8], + key: &EncryptionKey, + aad: &[u8], +) -> Result { + let (&alg_id, rest) = body + .split_first() + .ok_or(EncryptionError::DecryptionFailed)?; + + match alg_id { + ALG_ID_AEGIS256 => { + if rest.len() < NONCE_BYTES_AEGIS256 + TAG_BYTES_AEGIS256 { + return Err(EncryptionError::DecryptionFailed); + } + let nonce: &[u8; NONCE_BYTES_AEGIS256] = + rest[..NONCE_BYTES_AEGIS256].try_into().unwrap(); + let after_nonce = &rest[NONCE_BYTES_AEGIS256..]; + let tag_offset = after_nonce.len() - TAG_BYTES_AEGIS256; + let ciphertext = &after_nonce[..tag_offset]; + let tag: &[u8; TAG_BYTES_AEGIS256] = after_nonce[tag_offset..].try_into().unwrap(); + + let plaintext = Aegis256::::new(&key.expose_secret().0, nonce) + .decrypt(ciphertext, tag, aad) + .map_err(|_| EncryptionError::DecryptionFailed)?; + Ok(Bytes::from(plaintext)) + } + ALG_ID_AES256GCM => { + if rest.len() < NONCE_BYTES_AES256GCM + TAG_BYTES_AES256GCM { + return Err(EncryptionError::DecryptionFailed); + } + let (nonce_bytes, ciphertext_with_tag) = rest.split_at(NONCE_BYTES_AES256GCM); + let cipher = Aes256Gcm::new_from_slice(&key.expose_secret().0).map_err(|_| { + EncryptionError::EncodingFailed("invalid AES key length".to_owned()) + })?; + let nonce_generic = aes_gcm::Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt( + nonce_generic, + Payload { + msg: ciphertext_with_tag, + aad, + }, + ) + .map_err(|_| EncryptionError::DecryptionFailed)?; + Ok(Bytes::from(plaintext)) + } + _ => Err(EncryptionError::DecryptionFailed), + } +} + +pub fn encrypt_append_input( + input: AppendInput, + alg: EncryptionAlgorithm, + key: &EncryptionKey, + aad: &[u8], +) -> Result { + let encrypted_records: Vec = input + .records + .into_iter() + .map(|record| { + let AppendRecordParts { + timestamp, + record: metered_record, + } = record.into(); + let inner_record = metered_record.into_inner(); + let encrypted = match &inner_record { + Record::Envelope(env) => { + let plaintext = + encode_record_plaintext(env.headers().to_vec(), env.body().clone())?; + let enc_body = encrypt_record(&plaintext, alg, key, aad)?; + Record::try_from_parts(vec![], enc_body) + .map_err(|e| EncryptionError::EncodingFailed(e.to_string()))? + } + Record::Command(_) => inner_record, + }; + AppendRecordParts { + timestamp, + record: Metered::from(encrypted), + } + .try_into() + .map_err(|e: &str| EncryptionError::EncodingFailed(e.to_owned())) + }) + .collect::>()?; + + let records: AppendRecordBatch = encrypted_records + .try_into() + .map_err(|e: &str| EncryptionError::EncodingFailed(e.to_owned()))?; + + Ok(AppendInput { + records, + match_seq_num: input.match_seq_num, + fencing_token: input.fencing_token, + }) +} + +pub fn decrypt_read_batch( + batch: types::stream::ReadBatch, + directive: Option<&EncryptionDirective>, + aad: &[u8], +) -> Result { + let Some(EncryptionDirective::Key { key, .. }) = directive else { + return Ok(batch); + }; + let records: Vec = batch + .records + .into_inner() + .into_iter() + .map(|sr| { + let record::Record::Envelope(ref env) = sr.record else { + return Ok(sr); + }; + let plaintext = decrypt_record(env.body(), key, aad)?; + let (headers, body) = decode_record_plaintext(plaintext)?; + let record = record::Record::try_from_parts(headers, body) + .map_err(|e| EncryptionError::EncodingFailed(e.to_string()))?; + Ok(record::SequencedRecord { + position: sr.position, + record, + }) + }) + .collect::>()?; + Ok(types::stream::ReadBatch { + records: record::Metered::from(records), + tail: batch.tail, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key() -> EncryptionKey { + make_key([0x42u8; 32]) + } + + fn wrong_test_key() -> EncryptionKey { + make_key([0x99u8; 32]) + } + + fn test_aad() -> [u8; 32] { + stream_id_aad("test-basin", "test-stream") + } + + fn roundtrip(alg: EncryptionAlgorithm) { + let headers = vec![Header { + name: Bytes::from_static(b"x-test"), + value: Bytes::from_static(b"hello"), + }]; + let body = Bytes::from_static(b"secret payload"); + + let aad = test_aad(); + let plaintext = encode_record_plaintext(headers.clone(), body.clone()).unwrap(); + let key = test_key(); + let ciphertext = encrypt_record(&plaintext, alg, &key, &aad).unwrap(); + let decrypted = decrypt_record(&ciphertext, &key, &aad).unwrap(); + let (out_headers, out_body) = decode_record_plaintext(decrypted).unwrap(); + + assert_eq!(out_headers, headers); + assert_eq!(out_body, body); + } + + #[test] + fn roundtrip_aegis256() { + roundtrip(EncryptionAlgorithm::Aegis256); + } + + #[test] + fn roundtrip_aes256gcm() { + roundtrip(EncryptionAlgorithm::Aes256Gcm); + } + + #[test] + fn wrong_key_fails_aegis256() { + let aad = test_aad(); + let plaintext = encode_record_plaintext(vec![], Bytes::from_static(b"data")).unwrap(); + let key = test_key(); + let ciphertext = + encrypt_record(&plaintext, EncryptionAlgorithm::Aegis256, &key, &aad).unwrap(); + let result = decrypt_record(&ciphertext, &wrong_test_key(), &aad); + assert!(matches!(result, Err(EncryptionError::DecryptionFailed))); + } + + #[test] + fn wrong_key_fails_aes256gcm() { + let aad = test_aad(); + let plaintext = encode_record_plaintext(vec![], Bytes::from_static(b"data")).unwrap(); + let key = test_key(); + let ciphertext = + encrypt_record(&plaintext, EncryptionAlgorithm::Aes256Gcm, &key, &aad).unwrap(); + let result = decrypt_record(&ciphertext, &wrong_test_key(), &aad); + assert!(matches!(result, Err(EncryptionError::DecryptionFailed))); + } + + #[test] + fn truncated_ciphertext_fails_no_panic() { + let aad = test_aad(); + let plaintext = encode_record_plaintext(vec![], Bytes::from_static(b"data")).unwrap(); + let key = test_key(); + let ciphertext = + encrypt_record(&plaintext, EncryptionAlgorithm::Aegis256, &key, &aad).unwrap(); + let truncated = &ciphertext[..4]; + let result = decrypt_record(truncated, &key, &aad); + assert!(matches!(result, Err(EncryptionError::DecryptionFailed))); + } + + #[test] + fn unsupported_version_fails() { + let aad = test_aad(); + let key = test_key(); + let body = b"\xFFsome opaque bytes"; + let result = decrypt_record(body, &key, &aad); + assert!(matches!( + result, + Err(EncryptionError::UnsupportedVersion(0xFF)) + )); + } + + #[test] + fn empty_body_fails() { + let aad = test_aad(); + let key = test_key(); + let result = decrypt_record(b"", &key, &aad); + assert!(matches!(result, Err(EncryptionError::DecryptionFailed))); + } + + #[test] + fn version_byte_present() { + let aad = test_aad(); + let plaintext = encode_record_plaintext(vec![], Bytes::from_static(b"data")).unwrap(); + let key = test_key(); + let ciphertext = + encrypt_record(&plaintext, EncryptionAlgorithm::Aegis256, &key, &aad).unwrap(); + assert_eq!(ciphertext[0], CIPHERTEXT_V1); + assert_eq!(ciphertext[1], ALG_ID_AEGIS256); + } + + #[test] + fn alg_id_flip_detected() { + let aad = test_aad(); + let plaintext = encode_record_plaintext(vec![], Bytes::from_static(b"data")).unwrap(); + let key = test_key(); + let mut ciphertext = encrypt_record(&plaintext, EncryptionAlgorithm::Aegis256, &key, &aad) + .unwrap() + .to_vec(); + assert_eq!(ciphertext[0], CIPHERTEXT_V1); + assert_eq!(ciphertext[1], ALG_ID_AEGIS256); + ciphertext[1] = ALG_ID_AES256GCM; + let result = decrypt_record(&ciphertext, &key, &aad); + assert!(matches!(result, Err(EncryptionError::DecryptionFailed))); + } + + #[test] + fn version_flip_detected() { + let aad = test_aad(); + let plaintext = encode_record_plaintext(vec![], Bytes::from_static(b"data")).unwrap(); + let key = test_key(); + let mut ciphertext = encrypt_record(&plaintext, EncryptionAlgorithm::Aegis256, &key, &aad) + .unwrap() + .to_vec(); + ciphertext[0] = 0x02; + let result = decrypt_record(&ciphertext, &key, &aad); + assert!(matches!( + result, + Err(EncryptionError::UnsupportedVersion(0x02)) + )); + } + + #[test] + fn wrong_aad_fails() { + let aad = test_aad(); + let other_aad = stream_id_aad("other-basin", "other-stream"); + let plaintext = encode_record_plaintext(vec![], Bytes::from_static(b"data")).unwrap(); + let key = test_key(); + let ciphertext = + encrypt_record(&plaintext, EncryptionAlgorithm::Aegis256, &key, &aad).unwrap(); + let result = decrypt_record(&ciphertext, &key, &other_aad); + assert!(matches!(result, Err(EncryptionError::DecryptionFailed))); + } + + #[test] + fn parse_header_valid_aegis() { + let mut headers = HeaderMap::new(); + headers.insert( + S2_ENCRYPTION_HEADER, + http::HeaderValue::from_static( + "alg=aegis-256; key=AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + ), + ); + let directive = parse_s2_encryption_header(&headers).unwrap().unwrap(); + assert!(matches!( + directive, + EncryptionDirective::Key { + alg: Some(EncryptionAlgorithm::Aegis256), + .. + } + )); + } + + #[test] + fn parse_header_valid_aes() { + let mut headers = HeaderMap::new(); + headers.insert( + S2_ENCRYPTION_HEADER, + http::HeaderValue::from_static( + "alg=aes-256-gcm; key=AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + ), + ); + let directive = parse_s2_encryption_header(&headers).unwrap().unwrap(); + assert!(matches!( + directive, + EncryptionDirective::Key { + alg: Some(EncryptionAlgorithm::Aes256Gcm), + .. + } + )); + } + + #[test] + fn parse_header_valid_reordered_params() { + let mut headers = HeaderMap::new(); + headers.insert( + S2_ENCRYPTION_HEADER, + http::HeaderValue::from_static( + "key=AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=; alg=aes-256-gcm", + ), + ); + let directive = parse_s2_encryption_header(&headers).unwrap().unwrap(); + assert!(matches!( + directive, + EncryptionDirective::Key { + alg: Some(EncryptionAlgorithm::Aes256Gcm), + .. + } + )); + } + + #[test] + fn parse_header_key_only() { + let mut headers = HeaderMap::new(); + headers.insert( + S2_ENCRYPTION_HEADER, + http::HeaderValue::from_static("key=AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="), + ); + let directive = parse_s2_encryption_header(&headers).unwrap().unwrap(); + assert!(matches!( + directive, + EncryptionDirective::Key { alg: None, .. } + )); + } + + #[test] + fn parse_header_attest() { + let mut headers = HeaderMap::new(); + headers.insert( + S2_ENCRYPTION_HEADER, + http::HeaderValue::from_static("attest"), + ); + let directive = parse_s2_encryption_header(&headers).unwrap().unwrap(); + assert!(matches!(directive, EncryptionDirective::Attest)); + } + + #[test] + fn parse_header_absent() { + let headers = HeaderMap::new(); + let result = parse_s2_encryption_header(&headers).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn parse_header_malformed_no_semicolon() { + let mut headers = HeaderMap::new(); + headers.insert( + S2_ENCRYPTION_HEADER, + http::HeaderValue::from_static("alg=aegis-256"), + ); + let result = parse_s2_encryption_header(&headers); + assert!(matches!(result, Err(EncryptionError::MalformedHeader(_)))); + } + + #[test] + fn parse_header_wrong_key_length() { + let mut headers = HeaderMap::new(); + // "deadbeef" decodes to 4 bytes, not 32 + headers.insert( + S2_ENCRYPTION_HEADER, + http::HeaderValue::from_static("alg=aegis-256; key=3q2+7w=="), + ); + let result = parse_s2_encryption_header(&headers); + assert!(matches!(result, Err(EncryptionError::MalformedHeader(_)))); + } + + #[test] + fn parse_header_invalid_base64() { + let mut headers = HeaderMap::new(); + headers.insert( + S2_ENCRYPTION_HEADER, + http::HeaderValue::from_static("alg=aegis-256; key=not-valid-base64!!!"), + ); + let result = parse_s2_encryption_header(&headers); + assert!(matches!(result, Err(EncryptionError::MalformedHeader(_)))); + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 6685a7f4..2451ed4b 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -3,6 +3,7 @@ pub mod bash; pub mod caps; pub mod deep_size; +pub mod encryption; pub mod http; pub mod maybe; pub mod read_extent; diff --git a/common/src/types/config.rs b/common/src/types/config.rs index bd60be11..afadcae4 100644 --- a/common/src/types/config.rs +++ b/common/src/types/config.rs @@ -33,6 +33,17 @@ use enum_ordinalize::Ordinalize; use crate::maybe::Maybe; +/// Encryption algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::EnumString)] +pub enum EncryptionAlgorithm { + /// AEGIS-256 + #[strum(serialize = "aegis-256")] + Aegis256, + /// AES-256-GCM + #[strum(serialize = "aes-256-gcm")] + Aes256Gcm, +} + #[derive( Debug, Default, diff --git a/lite/src/backend/error.rs b/lite/src/backend/error.rs index 5633d659..dc98a65f 100644 --- a/lite/src/backend/error.rs +++ b/lite/src/backend/error.rs @@ -225,6 +225,8 @@ pub enum ReadError { StreamDeletionPending(#[from] StreamDeletionPendingError), #[error(transparent)] Unwritten(#[from] UnwrittenError), + #[error(transparent)] + Encryption(#[from] s2_common::encryption::EncryptionError), } impl From for ReadError { diff --git a/lite/src/handlers/v1/error.rs b/lite/src/handlers/v1/error.rs index d983d546..43f5ae84 100644 --- a/lite/src/handlers/v1/error.rs +++ b/lite/src/handlers/v1/error.rs @@ -257,6 +257,7 @@ impl ServiceError { ReadError::Unwritten(tail) => ErrorResponse::Unwritten(v1t::stream::TailResponse { tail: tail.0.into(), }), + ReadError::Encryption(e) => standard(ErrorCode::Invalid, e.to_string()), }, ServiceError::NotImplemented => { standard(ErrorCode::PermissionDenied, "Not implemented".to_string()) diff --git a/lite/src/handlers/v1/records.rs b/lite/src/handlers/v1/records.rs index e9d1c258..21b0fb98 100644 --- a/lite/src/handlers/v1/records.rs +++ b/lite/src/handlers/v1/records.rs @@ -14,13 +14,18 @@ use s2_api::{ }; use s2_common::{ caps::RECORD_BATCH_MAX, + encryption::{ + self, EncryptionDirective, EncryptionError, parse_s2_encryption_header, stream_id_aad, + }, http::extract::Header, read_extent::{CountOrBytes, ReadLimit}, record::{Metered, MeteredSize as _}, types::{ ValidationError, basin::BasinName, - stream::{ReadBatch, ReadEnd, ReadFrom, ReadSessionOutput, ReadStart, StreamName}, + stream::{ + AppendInput, ReadBatch, ReadEnd, ReadFrom, ReadSessionOutput, ReadStart, StreamName, + }, }, }; @@ -37,6 +42,48 @@ pub fn router() -> axum::Router { .route(super::paths::streams::records::APPEND, post(append)) } +struct EncryptionContext { + aad: Option<[u8; 32]>, + directive: Option, +} + +impl EncryptionContext { + fn resolve( + headers: &http::HeaderMap, + basin: &BasinName, + stream: &StreamName, + ) -> Result { + let directive = parse_s2_encryption_header(headers) + .map_err(|e| ServiceError::Validation(ValidationError(e.to_string())))?; + let aad = directive + .as_ref() + .map(|_| stream_id_aad(basin.as_ref(), stream.as_ref())); + Ok(Self { aad, directive }) + } + + fn aad(&self) -> &[u8] { + self.aad.as_ref().map_or(&[], |a| a.as_slice()) + } + + fn encrypt_input(&self, input: AppendInput) -> Result { + match &self.directive { + Some(EncryptionDirective::Key { alg, key }) => { + let alg = alg.ok_or_else(|| { + EncryptionError::MalformedHeader( + "encryption algorithm required for append".to_owned(), + ) + })?; + encryption::encrypt_append_input(input, alg, key, self.aad()) + } + _ => Ok(input), + } + } + + fn decrypt_batch(&self, batch: ReadBatch) -> Result { + encryption::decrypt_read_batch(batch, self.directive.as_ref(), self.aad()) + } +} + fn validate_read_until(start: ReadStart, end: ReadEnd) -> Result<(), ServiceError> { if let ReadFrom::Timestamp(ts) = start.from && end.until.deny(ts) @@ -171,6 +218,7 @@ pub struct ReadArgs { ))] pub async fn read( State(backend): State, + headers: http::HeaderMap, ReadArgs { basin, stream, @@ -179,6 +227,8 @@ pub async fn read( request, }: ReadArgs, ) -> Result { + let enc = EncryptionContext::resolve(&headers, &basin, &stream)?; + let start: ReadStart = start.try_into()?; match request { v1t::stream::ReadRequest::Unary { @@ -186,8 +236,11 @@ pub async fn read( response_mime, } => { let (start, end) = prepare_read(start, end, ReadMode::Unary)?; - let session = backend.read(basin, stream, start, end).await?; + let session = backend + .read(basin.clone(), stream.clone(), start, end) + .await?; let batch = merge_read_session(session, end.wait).await?; + let batch = enc.decrypt_batch(batch).map_err(ReadError::Encryption)?; match response_mime { JsonOrProto::Json => Ok(Json(v1t::stream::json::serialize_read_batch( format, &batch, @@ -205,7 +258,9 @@ pub async fn read( } => { let (start, end) = apply_last_event_id(start, end, last_event_id); let (start, end) = prepare_read(start, end, ReadMode::Streaming)?; - let session = backend.read(basin, stream, start, end).await?; + let session = backend + .read(basin.clone(), stream.clone(), start, end) + .await?; let events = async_stream::stream! { let mut processed = CountOrBytes::ZERO; tokio::pin!(session); @@ -216,6 +271,15 @@ pub async fn read( yield v1t::stream::sse::ping_event(); }, Ok(ReadSessionOutput::Batch(batch)) => { + let batch = match enc.decrypt_batch(batch) { + Ok(batch) => batch, + Err(err) => { + let (_, body) = ServiceError::from(ReadError::Encryption(err)).to_response().to_parts(); + yield v1t::stream::sse::error_event(body); + errored = true; + break; + } + }; let Some(last_record) = batch.records.last() else { continue; }; @@ -246,19 +310,20 @@ pub async fn read( response_compression, } => { let (start, end) = prepare_read(start, end, ReadMode::Streaming)?; - let s2s_stream = - backend - .read(basin, stream, start, end) - .await? - .map_ok(|msg| match msg { - ReadSessionOutput::Heartbeat(tail) => v1t::stream::proto::ReadBatch { - records: vec![], - tail: Some(tail.into()), - }, - ReadSessionOutput::Batch(batch) => { - v1t::stream::proto::ReadBatch::from(batch) - } - }); + let s2s_stream = backend + .read(basin.clone(), stream.clone(), start, end) + .await? + .map(move |msg| match msg { + Ok(ReadSessionOutput::Heartbeat(tail)) => Ok(v1t::stream::proto::ReadBatch { + records: vec![], + tail: Some(tail.into()), + }), + Ok(ReadSessionOutput::Batch(batch)) => { + let batch = enc.decrypt_batch(batch).map_err(ReadError::Encryption)?; + Ok(v1t::stream::proto::ReadBatch::from(batch)) + } + Err(e) => Err(e), + }); let response_stream = s2s::FramedMessageStream::<_>::new( response_compression, Box::pin(s2s_stream.map_err(ServiceError::from)), @@ -349,17 +414,23 @@ pub struct AppendArgs { ))] pub async fn append( State(backend): State, + headers: http::HeaderMap, AppendArgs { basin, stream, request, }: AppendArgs, ) -> Result { + let enc = EncryptionContext::resolve(&headers, &basin, &stream)?; + match request { v1t::stream::AppendRequest::Unary { input, response_mime, } => { + let input = enc + .encrypt_input(input) + .map_err(|e| ServiceError::Validation(ValidationError(e.to_string())))?; let ack = backend.append(basin, stream, input).await?; match response_mime { JsonOrProto::Json => { @@ -376,17 +447,25 @@ pub async fn append( inputs, response_compression, } => { - let (err_tx, err_rx) = tokio::sync::oneshot::channel(); + let (err_tx, err_rx) = tokio::sync::oneshot::channel::(); let inputs = async_stream::stream! { tokio::pin!(inputs); let mut err_tx = Some(err_tx); while let Some(input) = inputs.next().await { match input { - Ok(input) => yield input, + Ok(input) => match enc.encrypt_input(input) { + Ok(encrypted) => yield encrypted, + Err(e) => { + if let Some(tx) = err_tx.take() { + let _ = tx.send(ServiceError::Validation(ValidationError(e.to_string()))); + } + break; + } + }, Err(e) => { if let Some(tx) = err_tx.take() { - let _ = tx.send(e); + let _ = tx.send(e.into()); } break; } @@ -404,7 +483,7 @@ pub async fn append( let input_err_stream = futures::stream::once(err_rx).filter_map(|res| async move { match res { - Ok(err) => Some(Err(err.into())), + Ok(err) => Some(Err(err)), Err(_) => None, } }); diff --git a/sdk/src/api.rs b/sdk/src/api.rs index 59b054b4..49010de2 100644 --- a/sdk/src/api.rs +++ b/sdk/src/api.rs @@ -9,14 +9,14 @@ use http::{ header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, InvalidHeaderValue}, }; use prost::{self, Message}; +#[cfg(feature = "_hidden")] +use s2_api::v1::basin::CreateOrReconfigureBasinRequest; use s2_api::v1::{ access::{ AccessTokenInfo, IssueAccessTokenResponse, ListAccessTokensRequest, ListAccessTokensResponse, }, - basin::{ - BasinInfo, CreateBasinRequest, ListBasinsRequest, ListBasinsResponse, - }, + basin::{BasinInfo, CreateBasinRequest, ListBasinsRequest, ListBasinsResponse}, config::{BasinConfig, BasinReconfiguration, StreamConfig, StreamReconfiguration}, metrics::{ AccountMetricSetRequest, BasinMetricSetRequest, MetricSetResponse, StreamMetricSetRequest, @@ -32,13 +32,10 @@ use secrecy::ExposeSecret; use tokio_util::codec::Decoder; use tracing::{debug, warn}; use url::Url; -#[cfg(feature = "_hidden")] -use s2_api::v1::basin::CreateOrReconfigureBasinRequest; - -use crate::frame_signal::FrameSignal; use crate::{ client::{self, StreamingResponse, UnaryResponse}, + frame_signal::FrameSignal, retry::{RetryBackoff, RetryBackoffBuilder}, types::{ AccessTokenId, AppendRetryPolicy, BasinAuthority, BasinName, Compression, RetryConfig, @@ -51,6 +48,7 @@ const CONTENT_TYPE_PROTO: &str = "application/protobuf"; const ACCEPT_PROTO: &str = "application/protobuf"; const S2_REQUEST_TOKEN: &str = "s2-request-token"; const S2_BASIN: &str = "s2-basin"; +const S2_ENCRYPTION: &str = "s2-encryption"; const RETRY_AFTER_MS_HEADER: &str = "retry-after-ms"; #[derive(Debug, Clone)] @@ -359,6 +357,8 @@ impl BasinClient { .header(ACCEPT, ACCEPT_PROTO) .body(input.encode_to_vec()) .build()?; + let mut request = request; + self.client.set_encryption_header(&mut request); let response = self .request(request) .with_append_retry_policy(append_retry_policy) @@ -394,9 +394,11 @@ impl BasinClient { .query(&start) .query(&end); if let Some(wait) = end.wait { - builder = builder.timeout(self.client.request_timeout + Duration::from_secs(wait.into())); + builder = + builder.timeout(self.client.request_timeout + Duration::from_secs(wait.into())); } - let request = builder.build()?; + let mut request = builder.build()?; + self.client.set_encryption_header(&mut request); let response = self .request(request) .error_handler(read_response_error_handler) @@ -438,9 +440,11 @@ impl BasinClient { .timeout(self.client.request_timeout); request_builder = add_basin_header_if_required(request_builder, &self.config.endpoints, &self.name); + let mut request = request_builder.build()?; + self.client.set_encryption_header(&mut request); let response = self .client - .init_streaming(request_builder.build()?) + .init_streaming(request) .await? .into_result() .await?; @@ -489,9 +493,11 @@ impl BasinClient { .timeout(self.client.request_timeout); request_builder = add_basin_header_if_required(request_builder, &self.config.endpoints, &self.name); + let mut request = request_builder.build()?; + self.client.set_encryption_header(&mut request); let response = self .client - .init_streaming(request_builder.build()?) + .init_streaming(request) .await? .into_result() .await?; @@ -782,6 +788,7 @@ pub type Streaming = Pin>>>; pub struct BaseClient { client: Arc, default_headers: HeaderMap, + encryption_header: Option, request_timeout: Duration, retry_builder: RetryBackoffBuilder, compression: Compression, @@ -829,17 +836,45 @@ impl BaseClient { Compression::None => {} } + let encryption_header = config + .encryption + .as_ref() + .map(|enc| { + use crate::types::EncryptionConfig; + let value = match enc { + EncryptionConfig::Key { + alg: Some(alg), + key, + } => { + format!("alg={alg}; key={}", key.expose_secret()) + } + EncryptionConfig::Key { alg: None, key } => { + format!("key={}", key.expose_secret()) + } + EncryptionConfig::Attest => "attest".to_owned(), + }; + value.try_into() + }) + .transpose()?; + let client = client::Pool::new(connector); Ok(Self { client: Arc::new(client), default_headers, + encryption_header, request_timeout: config.request_timeout, retry_builder: retry_builder(&config.retry), compression: config.compression, }) } + pub fn set_encryption_header(&self, request: &mut client::Request) { + if let Some(ref value) = self.encryption_header { + request.headers_mut().insert(S2_ENCRYPTION, value.clone()); + } + } + pub fn get(&self, url: Url) -> client::RequestBuilder { client::RequestBuilder::get(url) .timeout(self.request_timeout) @@ -963,10 +998,7 @@ impl<'a> RequestBuilder<'a> { r }; - let response = self - .client - .execute_unary(attempt_request) - .await; + let response = self.client.execute_unary(attempt_request).await; let (err, retry_after) = match response { Ok(resp) => { @@ -1183,7 +1215,9 @@ mod tests { // Server errors that do NOT guarantee no mutation. assert!(!server_error(StatusCode::INTERNAL_SERVER_ERROR, "internal").has_no_side_effects()); assert!(!server_error(StatusCode::BAD_GATEWAY, "other").has_no_side_effects()); - assert!(!server_error(StatusCode::SERVICE_UNAVAILABLE, "unavailable").has_no_side_effects()); + assert!( + !server_error(StatusCode::SERVICE_UNAVAILABLE, "unavailable").has_no_side_effects() + ); } #[test] diff --git a/sdk/src/types.rs b/sdk/src/types.rs index 2b361e4c..7f75f271 100644 --- a/sdk/src/types.rs +++ b/sdk/src/types.rs @@ -385,6 +385,20 @@ impl RetryConfig { } } +/// Encryption configuration. +#[derive(Debug, Clone)] +pub enum EncryptionConfig { + /// Algorithm and key for encryption, or key-only for decryption. + Key { + /// Encryption algorithm. Required for appends, ignored for reads. + alg: Option, + /// Base64-encoded 32-byte key. + key: SecretString, + }, + /// Attest mode. + Attest, +} + #[derive(Debug, Clone)] #[non_exhaustive] /// Configuration for [`S2`](crate::S2). @@ -397,6 +411,7 @@ pub struct S2Config { pub(crate) compression: Compression, pub(crate) user_agent: HeaderValue, pub(crate) insecure_skip_cert_verification: bool, + pub(crate) encryption: Option, } impl S2Config { @@ -413,6 +428,7 @@ impl S2Config { .parse() .expect("valid user agent"), insecure_skip_cert_verification: false, + encryption: None, } } @@ -476,6 +492,14 @@ impl S2Config { } } + /// Set the encryption configuration. + pub fn with_encryption(self, encryption: EncryptionConfig) -> Self { + Self { + encryption: Some(encryption), + ..self + } + } + #[doc(hidden)] #[cfg(feature = "_hidden")] pub fn with_user_agent(self, user_agent: impl Into) -> Result { @@ -533,6 +557,24 @@ impl From for api::config::StorageClass { } } +/// Encryption algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncryptionAlgorithm { + /// AEGIS-256 + Aegis256, + /// AES-256-GCM + Aes256Gcm, +} + +impl fmt::Display for EncryptionAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Aegis256 => "aegis-256", + Self::Aes256Gcm => "aes-256-gcm", + }) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] /// Retention policy for records in a stream. pub enum RetentionPolicy {