diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-03-19 05:00:15 +0100 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-03-19 05:00:15 +0100 |
| commit | c1e70050872f398a7dccff5818b98f6eb100710c (patch) | |
| tree | b0b44488650862aa369b67d51bb70209f9f2f78b /crates | |
| parent | build(rocie-{client,server}): Mark as publishable (diff) | |
| download | server-c1e70050872f398a7dccff5818b98f6eb100710c.zip | |
feat(rocie-server/cli): Make the secret key for identity handling persist-able
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/rocie-server/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/rocie-server/src/cli.rs | 22 | ||||
| -rw-r--r-- | crates/rocie-server/src/main.rs | 232 |
3 files changed, 248 insertions, 7 deletions
diff --git a/crates/rocie-server/Cargo.toml b/crates/rocie-server/Cargo.toml index fdcf9ef..36b271b 100644 --- a/crates/rocie-server/Cargo.toml +++ b/crates/rocie-server/Cargo.toml @@ -46,6 +46,7 @@ serde_json = "1.0.145" serde_yaml = "0.9.34" sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } thiserror = "2.0.17" +tokio.workspace = true utoipa = { version = "5.4.0", features = ["actix_extras", "uuid"] } uuid = { version = "1.19.0", features = ["v4", "serde"] } diff --git a/crates/rocie-server/src/cli.rs b/crates/rocie-server/src/cli.rs index 01c4199..b198510 100644 --- a/crates/rocie-server/src/cli.rs +++ b/crates/rocie-server/src/cli.rs @@ -18,6 +18,24 @@ pub(crate) enum Command { #[arg(short, long)] port: Option<u16>, + /// File containing the secret key, + /// used to sign the JWT cookies handed out to clients (as hex). + /// + /// Leave empty to generate a random one. + /// Note that every client will be signed out, when this value changes (because the + /// rocie-server will not be able to verify the signatures made with the previous key + /// anymore). + /// + /// As there are some requirements that this key needs to fulfill, you can use the + /// `generate-key` sub-command to generate a compliant key. + /// E.g. + /// ```sh + /// rocie-server generate-key > ./key.hex + /// rocie-server serve --secret-key-file ./key.hex .. + /// ``` + #[arg(short, long, verbatim_doc_comment)] + secret_key_file: Option<PathBuf>, + /// Print the used port as single u16 to stdout when started. /// /// This can be used to determine the used port, when the `port` was left at `None`. @@ -35,4 +53,8 @@ pub(crate) enum Command { /// Print the `OpenAPI` API documentation to stdout. OpenApi, + + /// Generate (and print to stdout) a compliant secret key for use in the `serve --secret-key` + /// argument. + GenerateKey, } diff --git a/crates/rocie-server/src/main.rs b/crates/rocie-server/src/main.rs index 5f3b0ff..c57c3a2 100644 --- a/crates/rocie-server/src/main.rs +++ b/crates/rocie-server/src/main.rs @@ -5,6 +5,8 @@ use actix_web::{ web::Data, }; use clap::Parser; +use log::warn; +use tokio::{fs::File, io::AsyncReadExt}; use utoipa::OpenApi; use crate::cli::{CliArgs, Command}; @@ -78,12 +80,6 @@ async fn main() -> Result<(), std::io::Error> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); - // When using `Key::generate()` it is important to initialize outside of the - // `HttpServer::new` closure. When deployed the secret key should be read from a - // configuration file or environment variables. - // TODO: Load from a config file. <2025-12-07> - let secret_key = Key::generate(); - let args = CliArgs::parse(); match args.command { @@ -92,6 +88,7 @@ async fn main() -> Result<(), std::io::Error> { port, db_path, print_port, + secret_key_file, } => { let data = Data::new( app::App::new(db_path) @@ -99,6 +96,33 @@ async fn main() -> Result<(), std::io::Error> { .map_err(|err| std::io::Error::other(main::Error::AppInit(err)))?, ); + let secret_key = if let Some(path) = secret_key_file { + let mut file = File::open(&path).await.map_err(|error| { + std::io::Error::other(secret_key_read::Error::FileOpen { + path: path.clone(), + error, + }) + })?; + + let mut buf = Vec::with_capacity(64 * 2); + file.read_to_end(&mut buf).await.map_err(|error| { + std::io::Error::other(secret_key_read::Error::FileRead { + error, + path: path.clone(), + }) + })?; + + let contents = String::from_utf8(buf).map_err(|error| { + std::io::Error::other(secret_key_read::Error::Utf8 { error, path }) + })?; + + Key::from(&parse_hex_key(&contents)) + } else { + // When using `Key::generate()` it is important to initialize outside of the + // `HttpServer::new` closure. + Key::generate() + }; + let srv = HttpServer::new(move || { App::new() .wrap(Logger::new( @@ -140,15 +164,209 @@ async fn main() -> Result<(), std::io::Error> { println!("{}", openapi.to_pretty_json().expect("Comp-time constant")); Ok(()) } + Command::GenerateKey => { + let key = Key::generate(); + + print!("{}", format_hex_val(key.master())); + + Ok(()) + } + } +} + +fn parse_hex_key(key: &str) -> [u8; 64] { + let mut out = [0u8; 64]; + + let back = parse_hex_string(key); + + if back.len() != 64 { + warn!( + "Secret key is not 64 bytes long (len: {}), padding with zeros.", + back.len() + ); + } + + for (index, b) in back.iter().enumerate() { + out[index] = *b; + } + + out +} + +fn format_hex_val(key: &[u8]) -> String { + fn value_to_hex(value: u8) -> char { + match value { + 0 => '0', + 1 => '1', + 2 => '2', + 3 => '3', + 4 => '4', + 5 => '5', + 6 => '6', + 7 => '7', + 8 => '8', + 9 => '9', + 10 => 'a', + 11 => 'b', + 12 => 'c', + 13 => 'd', + 14 => 'e', + 15 => 'f', + _ => unreachable!("The 'value' is a u4"), + } + } + + let mut out = String::new(); + + for val in key { + let first = value_to_hex(val >> 4); + let second = value_to_hex(val & 0b0000_1111); + + out.push(first); + out.push(second); + } + + out +} + +fn parse_hex_string(input: &str) -> Vec<u8> { + let mut out = vec![0u8; input.len().div_ceil(2)]; + + let i1 = input.chars().step_by(2); + let mut i2 = input.chars().skip(1).step_by(2); + + for (index, b1) in i1.enumerate() { + if let Some(b2) = i2.next() { + out[index] = parse_hex_pair((b1, b2)); + } else { + // We only have b1 left + out[index] = parse_hex_digit(b1); + break; + } + } + + out +} + +fn parse_hex_pair(digit: (char, char)) -> u8 { + let first = parse_hex_digit(digit.0); + let second = parse_hex_digit(digit.1); + + (first << 4) | second +} + +fn parse_hex_digit(digit: char) -> u8 { + match digit.to_ascii_uppercase() { + '0' => 0, + '1' => 1, + '2' => 2, + '3' => 3, + '4' => 4, + '5' => 5, + '6' => 6, + '7' => 7, + '8' => 8, + '9' => 9, + 'A' => 10, + 'B' => 11, + 'C' => 12, + 'D' => 13, + 'E' => 14, + 'F' => 15, + _ => unreachable!("Invalid hex char: {digit:?}"), } } pub(crate) mod main { - use crate::app::app_create; + use crate::{app::app_create, secret_key_read}; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { #[error("Failed to initialize shared application state: {0}")] AppInit(#[from] app_create::Error), + + #[error("Failed to load secret key from --secret-key-file arg: {0}")] + SecretFileRead(#[from] secret_key_read::Error), + } +} + +pub(crate) mod secret_key_read { + use std::{path::PathBuf, string::FromUtf8Error}; + + use tokio::io; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to open the secret key file at `{path}`: {error}")] + FileOpen { path: PathBuf, error: io::Error }, + + #[error("Failed to read the contents of the secret key file at `{path}`: {error}")] + FileRead { path: PathBuf, error: io::Error }, + + #[error( + "Failed to interprete seceret key file (at `{path}`) contents as utf8 string: {error}" + )] + Utf8 { path: PathBuf, error: FromUtf8Error }, + } +} + +#[cfg(test)] +mod test { + use crate::{format_hex_val, parse_hex_key, parse_hex_pair, parse_hex_string}; + + #[test] + fn test_hex_parse() { + let input = "4c6541594a53a79b4649cce91610271e0b477748477dc89f350f8c3bbfc2f1a3b67ae51c56d2286006e070022529ed5b586d114985d05558cd2200bbc5d641c8"; + + let out = parse_hex_key(input); + + assert_eq!( + out, + [ + 76, 101, 65, 89, 74, 83, 167, 155, 70, 73, 204, 233, 22, 16, 39, 30, 11, 71, 119, + 72, 71, 125, 200, 159, 53, 15, 140, 59, 191, 194, 241, 163, 182, 122, 229, 28, 86, + 210, 40, 96, 6, 224, 112, 2, 37, 41, 237, 91, 88, 109, 17, 73, 133, 208, 85, 88, + 205, 34, 0, 187, 197, 214, 65, 200 + ] + ); + + assert_eq!(format_hex_val(&out), input.to_owned()); + } + + #[test] + fn test_hex_parse_basic() { + macro_rules! test { + ($input:literal -> $expected:literal) => { + let output = parse_hex_pair(( + $input.chars().next().unwrap(), + $input.chars().skip(1).next().unwrap(), + )); + + assert_eq!(output, $expected); + }; + } + + test!("ff" -> 255); + test!("10" -> 16); + test!("05" -> 5); + test!("0f" -> 15); + test!("af" -> 175); + } + + #[test] + fn test_hex_parse_str() { + macro_rules! test { + ($input:literal -> [$($expected:literal),*]) => { + let output = parse_hex_string($input); + + assert_eq!(output, vec![$($expected),*]); + }; + } + + test!("ffff" -> [255, 255]); + test!("1020" -> [16,32]); + test!("05ff" -> [5, 255]); + test!("0ff" -> [15, 15]); + test!("afb" -> [175, 11]); } } |
