diff --git a/Cargo.lock b/Cargo.lock index 165b9a9..b4aa5a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,6 +317,7 @@ dependencies = [ "maybe-owned", "rand", "regex", + "rsa 0.9.6", "ryu", "sea-orm", "sea-orm-migration", @@ -348,6 +349,7 @@ dependencies = [ "jwt-simple", "lazy_static", "regex", + "rsa 0.9.6", "serde", "serde_with", "tracing", diff --git a/attic/src/nix_store/tests/test_nar.rs b/attic/src/nix_store/tests/test_nar.rs index ae0759a..36d8900 100644 --- a/attic/src/nix_store/tests/test_nar.rs +++ b/attic/src/nix_store/tests/test_nar.rs @@ -19,7 +19,7 @@ use crate::nix_store::StorePath; /// Expected values for `nm1w9sdm6j6icmhd2q3260hl1w9zj6li-attic-test-no-deps`. pub const NO_DEPS: TestNar = TestNar { store_path: "/nix/store/nm1w9sdm6j6icmhd2q3260hl1w9zj6li-attic-test-no-deps", - original_file: include_bytes!("nar/nm1w9sdm6j6icmhd2q3260hl1w9zj6li-attic-test-no-deps"), + _original_file: include_bytes!("nar/nm1w9sdm6j6icmhd2q3260hl1w9zj6li-attic-test-no-deps"), nar: include_bytes!("nar/nm1w9sdm6j6icmhd2q3260hl1w9zj6li-attic-test-no-deps.nar"), export: include_bytes!("nar/nm1w9sdm6j6icmhd2q3260hl1w9zj6li-attic-test-no-deps.export"), closure: &["nm1w9sdm6j6icmhd2q3260hl1w9zj6li-attic-test-no-deps"], @@ -31,7 +31,7 @@ pub const NO_DEPS: TestNar = TestNar { /// as `3k1wymic8p7h5pfcqfhh0jan8ny2a712-attic-test-with-deps-c-final`. pub const WITH_DEPS_A: TestNar = TestNar { store_path: "/nix/store/n7q4i7rlmbk4xz8qdsxpm6jbhrnxraq2-attic-test-with-deps-a", - original_file: include_bytes!("nar/n7q4i7rlmbk4xz8qdsxpm6jbhrnxraq2-attic-test-with-deps-a"), + _original_file: include_bytes!("nar/n7q4i7rlmbk4xz8qdsxpm6jbhrnxraq2-attic-test-with-deps-a"), nar: include_bytes!("nar/n7q4i7rlmbk4xz8qdsxpm6jbhrnxraq2-attic-test-with-deps-a.nar"), export: include_bytes!("nar/n7q4i7rlmbk4xz8qdsxpm6jbhrnxraq2-attic-test-with-deps-a.export"), closure: &[ @@ -46,7 +46,7 @@ pub const WITH_DEPS_A: TestNar = TestNar { /// This depends on `3k1wymic8p7h5pfcqfhh0jan8ny2a712-attic-test-with-deps-c-final`. pub const WITH_DEPS_B: TestNar = TestNar { store_path: "/nix/store/544qcchwgcgpz3xi1bbml28f8jj6009p-attic-test-with-deps-b", - original_file: include_bytes!("nar/544qcchwgcgpz3xi1bbml28f8jj6009p-attic-test-with-deps-b"), + _original_file: include_bytes!("nar/544qcchwgcgpz3xi1bbml28f8jj6009p-attic-test-with-deps-b"), nar: include_bytes!("nar/544qcchwgcgpz3xi1bbml28f8jj6009p-attic-test-with-deps-b.nar"), export: include_bytes!("nar/544qcchwgcgpz3xi1bbml28f8jj6009p-attic-test-with-deps-b.export"), closure: &[ @@ -58,7 +58,7 @@ pub const WITH_DEPS_B: TestNar = TestNar { /// Expected values for `3k1wymic8p7h5pfcqfhh0jan8ny2a712-attic-test-with-deps-c-final`. pub const WITH_DEPS_C: TestNar = TestNar { store_path: "/nix/store/3k1wymic8p7h5pfcqfhh0jan8ny2a712-attic-test-with-deps-c-final", - original_file: include_bytes!( + _original_file: include_bytes!( "nar/3k1wymic8p7h5pfcqfhh0jan8ny2a712-attic-test-with-deps-c-final" ), nar: include_bytes!("nar/3k1wymic8p7h5pfcqfhh0jan8ny2a712-attic-test-with-deps-c-final.nar"), @@ -75,7 +75,7 @@ pub struct TestNar { store_path: &'static str, /// The original file. - original_file: &'static [u8], + _original_file: &'static [u8], /// A NAR dump without path metadata. nar: &'static [u8], diff --git a/book/src/admin-guide/deployment/nixos.md b/book/src/admin-guide/deployment/nixos.md index 27615d2..b30f7de 100644 --- a/book/src/admin-guide/deployment/nixos.md +++ b/book/src/admin-guide/deployment/nixos.md @@ -11,16 +11,16 @@ Attic provides [a NixOS module](https://github.com/zhaofengli/attic/blob/main/ni ## Generating the Credentials File -The HS256 JWT secret can be generated with the `openssl` utility: +The RS256 JWT secret can be generated with the `openssl` utility: ```bash -openssl rand 64 | base64 -w0 +nix run nixpkgs#openssl -- genrsa -traditional 4096 | base64 -w0 ``` Create a file on the server containing the following contents: ``` -ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="output from openssl" +ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64="output from above" ``` Ensure the file is only accessible by root. @@ -47,6 +47,8 @@ You can import the module in one of two ways: settings = { listen = "[::]:8080"; + jwt = { }; + # Data chunking # # Warning: If you change any of the values here, it will be diff --git a/integration-tests/basic/default.nix b/integration-tests/basic/default.nix index d6b17bc..ba18071 100644 --- a/integration-tests/basic/default.nix +++ b/integration-tests/basic/default.nix @@ -5,8 +5,8 @@ let serverConfigFile = config.nodes.server.services.atticd.configFile; cmd = { - atticadm = "atticd-atticadm"; - atticd = ". /etc/atticd.env && export ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64 && atticd -f ${serverConfigFile}"; + atticadm = ". /etc/atticd.env && export ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64 && atticd-atticadm"; + atticd = ". /etc/atticd.env && export ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64 && atticd -f ${serverConfigFile}"; }; makeTestDerivation = pkgs.writeShellScript "make-drv" '' @@ -147,7 +147,7 @@ in { # For testing only - Don't actually do this environment.etc."atticd.env".text = '' - ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="dGVzdCBzZWNyZXQ=" + ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64='LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBekhqUzFGKzlRaFFUdlJZYjZ0UGhxS09FME5VYkIraTJMOTByWVBNQVVoYVBUMmlKCmVUNk9vWFlmZWszZlZ1dXIrYks1VWFVRjhUbEx2Y1FHa1Arckd0WDRiQUpGTWJBcTF3Y25FQ3R6ZGVERHJnSlIKMGUvNWJhdXQwSS9YS0ticG9oYjNvWVhtUmR5eG9WVGE3akY1bk11ajBsd25kUTcwYTF1ZGkzMGNpYkdTWHZMagpVeGltL3ByYjUrV3ZPdjN4UnhlbDZHYmptUW1RMVBHeHVLcmx3b1ZKRnlWTjl3QmExajBDelJDcURnTFRwQWw0CjhLVWlDY2V1VUZQcmdZaW9vSVhyVExlWmxVbFVVV3FHSDBJbGFKeVUyQ05iNWJtZWM1TnZ4RDlaakFoYytucmgKRS80VzkxajdQMFVyQnp4am9NUTRlKzBPZDhmQnBvSDAwbm4xUXdJREFRQUJBb0lCQUE2RmxEK21Ed3gyM1pJRAoxSGJBbHBuQ0IwaEhvbFJVK0Q5OC96d3k5ZlplaU00VWVCTUcyTjFweE1HTWIweStqeWU4UkVJaXJNSGRsbDRECllvNEF3bmUwODZCRUp3TG81cG4vOVl2RjhqelFla1ZNLzkrZm9nRGlmUVUvZWdIMm5NZzR4bHlQNUhOWXdicmEKQ25SNVNoQlRQQzdRQWJOa0hRTFU3bUwrUHowZUlXaG9KWVRoUUpkU0g3RDB0K1QwZzVVNDdPam5qbXJaTWwxaApHOE1IUHhKMk5WU1l2N0dobnpjblZvcVVxYzlxeldXRDZXZERtV1BPNGJ1K2p0b2E2U2o4cjJtb0RRZ1A5YXNhCm93RUFJbHBmbVkxYUx2dENwWG4rejRTTWJKcHRXMlVvaktGa2dkYm9jZmtXYWdtSGZRa2xmS0dBQ0hibU9ZV24KeDRCbTU3a0NnWUVBN1dXaXJDZnBRR01hR3A2WWxMQlVUc1VJSXJOclF4UmtuRlc3dFVYd0NqWFZ5SDlTR3FqNgphTkNhYzZpaks3QVNBYXlxY1JQRjFPY2gyNmxpVmRKUHNuRGxwUjhEVXB2TzRVOVRzSTJyZ1lZYzNrSWkzVGFKClgzV0Vic1Z6Nk45WXFPSXlnVnZiTEhLS0F4Uyt4b1Z2SjkzQmdWRHN5SkxRdmhrM3VubXk3M2tDZ1lFQTNINnYKeUhOKzllOVAyOS9zMVY1eWZxSjdvdVdKV0lBTHFDYm9zOTRRSVdPSG5HRUtSSGkydWIzR0d6U2tRSzN1eTUrdQo4M0txaFJOejRVMkdOK1pLaFE0NHhNVmV4TUVvZzJVU3lTaVZ0cFdqWXBwT2Q1NnVaMzRWaFU2TWRNZS9zT0JnCnNoei84MUxUSis2cHdFZE9wV2tPVlRaMXJISlZXQmdtVk5qWjc1c0NnWUVBNVd5YjBaU2dyMEVYTVRLa2NzNFcKTENudXV0cDZodEZtaWsrd29IZCtpOStMUThFSU1BdXVOUzJrbHJJYlAxVmhrWXkxQzZMNFJkRTV2M2ZyT05XUApmL3ZyYzdDTkhZREdacWlyVUswWldvdXB5b0pQLzBsOWFXdkJHT3hxSUZ2NDZ2M3ZvV1NNWkdBdFVOenpvaGZDClhOeks3WmF2dndka0JOT0tNQVQ5RU1FQ2dZRUF3NEhaWDRWNUo1d2dWVGVDQ2RjSzhsb2tBbFpBcUNZeEw5SUEKTjZ4STVUSVpSb0dNMXhXcC81dlRrci9rZkMwOU5YUExiclZYbVZPY1JrTzFKTStmZDhjYWN1OEdqck11dHdMaAoyMWVQR0N3cWlQMkZZZTlqZVFTRkZJU0hhZXpMZll3V2NSZmhvdURudGRxYXpaRHNuU0kvd1RMZXVCOVFxU0lRCnF0NzByczBDZ1lCQ2lzV0VKdXpQUUlJNzVTVkU4UnJFZGtUeUdhOEVBOHltcStMdDVLRDhPYk80Q2JHYVFlWXkKWFpjSHVyOFg2cW1lWHZVU3MwMHBMMUdnTlJ3WCtSUjNMVDhXTm9vc0NqVDlEUW9GOFZveEtseDROVTRoUGlrTQpBc0w1RS9wYnVLeXkvSU5LTnQyT3ZPZmJYVitlTXZQdGs5c1dORjNyRTBYcU15TW9maG9NaVE9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=' ''; services.atticd = { @@ -156,6 +156,8 @@ in { settings = { listen = "[::]:8080"; + jwt = { }; + chunking = { nar-size-threshold = 1; min-size = 64 * 1024; @@ -165,7 +167,7 @@ in { }; }; - environment.systemPackages = [ pkgs.attic-server ]; + environment.systemPackages = [ pkgs.openssl pkgs.attic-server ]; networking.firewall.allowedTCPPorts = [ 8080 ]; }; diff --git a/nixos/atticd.nix b/nixos/atticd.nix index f14141a..223af05 100644 --- a/nixos/atticd.nix +++ b/nixos/atticd.nix @@ -16,7 +16,7 @@ let } '' cat $configFile - export ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="dGVzdCBzZWNyZXQ=" + export ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64="$(${pkgs.openssl}/bin/openssl genrsa -traditional 4096 | ${pkgs.coreutils}/bin/base64 -w0)" export ATTIC_SERVER_DATABASE_URL="sqlite://:memory:" ${cfg.package}/bin/atticd --mode check-config -f $configFile cat <$configFile >$out @@ -78,8 +78,8 @@ in Path to an EnvironmentFile containing required environment variables: - - ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64: The Base64-encoded version of the - HS256 JWT secret. Generate it with `openssl rand 64 | base64 -w0`. + - ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64: The base64-encoded RSA PEM PKCS1 of the + RS256 JWT secret. Generate it with `openssl genrsa -traditional 4096 | base64 -w0`. ''; type = types.nullOr types.path; default = null; @@ -153,9 +153,9 @@ in message = '' is not set. - Run `openssl rand 64 | base64 -w0` and create a file with the following contents: + Run `openssl genrsa -traditional -out private_key.pem 4096 | base64 -w0` and create a file with the following contents: - ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64="output from command" + ATTIC_SERVER_TOKEN_RS256_SECRET="output from command" Then, set `services.atticd.credentialsFile` to the quoted absolute path of the file. ''; diff --git a/server/Cargo.toml b/server/Cargo.toml index a4794d3..766eaef 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -61,6 +61,7 @@ tracing-subscriber = { version = "0.3.17", features = [ "json" ] } uuid = { version = "1.3.3", features = ["v4"] } console-subscriber = "0.2.0" xdg = "2.5.0" +rsa = "0.9.3" [dependencies.async-compression] version = "0.4.0" diff --git a/server/src/access/http.rs b/server/src/access/http.rs index 1d108ff..5ba064a 100644 --- a/server/src/access/http.rs +++ b/server/src/access/http.rs @@ -1,5 +1,7 @@ //! HTTP middlewares for access control. +use attic::cache::CacheName; +use attic_token::util::parse_authorization_header; use axum::{extract::Request, middleware::Next, response::Response}; use sea_orm::DatabaseConnection; use tokio::sync::OnceCell; @@ -8,8 +10,6 @@ use crate::access::{CachePermission, Token}; use crate::database::{entity::cache::CacheModel, AtticDatabase}; use crate::error::ServerResult; use crate::{RequestState, State}; -use attic::cache::CacheName; -use attic_token::util::parse_authorization_header; /// Auth state. #[derive(Debug)] @@ -101,10 +101,19 @@ pub async fn apply_auth(req: Request, next: Next) -> Response { .and_then(parse_authorization_header) .and_then(|jwt| { let state = req.extensions().get::().unwrap(); - let res_token = Token::from_jwt(&jwt, &state.config.token_hs256_secret); + let signature_type = state.config.jwt.signing_config.clone().into(); + + let res_token = Token::from_jwt( + &jwt, + &signature_type, + &state.config.jwt.token_bound_issuer, + &state.config.jwt.token_bound_audiences, + ); + if let Err(e) = &res_token { tracing::debug!("Ignoring bad JWT token: {}", e); } + res_token.ok() }); diff --git a/server/src/adm/command/make_token.rs b/server/src/adm/command/make_token.rs index 1cfef1c..c027d41 100644 --- a/server/src/adm/command/make_token.rs +++ b/server/src/adm/command/make_token.rs @@ -115,7 +115,13 @@ pub async fn run(config: Config, opts: Opts) -> Result<()> { if sub.dump_claims { println!("{}", serde_json::to_string(token.opaque_claims())?); } else { - let encoded_token = token.encode(&config.token_hs256_secret)?; + let signature_type = config.jwt.signing_config.into(); + + let encoded_token = token.encode( + &signature_type, + &config.jwt.token_bound_issuer, + &config.jwt.token_bound_audiences, + )?; println!("{}", encoded_token); } diff --git a/server/src/config-template.toml b/server/src/config-template.toml index 84127d2..f81f61d 100644 --- a/server/src/config-template.toml +++ b/server/src/config-template.toml @@ -34,13 +34,6 @@ allowed-hosts = [] # cache. #require-proof-of-possession = true -# JWT signing token -# -# Set this to the Base64 encoding of some random data. -# You can also set it via the `ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64` environment -# variable. -token-hs256-secret-base64 = "%token_hs256_secret_base64%" - # Database connection [database] # Connection URL @@ -85,7 +78,7 @@ path = "%storage_path%" #[storage.credentials] # access_key_id = "" # secret_access_key = "" - + # Data chunking # # Warning: If you change any of the values here, it will be @@ -134,3 +127,37 @@ interval = "12 hours" # Zero (default) means time-based garbage-collection is # disabled by default. You can enable it on a per-cache basis. #default-retention-period = "6 months" + +[jwt] +# WARNING: Changing _anything_ in this section will break any existing +# tokens. If you need to regenerate them, ensure that you use the the +# correct secret and include the `iss` and `aud` claims. + +# JWT `iss` claim +# +# Set this to the JWT issuer that you want to validate. +# If this is set, all received JWTs will validate that the `iss` claim +# matches this value. +#token-bound-issuer = "some-issuer" + +# JWT `aud` claim +# +# Set this to the JWT audience(s) that you want to validate. +# If this is set, all received JWTs will validate that the `aud` claim +# contains at least one of these values. +#token-bound-audiences = ["some-audience1", "some-audience2"] + +[jwt.signing] +# JWT RS256 secret key +# +# Set this to the base64-encoded private half of an RSA PEM PKCS1 key. +# You can also set it via the `ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64` +# environment variable. +token-rs256-secret-base64 = "%token_rs256_secret_base64%" + +# JWT HS256 secret key +# +# Set this to the base64-encoded HMAC secret key. +# You can also set it via the `ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64` +# environment variable. +#token-hs256-secret-base64 = "" diff --git a/server/src/config.rs b/server/src/config.rs index 7f24ba8..321034e 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -1,5 +1,6 @@ //! Server configuration. +use std::collections::HashSet; use std::env; use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -7,12 +8,16 @@ use std::time::Duration; use anyhow::Result; use async_compression::Level as CompressionLevel; +use attic_token::SignatureType; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use derivative::Derivative; use serde::{de, Deserialize}; use xdg::BaseDirectories; -use crate::access::{decode_token_hs256_secret_base64, HS256Key}; +use crate::access::{ + decode_token_hs256_secret_base64, decode_token_rs256_pubkey_base64, + decode_token_rs256_secret_base64, HS256Key, RS256KeyPair, RS256PublicKey, +}; use crate::narinfo::Compression as NixCompression; use crate::storage::{LocalStorageConfig, S3StorageConfig}; @@ -26,9 +31,18 @@ const XDG_PREFIX: &str = "attic"; /// This is useful for deploying to certain application platforms like Fly.io const ENV_CONFIG_BASE64: &str = "ATTIC_SERVER_CONFIG_BASE64"; -/// Environment variable storing the Base64-encoded HS256 JWT secret. +/// Environment variable storing the base64-encoded HMAC secret (used for signing and verifying +/// received JWTs). const ENV_TOKEN_HS256_SECRET_BASE64: &str = "ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64"; +/// Environment variable storing the base64-encoded RSA PEM PKCS1 private key (used for signing and +/// verifying received JWTs). +const ENV_TOKEN_RS256_SECRET_BASE64: &str = "ATTIC_SERVER_TOKEN_RS256_SECRET_BASE64"; + +/// Environment variable storing the base64-encoded RSA PEM PKCS1 public key (used for verifying +/// received JWTs only). +const ENV_TOKEN_RS256_PUBKEY_BASE64: &str = "ATTIC_SERVER_TOKEN_RS256_PUBKEY_BASE64"; + /// Environment variable storing the database connection string. const ENV_DATABASE_URL: &str = "ATTIC_SERVER_DATABASE_URL"; @@ -108,14 +122,71 @@ pub struct Config { #[serde(default = "Default::default")] pub garbage_collection: GarbageCollectionConfig, + /// JSON Web Token. + pub jwt: JWTConfig, +} + +/// JSON Web Token configuration. +#[derive(Clone, Derivative, Deserialize)] +#[derivative(Debug)] +pub struct JWTConfig { + /// The `iss` claim of the JWT. + /// + /// If specified, received JWTs must have this claim, and its value must match this + /// configuration. + #[serde(rename = "token-bound-issuer")] + #[serde(default = "Default::default")] + pub token_bound_issuer: Option, + + /// The `aud` claim of the JWT. + /// + /// If specified, received JWTs must have this claim, and must contain one of the configured + /// values. + #[serde(rename = "token-bound-audiences")] + #[serde(default = "Default::default")] + pub token_bound_audiences: Option>, + + /// JSON Web Token signing. + #[serde(rename = "signing")] + #[serde(default = "load_jwt_signing_config_from_env")] + #[derivative(Debug = "ignore")] + pub signing_config: JWTSigningConfig, +} + +/// JSON Web Token signing configuration. +#[derive(Clone, Deserialize)] +pub enum JWTSigningConfig { + /// JSON Web Token RSA pubkey. + /// + /// Set this to the base64-encoded RSA PEM PKCS1 public key to use for verifying JWTs only. + #[serde(rename = "token-rs256-pubkey-base64")] + #[serde(deserialize_with = "deserialize_token_rs256_pubkey_base64")] + RS256VerifyOnly(RS256PublicKey), + + /// JSON Web Token RSA secret. + /// + /// Set this to the base64-encoded RSA PEM PKCS1 private key to use for signing and verifying + /// JWTs. + #[serde(rename = "token-rs256-secret-base64")] + #[serde(deserialize_with = "deserialize_token_rs256_secret_base64")] + RS256SignAndVerify(RS256KeyPair), + /// JSON Web Token HMAC secret. /// - /// Set this to the base64 encoding of a randomly generated secret. + /// Set this to the base64-encoded HMAC secret to use for signing and verifying JWTs. #[serde(rename = "token-hs256-secret-base64")] #[serde(deserialize_with = "deserialize_token_hs256_secret_base64")] - #[serde(default = "load_token_hs256_secret_from_env")] - #[derivative(Debug = "ignore")] - pub token_hs256_secret: HS256Key, + HS256SignAndVerify(HS256Key), +} + +impl From for SignatureType { + fn from(value: JWTSigningConfig) -> Self { + match value { + JWTSigningConfig::RS256VerifyOnly(key) => Self::RS256PubkeyOnly(key), + JWTSigningConfig::RS256SignAndVerify(key) => Self::RS256(key), + JWTSigningConfig::HS256SignAndVerify(key) => Self::HS256(key), + } + } } /// Database connection configuration. @@ -240,16 +311,82 @@ pub struct GarbageCollectionConfig { pub default_retention_period: Duration, } -fn load_token_hs256_secret_from_env() -> HS256Key { - let s = env::var(ENV_TOKEN_HS256_SECRET_BASE64) - .expect("The HS256 secret must be specified in either token_hs256_secret or the ATTIC_SERVER_TOKEN_HS256_SECRET_BASE64 environment."); +fn load_jwt_signing_config_from_env() -> JWTSigningConfig { + let config = if let Some(config) = load_token_rs256_pubkey_from_env() { + config + } else if let Some(config) = load_token_rs256_secret_from_env() { + config + } else if let Some(config) = load_token_hs256_secret_from_env() { + config + } else { + panic!( + "\n\ + You must configure JWT signing and verification inside your TOML \ + configuration by setting one of the following options in the \ + [jwt.signing] block:\n\ + \n\ + * token-rs256-pubkey-base64\n\ + * token-rs256-secret-base64\n\ + * token-hs256-secret-base64\n\ + \n\ + or by setting one of the following environment variables:\n\ + \n\ + * {ENV_TOKEN_RS256_PUBKEY_BASE64}\n\ + * {ENV_TOKEN_RS256_SECRET_BASE64}\n\ + * {ENV_TOKEN_HS256_SECRET_BASE64}\n\ + \n\ + Options will be tried in that same order (configuration options \ + first, then environment options if none of the configuration options \ + were set, starting with the respective RSA pubkey option, the RSA \ + secret option, and finally the HMAC secret option). \ + The first option that is found will be used.\n\ + \n\ + If an RS256 pubkey (asymmetric RSA PEM PKCS1 public key) is \ + provided, it will only be possible to verify received JWTs, and not \ + sign new JWTs.\n\ + \n\ + If an RS256 secret (asymmetric RSA PEM PKCS1 private key) is \ + provided, it will be used for both signing new JWTs and verifying \ + received JWTs.\n\ + \n\ + If an HS256 secret (symmetric HMAC secret) is provided, it will be \ + used for both signing new JWTs and verifying received JWTs.\n\ + " + ) + }; + + config +} + +fn load_token_hs256_secret_from_env() -> Option { + let s = env::var(ENV_TOKEN_HS256_SECRET_BASE64).ok()?; - decode_token_hs256_secret_base64(&s).expect("Failed to load as decoding key") + decode_token_hs256_secret_base64(&s) + .ok() + .map(JWTSigningConfig::HS256SignAndVerify) +} + +fn load_token_rs256_secret_from_env() -> Option { + let s = env::var(ENV_TOKEN_RS256_SECRET_BASE64).ok()?; + + decode_token_rs256_secret_base64(&s) + .ok() + .map(JWTSigningConfig::RS256SignAndVerify) +} + +fn load_token_rs256_pubkey_from_env() -> Option { + let s = env::var(ENV_TOKEN_RS256_PUBKEY_BASE64).ok()?; + + decode_token_rs256_pubkey_base64(&s) + .ok() + .map(JWTSigningConfig::RS256VerifyOnly) } fn load_database_url_from_env() -> String { - env::var(ENV_DATABASE_URL) - .expect("Database URL must be specified in either database.url or the ATTIC_SERVER_DATABASE_URL environment.") + env::var(ENV_DATABASE_URL).expect(&format!( + "Database URL must be specified in either database.url \ + or the {ENV_DATABASE_URL} environment." + )) } impl CompressionConfig { @@ -308,6 +445,32 @@ where Ok(key) } +fn deserialize_token_rs256_secret_base64<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + use de::Error; + + let s = String::deserialize(deserializer)?; + let key = decode_token_rs256_secret_base64(&s).map_err(Error::custom)?; + + Ok(key) +} + +fn deserialize_token_rs256_pubkey_base64<'de, D>( + deserializer: D, +) -> Result +where + D: de::Deserializer<'de>, +{ + use de::Error; + + let s = String::deserialize(deserializer)?; + let key = decode_token_rs256_pubkey_base64(&s).map_err(Error::custom)?; + + Ok(key) +} + fn default_listen_address() -> SocketAddr { "[::]:8080".parse().unwrap() } diff --git a/server/src/oobe.rs b/server/src/oobe.rs index 987edde..d3d912d 100644 --- a/server/src/oobe.rs +++ b/server/src/oobe.rs @@ -14,11 +14,10 @@ use anyhow::Result; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use chrono::{Months, Utc}; -use rand::distributions::Alphanumeric; -use rand::Rng; +use rsa::pkcs1::EncodeRsaPrivateKey; use tokio::fs::{self, OpenOptions}; -use crate::access::{decode_token_hs256_secret_base64, Token}; +use crate::access::{decode_token_rs256_secret_base64, SignatureType, Token}; use crate::config; use attic::cache::CacheNamePattern; @@ -45,20 +44,18 @@ pub async fn run_oobe() -> Result<()> { let storage_path = data_path.join("storage"); fs::create_dir_all(&storage_path).await?; - let hs256_secret_base64 = { - let random: String = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(128) - .map(char::from) - .collect(); + let rs256_secret_base64 = { + let mut rng = rand::thread_rng(); + let private_key = rsa::RsaPrivateKey::new(&mut rng, 4096)?; + let pkcs1_pem = private_key.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)?; - BASE64_STANDARD.encode(random) + BASE64_STANDARD.encode(pkcs1_pem.as_bytes()) }; let config_content = CONFIG_TEMPLATE .replace("%database_url%", &database_url) .replace("%storage_path%", storage_path.to_str().unwrap()) - .replace("%token_hs256_secret_base64%", &hs256_secret_base64); + .replace("%token_rs256_secret_base64%", &rs256_secret_base64); fs::write(&config_path, config_content.as_bytes()).await?; @@ -76,8 +73,8 @@ pub async fn run_oobe() -> Result<()> { perm.configure_cache_retention = true; perm.destroy_cache = true; - let key = decode_token_hs256_secret_base64(&hs256_secret_base64).unwrap(); - token.encode(&key)? + let key = decode_token_rs256_secret_base64(&rs256_secret_base64).unwrap(); + token.encode(&SignatureType::RS256(key), &None, &None)? }; eprintln!(); diff --git a/token/Cargo.toml b/token/Cargo.toml index a6880d3..37f75f5 100644 --- a/token/Cargo.toml +++ b/token/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" attic = { path = "../attic", default-features = false } base64 = "0.22.1" -chrono = "0.4.24" +chrono = "0.4.31" displaydoc = "0.2.4" indexmap = { version = "2.2.6", features = ["serde"] } jwt-simple = "0.11.5" @@ -18,3 +18,4 @@ regex = "1.8.3" serde = "1.0.163" serde_with = "3.0.0" tracing = "0.1.37" +rsa = "0.9.3" diff --git a/token/src/lib.rs b/token/src/lib.rs index 3b1bf46..6f1487e 100644 --- a/token/src/lib.rs +++ b/token/src/lib.rs @@ -1,7 +1,7 @@ //! Access control. //! //! Access control in Attic is simple and stateless [0] - The server validates -//! the JWT against a HS256 key and allows access based on the `https://jwt.attic.rs/v1` +//! the JWT against the configured key and allows access based on the `https://jwt.attic.rs/v1` //! claim. //! //! One primary goal of the Attic Server is easy scalability. It's designed @@ -83,14 +83,16 @@ pub mod util; #[cfg(test)] mod tests; +use std::collections::HashSet; use std::error::Error as StdError; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use chrono::{DateTime, Utc}; use displaydoc::Display; use indexmap::IndexMap; +use jwt_simple::prelude::{Duration, RSAKeyPairLike, RSAPublicKeyLike, VerificationOptions}; pub use jwt_simple::{ - algorithms::{HS256Key, MACLike}, + algorithms::{HS256Key, MACLike, RS256KeyPair, RS256PublicKey}, claims::{Claims, JWTClaims}, prelude::UnixTimeStamp, }; @@ -155,49 +157,49 @@ pub struct AtticAccess { pub struct CachePermission { /// Can pull objects from the cache. #[serde(default = "CachePermission::permission_default")] - #[serde(skip_serializing_if = "is_false")] + #[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(rename = "r")] #[serde_as(as = "BoolFromInt")] pub pull: bool, /// Can push objects to the cache. #[serde(default = "CachePermission::permission_default")] - #[serde(skip_serializing_if = "is_false")] + #[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(rename = "w")] #[serde_as(as = "BoolFromInt")] pub push: bool, /// Can delete objects from the cache. #[serde(default = "CachePermission::permission_default")] - #[serde(skip_serializing_if = "is_false")] + #[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(rename = "d")] #[serde_as(as = "BoolFromInt")] pub delete: bool, /// Can create the cache itself. #[serde(default = "CachePermission::permission_default")] - #[serde(skip_serializing_if = "is_false")] + #[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(rename = "cc")] #[serde_as(as = "BoolFromInt")] pub create_cache: bool, /// Can reconfigure the cache. #[serde(default = "CachePermission::permission_default")] - #[serde(skip_serializing_if = "is_false")] + #[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(rename = "cr")] #[serde_as(as = "BoolFromInt")] pub configure_cache: bool, /// Can configure retention/quota settings. #[serde(default = "CachePermission::permission_default")] - #[serde(skip_serializing_if = "is_false")] + #[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(rename = "cq")] #[serde_as(as = "BoolFromInt")] pub configure_cache_retention: bool, /// Can destroy the cache itself. #[serde(default = "CachePermission::permission_default")] - #[serde(skip_serializing_if = "is_false")] + #[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(rename = "cd")] #[serde_as(as = "BoolFromInt")] pub destroy_cache: bool, @@ -223,14 +225,68 @@ pub enum Error { /// Base64 decode error: {0} Base64Error(base64::DecodeError), + + /// RSA Key error: {0} + RsaKeyError(rsa::pkcs1::Error), + + /// Failure decoding the base64 layer of the base64 encoded PEM + Utf8Error(std::str::Utf8Error), + + /// Pubkey-only JWT authentication cannot create signed JWTs + PubkeyOnlyCannotCreateToken, +} + +/// The supported JWT signature types. +pub enum SignatureType { + HS256(HS256Key), + RS256(RS256KeyPair), + RS256PubkeyOnly(RS256PublicKey), } impl Token { /// Verifies and decodes a token. - pub fn from_jwt(token: &str, key: &HS256Key) -> Result { - key.verify_token(token, None) - .map_err(Error::TokenError) - .map(Token) + pub fn from_jwt( + token: &str, + signature_type: &SignatureType, + maybe_bound_issuer: &Option, + maybe_bound_audiences: &Option>, + ) -> Result { + let opts = VerificationOptions { + reject_before: None, + accept_future: false, + required_subject: None, + required_key_id: None, + required_public_key: None, + required_nonce: None, + allowed_issuers: maybe_bound_issuer + .as_ref() + .map(|s| [s.to_owned()].into()) + .to_owned(), + allowed_audiences: maybe_bound_audiences.to_owned(), + time_tolerance: None, + max_validity: None, + max_token_length: None, + max_header_length: None, + artificial_time: None, + }; + + match signature_type { + SignatureType::HS256(key) => key + .verify_token(token, Some(opts)) + .map_err(Error::TokenError) + .map(Token), + SignatureType::RS256(key) => { + let public_key = key.public_key(); + public_key + .verify_token(token, Some(opts)) + .map_err(Error::TokenError) + .map(Token) + } + SignatureType::RS256PubkeyOnly(key) => key + .verify_token(token, Some(opts)) + .map_err(Error::TokenError) + .map(Token), + } } /// Creates a new token with an expiration timestamp. @@ -239,12 +295,17 @@ impl Token { attic_ns: Default::default(), }; + let now_epoch = Utc::now().signed_duration_since(DateTime::UNIX_EPOCH); + Self(JWTClaims { issued_at: None, expires_at: Some(UnixTimeStamp::from_secs( exp.timestamp().try_into().unwrap(), )), - invalid_before: None, + invalid_before: Some(Duration::new( + now_epoch.num_seconds().try_into().unwrap(), + 0, + )), issuer: None, subject: Some(sub), audiences: None, @@ -255,8 +316,28 @@ impl Token { } /// Encodes the token. - pub fn encode(&self, key: &HS256Key) -> Result { - key.authenticate(self.0.clone()).map_err(Error::TokenError) + pub fn encode( + &self, + signature_type: &SignatureType, + maybe_bound_issuer: &Option, + maybe_bound_audiences: &Option>, + ) -> Result { + let mut token = self.0.clone(); + + if let Some(issuer) = maybe_bound_issuer { + token = token.with_issuer(issuer); + } + if let Some(audiences) = maybe_bound_audiences { + token = token.with_audiences(audiences.to_owned()); + } + + match signature_type { + SignatureType::HS256(key) => key.authenticate(token).map_err(Error::TokenError), + SignatureType::RS256(key) => key.sign(token).map_err(Error::TokenError), + SignatureType::RS256PubkeyOnly(_) => { + return Err(Error::PubkeyOnlyCannotCreateToken); + } + } } /// Returns the subject of the token. @@ -362,11 +443,23 @@ impl CachePermission { impl StdError for Error {} pub fn decode_token_hs256_secret_base64(s: &str) -> Result { - let secret = BASE64_STANDARD.decode(s).map_err(Error::Base64Error)?; - Ok(HS256Key::from_bytes(&secret)) + let decoded = BASE64_STANDARD.decode(s).map_err(Error::Base64Error)?; + let secret = std::str::from_utf8(&decoded).map_err(Error::Utf8Error)?; + Ok(HS256Key::from_bytes(&secret.as_bytes())) } -// bruh -fn is_false(b: &bool) -> bool { - !b +pub fn decode_token_rs256_secret_base64(s: &str) -> Result { + let decoded = BASE64_STANDARD.decode(s).map_err(Error::Base64Error)?; + let secret = std::str::from_utf8(&decoded).map_err(Error::Utf8Error)?; + let keypair = RS256KeyPair::from_pem(secret).map_err(Error::TokenError)?; + + Ok(keypair) +} + +pub fn decode_token_rs256_pubkey_base64(s: &str) -> Result { + let decoded = BASE64_STANDARD.decode(s).map_err(Error::Base64Error)?; + let pubkey = std::str::from_utf8(&decoded).map_err(Error::Utf8Error)?; + let pubkey = RS256PublicKey::from_pem(pubkey).map_err(Error::TokenError)?; + + Ok(pubkey) } diff --git a/token/src/tests.rs b/token/src/tests.rs index 948ffc9..5ea2156 100644 --- a/token/src/tests.rs +++ b/token/src/tests.rs @@ -10,15 +10,12 @@ macro_rules! cache { #[test] fn test_basic() { - // "very secure secret" - let base64_secret = "dmVyeSBzZWN1cmUgc2VjcmV0"; - - let dec_key = decode_token_hs256_secret_base64(base64_secret).unwrap(); - /* + $ cat json { "sub": "meow", "exp": 4102324986, + "nbf": 0, "https://jwt.attic.rs/v1": { "caches": { "all-*": {"r":1}, @@ -30,8 +27,15 @@ fn test_basic() { } } */ + // nix shell nixpkgs#jwt-cli + // openssl genpkey -out rs256 -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -outform der + // BASE64_SECRET=$(openssl rsa -in rs256 -outform PEM -traditional | base64 -w0) + let base64_secret = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBNUZranRMRzV5eS9pMFlnYkQxeUJBK21GckNmLzZiQ2F0TDFFQ3ppNG1tZWhSZTcwCkFEL0dSSHhTVUErc0pZeCtZNjlyL0RqQWs2OFJlQ1c4b2FQWXhtc21RNG5VM2ZwZ2E3WWFqZ3ZoWmVsa3JtaC8KZ1ZURWtFTG1IZlJtQkwvOWlsT20yRHNtYTVhUFo0SFl6ellpdjJvcFF5UGRndXcyWXFtbzE3Nk5MdllCMmpJTwovR3FkdE55K3NPV296NktVSVlJa0hWWU5HMENVcFNzdXBqUTJ6VTVZMFc2UXlNQWFWd1BONElJT3lXWUNwZXRECjFJbWxYekhROXM4NXFSWnlLa21iZFhtTVBVWmUvekRxc2FFd3lscFlpT0RjbDdRYU5QTzEzZnk3UGtQMmVwdUkKTk5tZ1E0WEF0MkF4ZXNKck5ibUs4aG1iM3doRXZkNjRFMGdEV1FJREFRQUJBb0lCQUJEemNRd2IyVi8wK1JCMgoyeE5qMll2eHpPTi93S2FYWHBTbUxDUHRIUDhSVEU2RnM0VkZOckdrelBOMmhsL3ZNdjZ4YWdHNk1NbUZ5SFV6CnovSHIyTTY1NjRnOTloaFlXc29FSmFwL3hVYXNjYlhrdWZwZTBZeW4rcThra21JdDRtTmZYRlpXNWI0ODJmNWsKRERVdG5weTVBOEVoSzNOcGw0dnhia0E5dS90TlVlT1NHTkhPYVZjcHdERVhDNXJ4bmFxTm5wMkMwa1A4ODRINgpSb2lZVkF4bytHaVpNVzhIOFRmSXVsenh3c04yQnVNcUNmOGVhNG1EM0pRVHZ2REhhUHM4eVJTUlB3UmlHYUkzCnVybFRmdjg4U20va09oL0N2SkpoRnhCVkVNVjIydWRNUmU3L3NpTWtlbVlvUnhaTWJjRGVQK2h1RktJWTRSMEoKNnRJUHQ3VUNnWUVBOTlhL2IzeFBsQWh0ck02dUlUUXNQd0FYQUg3Q1NXL1FSdVJUTWVhYXVIMk9sRitjZmpMNApJS1Nsdy9QaUtaUEk1TFRWM2ZVZk5WNTVsOFZHTytsT2ViTFhnaXBYM3BqSDBma3AyY3Q2Smk3aGw0aUlXK0h0ClpJNE9KYkYwTTBETHdySkd3T25QL2trRHNxSW9IbC9MdTBRM2FxSm1RVCsvcG54R083R21kbDhDZ1lFQTY5NFcKZHF2NnF4VjF5V0Z4QWZOOE1hZStpTC9xY1VhTm85ZzMva2YvOXZ3VXdtcERvR0xnaVVLMWZKb3BUYlBjcWgwRwptbUZEQ3V2M1Q0OS9yU2k5dU4zYm82cmlXRUl4VFg1YUtFSjlpSEFMWDJGWDdGSDJRdUZGWEwzQ2c0ckdvL1pDCmdjUkxuS3dma3JUVnRxeEdaNjN4YmsvcFpHWjZtTW01VkNDck1VY0NnWUVBc3JUT1pQMG1CSC92VldQU2UyNjcKV05JZncrT2pCSUR6bGFxZHNxV3Rlc3BPUFA2VVFRdFBqM29wYlJvMlFmU21Md09XRXUzbEN2Nk1mcnRvNFZwaAprNjg1WmtwU0FkZjRmWmRFYmg4aWZOWGhKUHIyR0FyWXVtRVVJbW5LZUFxSTRtTGFVZEJHZ2Z6MEJhS1hldzlvClFDZjRMWlBjVjhBMzJUeFRDRWdZMTlFQ2dZQU04U2F5WkVWZzFkQ2N1Q2dIUDJEMUtJc2YzY2Z6WnplbVlkclEKclFxeWRxcDg4Rys5Z1M5bzJLdzBwaERXSHFSaEFTNjNrZGFuNXNLdkx1U0dqOUc1THhNNks4bzNwWW9uQW1QWQpDYTN4cXBRMUs1WXpkVnZaMTVxQ3VEYlFHUEZGVmVIWVZQa0JJOENud0J4cDVaSUhabGYxQVpXQTJNNnBTNGhMCndXOGpTUUtCZ1FDQmNJbjU4Y0lmZkhmMjM4SUJvZnR1UVVzREZGcnkzaUVpaWpTYmJ1WnB1Vm8zL2pWbUsyaEYKS2xUL2xoRDdWdGJ1V3phMG9WQmZDaWZqMnZ2S2pmZ0l6NnF3Um1UbC9DSjlWdUNHTUI1VG55cGl3OEtodXorSAo0L2twdDdNcW9WQ0dRSjd1WVQyQzY1K0JqNklnUnBQT09za3VKNW1RZ0FlbTQ3eDBrVnRSemc9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo="; + + let dec_key = decode_token_rs256_secret_base64(base64_secret).unwrap(); - let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjQxMDIzMjQ5ODYsImh0dHBzOi8vand0LmF0dGljLnJzL3YxIjp7ImNhY2hlcyI6eyJhbGwtKiI6eyJyIjoxfSwiYWxsLWNpLSoiOnsidyI6MX0sImNhY2hlLXJvIjp7InIiOjF9LCJjYWNoZS1ydyI6eyJyIjoxLCJ3IjoxfSwidGVhbS0qIjp7ImNjIjoxLCJyIjoxLCJ3IjoxfX19LCJpYXQiOjE3MTY2NjA1ODksInN1YiI6Im1lb3cifQ.8vtxp_1OEYdcnkGPM4c9ORXooJZV7DOTS4NRkMKN8mw"; + // TOKEN=$(jq -c < json | jwt encode --alg RS256 --secret @./rs256 -) + let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjQxMDIzMjQ5ODYsImh0dHBzOi8vand0LmF0dGljLnJzL3YxIjp7ImNhY2hlcyI6eyJhbGwtKiI6eyJyIjoxfSwiYWxsLWNpLSoiOnsidyI6MX0sImNhY2hlLXJvIjp7InIiOjF9LCJjYWNoZS1ydyI6eyJyIjoxLCJ3IjoxfSwidGVhbS0qIjp7ImNjIjoxLCJyIjoxLCJ3IjoxfX19LCJpYXQiOjE3MjIwMDUwNzksIm5iZiI6MCwic3ViIjoibWVvdyJ9.Zs24IUbQOpOjhEe0sfsoSSJhDrzf4v-_wX_ceKqHeb2MERY8XSIQ1RPTNVeOW4LfJHumJj_rxh8Wv2BRGZSMldrTt0Ab_N7FnkhA37_jnRvgvEjSG3V4fC8aA4KoOa-43NRpg4HmPxiXte5-6LneBOR94Wss868wC1b_2yX2zCc1wQoZA3LNo-CRLnL4Yp5wY4Bbgyguv_9mfqXVYZykZnxumyGwVFD-Rub3KQ9d53Rf9tKcvRk9qxO2q8F2PKjeaUBG2xZtGwkWTMvSmwR1dKtkPUyPggOzbLoUG-6fxfo7D3NyL5qWCSN_7CkI-xlsRSLY1gTq-FqXvcpHeZbc8w"; // NOTE(cole-h): check that we get a consistent iteration order when getting permissions for // caches -- this depends on the order of the fields in the token, but should otherwise be @@ -40,7 +44,8 @@ fn test_basic() { for _ in 0..=1_000 { // NOTE(cole-h): we construct a new Token every iteration in order to get different "random // state" - let decoded = Token::from_jwt(token, &dec_key).unwrap(); + let decoded = + Token::from_jwt(token, &SignatureType::RS256(dec_key.clone()), &None, &None).unwrap(); let perm_all_ci = decoded.get_permission_for_cache(&cache! { "all-ci-abc" }); // NOTE(cole-h): if the iteration order of the token is inconsistent, the permissions may be @@ -55,7 +60,7 @@ fn test_basic() { "Iteration order should be consistent to prevent random auth failures (and successes)" ); - let decoded = Token::from_jwt(token, &dec_key).unwrap(); + let decoded = Token::from_jwt(token, &SignatureType::RS256(dec_key), &None, &None).unwrap(); let perm_rw = decoded.get_permission_for_cache(&cache! { "cache-rw" });