Skip to content

Commit

Permalink
feat: support multiple jwt public keys
Browse files Browse the repository at this point in the history
Support two JWT keys. One used for signing (the primary private key),
and an extra one for verification, so that we can rotate keys without
downtime, and then remove the "secondary" if desired. It also now
possible to specify the key algorithm. ES256 and RS256 are supported but
RS256 will be removed.

Adds a new crate, `si-jwt-public-key` (since this logic needs to be
shared between sdf and the module-index).
  • Loading branch information
zacharyhamm committed Dec 9, 2024
1 parent 84dd6d3 commit 8e99bac
Show file tree
Hide file tree
Showing 34 changed files with 731 additions and 398 deletions.
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ members = [
"lib/si-firecracker",
"lib/si-frontend-types-rs",
"lib/si-hash",
"lib/si-jwt-public-key",
"lib/si-layer-cache",
"lib/si-pkg",
"lib/si-pool-noodle",
Expand Down
7 changes: 7 additions & 0 deletions bin/auth-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ Use `pnpx prisma` to run prisma commands locally. For example

### JWT Signing Key

### ES256

- `ssh-keygen -t ecdsa -b 256 -m PEM -f jwtES256.key`
- `openssl ec -in jwtES256.key -pubout -outform PEM -out jwtES256.key.pub`

### RS256 (deprecated)

- `ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key`
- `openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub`

Expand Down
102 changes: 83 additions & 19 deletions bin/auth-api/src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
/*
instructions to generate JWT signing key
run `ssh-keygen -t ecdsa -b 256 -m PEM -f jwtES256.key`
- run `openssl ec -in jwtES256.key -pubout -outform PEM -out jwtES256.key.pub`
- `cat jwtES256.key`
- `cat jwtES256.key.pub`
For RS256: (deprecated)
- run `ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key` # Don't add passphrase
- run `openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub`
- `cat jwtRS256.key`
Expand All @@ -9,34 +15,92 @@ instructions to generate JWT signing key
import fs from "fs";
import JWT from "jsonwebtoken";

// load private and public key from either env var or paths set in config
// keys in the repo are also used by SDF to verify jwt is signed correctly and in tests to create/sign jwts
let _JWT_PRIVATE_KEY = process.env.JWT_PRIVATE_KEY;
if (!_JWT_PRIVATE_KEY && process.env.JWT_PRIVATE_KEY_PATH) {
// path is relative to .env file
_JWT_PRIVATE_KEY = fs.readFileSync(`${process.env.JWT_PRIVATE_KEY_PATH}`, 'utf-8');
}
let _JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY;
if (!_JWT_PUBLIC_KEY && process.env.JWT_PUBLIC_KEY_PATH) {
// path is relative to .env file
_JWT_PUBLIC_KEY = fs.readFileSync(`${process.env.JWT_PUBLIC_KEY_PATH}`, 'utf-8');
}
if (!_JWT_PRIVATE_KEY) throw new Error('Missing JWT signing private key');
if (!_JWT_PUBLIC_KEY) throw new Error('Missing JWT signing public key');
const DEFAULT_ALGO = "RS256";

type Algo = "RS256" | "ES256";

const jwtAlgo = (algo?: string): Algo => {
switch (algo) {
case "RS256":
case "ES256":
return algo;
default:
return DEFAULT_ALGO;
}
};

const keyEnvPaths = {
primary: {
private: "JWT_PRIVATE_KEY",
privatePath: "JWT_PRIVATE_KEY_PATH",
public: "JWT_PUBLIC_KEY",
publicPath: "JWT_PUBLIC_KEY_PATH",
algo: "JWT_ALGO",
},
secondary: {
private: "JWT_2ND_PRIVATE_KEY",
privatePath: "JWT_2ND_PRIVATE_KEY_PATH",
public: "JWT_2ND_PUBLIC_KEY",
publicPath: "JWT_2ND_PUBLIC_KEY_PATH",
algo: "JWT_2ND_ALGO",
},
};

// load private and public keys from either env var or paths set in config keys
// in the repo are also used by SDF to verify jwt is signed correctly and in
// tests to create/sign jwts

const prepareKeys = (which: "primary" | "secondary"): { privKey?: string, pubKey?: string, algo: Algo } => {
const privateLiteral = process.env[keyEnvPaths[which].private];
const privatePath = process.env[keyEnvPaths[which].privatePath];

let privKey = privateLiteral ?? (privatePath ? fs.readFileSync(privatePath, 'utf-8') : undefined);
if (privKey) {
privKey = privKey.replace(/\\n/g, '\n');
}

const publicLiteral = process.env[keyEnvPaths[which].public];
const publicPath = process.env[keyEnvPaths[which].publicPath];

let pubKey = publicLiteral ?? (publicPath ? fs.readFileSync(publicPath, 'utf-8') : undefined);
if (pubKey) {
pubKey = pubKey.replace(/\\n/g, '\n');
}

const algo = jwtAlgo(process.env[keyEnvPaths[which].algo]);

return {
privKey,
pubKey,
algo,
};
};

const { privKey: primaryPrivKey, pubKey: primaryPubKey, algo } = prepareKeys("primary");
const { pubKey: secondaryPubKey } = prepareKeys("secondary");

_JWT_PRIVATE_KEY = _JWT_PRIVATE_KEY.replace(/\\n/g, '\n');
_JWT_PUBLIC_KEY = _JWT_PUBLIC_KEY.replace(/\\n/g, '\n');
if (!primaryPrivKey) throw new Error('Missing JWT signing private key');
if (!primaryPubKey) throw new Error('Missing JWT signing public key');

export const JWT_PUBLIC_KEY = _JWT_PUBLIC_KEY;
export const JWT_PUBLIC_KEY = primaryPubKey;
export const JWT_2ND_PUBLIC_KEY = secondaryPubKey;

export function createJWT(
payload: Record<string, any>,
options?: Omit<JWT.SignOptions, 'algorithm'>,
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return JWT.sign(payload, _JWT_PRIVATE_KEY!, { algorithm: "RS256", ...options });
return JWT.sign(payload, primaryPrivKey!, { algorithm: algo, ...options });
}
export function verifyJWT(token: string) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return JWT.verify(token, _JWT_PUBLIC_KEY!);
try {
return JWT.verify(token, primaryPubKey!);
} catch (err) {
if (secondaryPubKey) {
return JWT.verify(token, secondaryPubKey);
} else {
throw err;
}
}
}
1 change: 1 addition & 0 deletions bin/module-index/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ rust_binary(
deps = [
"//lib/module-index-server:module-index-server",
"//lib/si-std:si-std",
"//lib/si-jwt-public-key:si-jwt-public-key",
"//lib/telemetry-application-rs:telemetry-application",
"//third-party/rust:clap",
"//third-party/rust:color-eyre",
Expand Down
9 changes: 9 additions & 0 deletions bin/module-index/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ pub(crate) struct Args {
/// The path to the JWT public signing key
#[arg(long, env)]
pub(crate) jwt_public_key: Option<String>,

#[arg(long, env)]
pub(crate) jwt_public_key_algo: Option<String>,

#[arg(long, env)]
pub(crate) jwt_secondary_public_key: Option<String>,

#[arg(long, env)]
pub(crate) jwt_secondary_public_key_algo: Option<String>,
// /// Database migration mode on startup
// #[arg(long, value_parser = PossibleValuesParser::new(MigrationMode::variants()))]
}
Expand Down
3 changes: 1 addition & 2 deletions bin/module-index/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ async fn async_main() -> Result<()> {

let config = Config::try_from(args)?;

let jwt_public_signing_key =
Server::load_jwt_public_signing_key(config.jwt_signing_public_key_path()).await?;
let jwt_public_signing_key = Server::load_jwt_public_signing_key(&config).await?;

// our pg pool works for migrations (refinery) but doesnt work for SeaORM :(
// so we set up both connections for now... Would like to clean this up
Expand Down
28 changes: 26 additions & 2 deletions bin/sdf/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,19 @@ pub(crate) struct Args {

/// jwt public signing key as a base64 string
#[arg(long)]
pub(crate) jwt_public_signing_key_base64: Option<SensitiveString>,
pub(crate) jwt_public_signing_key_base64: Option<String>,

/// jwt public signing key algorithm (ES256 or RS256)
#[arg(long)]
pub(crate) jwt_public_signing_key_algo: Option<String>,

/// jwt secondary public signing key as a base64 string
#[arg(long)]
pub(crate) jwt_secondary_public_signing_key_base64: Option<String>,

/// jwt secondary public signing key algorithm (ES256 or RS256)
#[arg(long)]
pub(crate) jwt_secondary_public_signing_key_algo: Option<String>,

/// The path at which the layer db cache is created/used on disk [e.g. /banana/]
#[arg(long)]
Expand Down Expand Up @@ -338,9 +350,21 @@ impl TryFrom<Args> for Config {
base64.to_string(),
);
}

if let Some(jwt) = args.jwt_public_signing_key_base64 {
config_map.set("jwt_signing_public_key.key_base64", jwt.to_string());
config_map.set("jwt_signing_public_key.key_base64", jwt);
}
if let Some(algo) = args.jwt_public_signing_key_algo {
config_map.set("jwt_signing_public_key.algo", algo);
}

if let Some(jwt) = args.jwt_secondary_public_signing_key_base64 {
config_map.set("jwt_secondary_signing_public_key.key_base64", jwt);
}
if let Some(algo) = args.jwt_secondary_public_signing_key_algo {
config_map.set("jwt_secondary_signing_public_key.algo", algo);
}

if let Some(layer_cache_disk_path) = args.layer_db_disk_path {
config_map.set("layer_db_config.disk_path", layer_cache_disk_path);
}
Expand Down
1 change: 1 addition & 0 deletions component/init/configs/service.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ runtime_strategy = "LocalFirecracker"

[jwt_signing_public_key]
key_base64 = "$SI_JWT_KEY_BASE64"
algo = "RS256"

[nats]
creds = """
Expand Down
3 changes: 2 additions & 1 deletion lib/dal-test/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ si-crypto = { path = "../../lib/si-crypto" }
si-data-nats = { path = "../../lib/si-data-nats" }
si-data-pg = { path = "../../lib/si-data-pg" }
si-events = { path = "../../lib/si-events-rs" }
si-jwt-public-key = { path = "../../lib/si-jwt-public-key" }
si-layer-cache = { path = "../../lib/si-layer-cache" }
si-pkg = { path = "../../lib/si-pkg" }
si-runtime = { path = "../../lib/si-runtime-rs" }
Expand Down Expand Up @@ -50,5 +51,5 @@ tokio = { workspace = true }
tokio-util = { workspace = true }
tracing-opentelemetry = { workspace = true }
tracing-subscriber = { workspace = true }
ulid = { workspace = true }
ulid = { workspace = true }
uuid = { workspace = true }
25 changes: 18 additions & 7 deletions lib/dal-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use std::{
env, fmt,
future::IntoFuture,
path::{Path, PathBuf},
str::FromStr,
sync::{Arc, Once},
};

Expand All @@ -39,8 +40,7 @@ use dal::{
builtins::func,
feature_flags::FeatureFlagService,
job::processor::{JobQueueProcessor, NatsProcessor},
DalContext, DalLayerDb, JetstreamStreams, JwtPublicSigningKey, ModelResult, ServicesContext,
Workspace,
DalContext, DalLayerDb, JetstreamStreams, ModelResult, ServicesContext, Workspace,
};
use derive_builder::Builder;
use jwt_simple::prelude::RS256KeyPair;
Expand All @@ -51,9 +51,10 @@ use si_crypto::{
};
use si_data_nats::{jetstream, NatsClient, NatsConfig};
use si_data_pg::{PgPool, PgPoolConfig};
use si_jwt_public_key::{JwtAlgo, JwtConfig, JwtPublicSigningKeyChain};
use si_layer_cache::hybrid_cache::CacheConfig;
use si_runtime::DedicatedExecutor;
use si_std::ResultExt;
use si_std::{CanonicalFile, ResultExt};
use telemetry::prelude::*;
use tokio::{fs::File, io::AsyncReadExt, sync::Mutex};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
Expand Down Expand Up @@ -170,6 +171,7 @@ pub struct Config {
module_index_url: String,
veritech_encryption_key_path: String,
jwt_signing_public_key_path: String,
jwt_signing_public_key_algo: JwtAlgo,
jwt_signing_private_key_path: String,
postgres_key_path: String,
#[builder(default)]
Expand Down Expand Up @@ -619,13 +621,22 @@ pub fn random_identifier_string() -> String {
}

/// Returns a JWT public signing key, which is used to verify claims.
pub async fn jwt_public_signing_key() -> Result<JwtPublicSigningKey> {
let jwt_signing_public_key_path = {
pub async fn jwt_public_signing_key() -> Result<JwtPublicSigningKeyChain> {
let jwt_config = {
let context_builder = TEST_CONTEXT_BUILDER.lock().await;
let config = context_builder.config()?;
config.jwt_signing_public_key_path.clone()
let key_file = Some(CanonicalFile::from_str(
&config.jwt_signing_public_key_path,
)?);

JwtConfig {
key_file,
key_base64: None,
algo: config.jwt_signing_public_key_algo,
}
};
let key = JwtPublicSigningKey::load(&jwt_signing_public_key_path).await?;

let key = JwtPublicSigningKeyChain::from_config(jwt_config, None).await?;

Ok(key)
}
Expand Down
3 changes: 2 additions & 1 deletion lib/dal/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ rust_library(
"//lib/si-events-rs:si-events",
"//lib/si-frontend-types-rs:si-frontend-types",
"//lib/si-hash:si-hash",
"//lib/si-jwt-public-key:si-jwt-public-key",
"//lib/si-layer-cache:si-layer-cache",
"//lib/si-pkg:si-pkg",
"//lib/si-runtime-rs:si-runtime",
Expand Down Expand Up @@ -121,7 +122,7 @@ rust_test(
],
crate_root = "tests/integration.rs",
srcs = glob([
"tests/**/*.rs",
"tests/**/*.rs",
"tests/integration_test/external/ignition/*.ign",
]),
env = {
Expand Down
1 change: 1 addition & 0 deletions lib/dal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ si-data-pg = { path = "../../lib/si-data-pg" }
si-events = { path = "../../lib/si-events-rs" }
si-frontend-types = { path = "../../lib/si-frontend-types-rs" }
si-hash = { path = "../../lib/si-hash" }
si-jwt-public-key = { path = "../../lib/si-jwt-public-key" }
si-layer-cache = { path = "../../lib/si-layer-cache" }
si-pkg = { path = "../../lib/si-pkg" }
si-runtime = { path = "../../lib/si-runtime-rs" }
Expand Down
Loading

0 comments on commit 8e99bac

Please sign in to comment.