aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-03-19 05:00:15 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-03-19 05:00:15 +0100
commitc1e70050872f398a7dccff5818b98f6eb100710c (patch)
treeb0b44488650862aa369b67d51bb70209f9f2f78b
parentbuild(rocie-{client,server}): Mark as publishable (diff)
downloadserver-c1e70050872f398a7dccff5818b98f6eb100710c.zip
feat(rocie-server/cli): Make the secret key for identity handling persist-able
Diffstat (limited to '')
-rw-r--r--crates/rocie-server/Cargo.toml1
-rw-r--r--crates/rocie-server/src/cli.rs22
-rw-r--r--crates/rocie-server/src/main.rs232
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]);
}
}