Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ members = [
"crates/idmapping-driver-sql",
"crates/k8s-auth-driver-raft",
"crates/k8s-auth-driver-sql",
"crates/key-repository",
"crates/keystone",
"crates/mapping-driver-raft",
"crates/resource-driver-sql",
Expand Down Expand Up @@ -115,6 +116,7 @@ openstack-keystone-storage-crypto = { version = "0.1.0", path = "crates/storage-
openstack-keystone-federation-driver-sql = { version = "0.1", path = "crates/federation-driver-sql" }
openstack-keystone-k8s-auth-driver-sql = { version = "0.1", path = "crates/k8s-auth-driver-sql/" }
openstack-keystone-k8s-auth-driver-raft = { version = "0.1", path = "crates/k8s-auth-driver-raft/" }
openstack-keystone-key-repository = { version = "0.1.0", path = "crates/key-repository" }
openstack-keystone-mapping-driver-raft = { version = "0.1", path = "crates/mapping-driver-raft/" }
openstack-keystone-identity-driver-sql = { version = "0.1", path = "crates/identity-driver-sql" }
openstack-keystone-idmapping-driver-sql = { version = "0.1", path = "crates/idmapping-driver-sql/" }
Expand Down
1 change: 1 addition & 0 deletions crates/cli-manage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ openstack-keystone-credential-driver-sql.workspace = true
openstack-keystone-distributed-storage.workspace = true
openstack-keystone-federation-driver-sql.workspace = true
openstack-keystone-k8s-auth-driver-sql.workspace = true
openstack-keystone-token-driver-fernet.workspace = true
openstack-keystone-token-restriction-driver-sql.workspace = true
openstack-keystone-webauthn.workspace = true
sea-orm = { workspace = true, features = ["sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "runtime-tokio", "runtime-tokio-native-tls"] }
Expand Down
1 change: 1 addition & 0 deletions crates/cli-manage/src/credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ impl PerformAction for CredentialCommand {
CredentialCommands::Setup => {
let repo = FernetKeyRepository::new(config.credential.key_repository.clone());
repo.setup()
.await
.wrap_err("setting up credential key repository")?;
println!(
"credential key repository initialized at {}",
Expand Down
6 changes: 6 additions & 0 deletions crates/cli-manage/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ mod common;
mod credential;
mod db;
mod storage;
mod token;

use crate::bootstrap::*;
use crate::credential::*;
use crate::db::*;
use crate::storage::*;
use crate::token::*;

/// OpenStack Keystone management CLI.
///
Expand Down Expand Up @@ -66,6 +68,9 @@ enum Command {

/// Distributed storage management.
Storage(StorageCommand),

/// Fernet token encryption key management (ADR 0019 §4).
Token(TokenCommand),
}

#[async_trait]
Expand All @@ -83,6 +88,7 @@ async fn main() -> Result<(), Report> {
Command::Credential(x) => x.take_action(&cfg).await?,
Command::Db(x) => x.take_action(&cfg).await?,
Command::Storage(x) => x.take_action(&cfg).await?,
Command::Token(x) => x.take_action(&cfg).await?,
}
Ok(())
}
144 changes: 144 additions & 0 deletions crates/cli-manage/src/token.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
//! Fernet token encryption key management commands (ADR 0019 §4).
//!
//! Mirrors [`crate::credential::CredentialCommand`], backed by the same
//! shared [`openstack_keystone_key_repository`] logic. Unlike the
//! credential key repository, there is no `migrate` subcommand here: tokens
//! are short-lived and are never re-encrypted, they simply stop decrypting
//! once their key is pruned by a later rotation.

use async_trait::async_trait;
use clap::{Parser, Subcommand};
use color_eyre::{Report, eyre::WrapErr};
use eyre::Result;

use openstack_keystone_config::Config;
use openstack_keystone_token_driver_fernet::utils::FernetUtils;

use crate::PerformAction;
use crate::common::setup_logging;

/// Fernet token encryption key management.
#[derive(Parser)]
pub struct TokenCommand {
/// Verbosity level. Repeat to increase level.
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,

#[command(subcommand)]
command: TokenCommands,
}

#[derive(Subcommand)]
enum TokenCommands {
/// Populate the token key repository with an initial staged key.
///
/// Must be run once during deployment, before any tokens are issued via
/// either service.
Setup,

/// Promote the staged key to Primary.
///
/// Aborts nothing else first — unlike `credential rotate`, there is no
/// stale-data check to run: tokens issued under a key that gets pruned
/// simply fail to decrypt (they expire long before `max_active_keys`
/// rotations typically elapse).
Rotate,
}

fn utils_from(config: &Config) -> FernetUtils {
FernetUtils {
key_repository: config.fernet_tokens.key_repository.clone(),
max_active_keys: config.fernet_tokens.max_active_keys,
}
}

#[async_trait]
impl PerformAction for TokenCommand {
async fn take_action(self, config: &Config) -> Result<(), Report> {
setup_logging(self.verbose);

let utils = utils_from(config);

match self.command {
TokenCommands::Setup => {
utils
.initialize_key_repository()
.await
.wrap_err("setting up token key repository")?;
println!(
"token key repository initialized at {}",
config.fernet_tokens.key_repository.display()
);
Ok(())
}

TokenCommands::Rotate => {
utils.rotate().await.wrap_err("running token_rotate")?;
println!("token key repository rotated");
Ok(())
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn test_config(key_repo: &std::path::Path) -> Config {
let mut cfg = Config::default();
cfg.fernet_tokens.key_repository = key_repo.to_path_buf();
cfg
}

fn command(command: TokenCommands) -> TokenCommand {
TokenCommand {
verbose: 0,
command,
}
}

#[tokio::test]
async fn test_setup_creates_staged_key() {
let key_dir = tempfile::tempdir().unwrap();
let cfg = test_config(key_dir.path());

command(TokenCommands::Setup)
.take_action(&cfg)
.await
.unwrap();

assert!(key_dir.path().join("0").exists());
}

#[tokio::test]
async fn test_setup_rotate_roundtrip() {
let key_dir = tempfile::tempdir().unwrap();
let cfg = test_config(key_dir.path());

command(TokenCommands::Setup)
.take_action(&cfg)
.await
.unwrap();
command(TokenCommands::Rotate)
.take_action(&cfg)
.await
.unwrap();

assert!(key_dir.path().join("1").exists(), "staged key promoted");
assert!(key_dir.path().join("0").exists(), "fresh key staged");
}
}
9 changes: 9 additions & 0 deletions crates/config/src/fernet_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ pub struct FernetTokenProvider {
/// Maximal number of fernet keys to keep as active.
#[serde(default = "default_fernet_max_active_keys")]
pub max_active_keys: usize,

/// Allow starting (and encrypting/decrypting with) the well-known Null
/// Key (`base64.urlsafe_b64encode(b'\x00' * 32)`). This exists solely as
/// a transient migration aid; it must be `false` in any real deployment.
/// Defaults to `false` (refuse to start if a key decodes to the Null
/// Key). Mirrors `[credential] insecure_allow_null_key`.
#[serde(default)]
pub insecure_allow_null_key: bool,
}

fn default_fernet_key_repository() -> PathBuf {
Expand All @@ -41,6 +49,7 @@ impl Default for FernetTokenProvider {
Self {
key_repository: default_fernet_key_repository(),
max_active_keys: default_fernet_max_active_keys(),
insecure_allow_null_key: false,
}
}
}
4 changes: 1 addition & 3 deletions crates/credential-driver-sql/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,13 @@ async-trait.workspace = true
base64 = { workspace = true }
fernet = { workspace = true, features = ["rustcrypto"] }
inventory.workspace = true
nix = { workspace = true, features = ["fs", "user"] }
openstack-keystone-config.workspace = true
openstack-keystone-core.workspace = true
openstack-keystone-core-types.workspace = true
scopeguard = { workspace = true }
openstack-keystone-key-repository.workspace = true
sea-orm = { workspace = true, features = ["default"] }
secrecy = { workspace = true }
serde_json.workspace = true
sha1 = { workspace = true }
sha2.workspace = true
tempfile = { workspace = true }
thiserror.workspace = true
Expand Down
3 changes: 2 additions & 1 deletion crates/credential-driver-sql/src/credential/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub async fn create(
.ok_or(CredentialProviderError::MissingUserId)?;

let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone());
let keys = repo.load(cfg.credential.insecure_allow_null_key)?;
let keys = repo.load(cfg.credential.insecure_allow_null_key).await?;
let encrypted_blob = keys.multi_fernet.encrypt(rec.blob.as_bytes());

let extra = rec.extra.as_ref().map(serde_json::to_string).transpose()?;
Expand Down Expand Up @@ -101,6 +101,7 @@ mod tests {
let db = test_db().await;
crate::fernet::FernetKeyRepository::new(key_dir.path().to_path_buf())
.setup()
.await
.unwrap();

let rec = CredentialCreate {
Expand Down
16 changes: 8 additions & 8 deletions crates/credential-driver-sql/src/credential/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ use crate::entity::{credential as db_credential, prelude::Credential as DbCreden
use crate::fernet::FernetKeyRepository;

/// Decrypt a stored credential row into the plaintext API representation.
pub fn to_plaintext(
pub async fn to_plaintext(
cfg: &Config,
model: db_credential::Model,
) -> Result<Credential, CredentialProviderError> {
let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone());
let keys = repo.load(cfg.credential.insecure_allow_null_key)?;
let keys = repo.load(cfg.credential.insecure_allow_null_key).await?;
let plaintext = keys
.multi_fernet
.decrypt(&model.encrypted_blob)
Expand Down Expand Up @@ -64,7 +64,7 @@ pub async fn get<I: AsRef<str>>(
let select = DbCredential::find().filter(db_credential::Column::Id.eq(id.as_ref()));

if let Some(model) = select.one(db).await.context("fetching credential by id")? {
return Ok(Some(to_plaintext(cfg, model)?));
return Ok(Some(to_plaintext(cfg, model).await?));
}
Ok(None)
}
Expand Down Expand Up @@ -124,19 +124,19 @@ mod tests {
);
}

#[test]
fn test_to_plaintext_roundtrip() {
#[tokio::test]
async fn test_to_plaintext_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config::default();
cfg.credential.key_repository = dir.path().to_path_buf();
let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone());
repo.setup().unwrap();
let keys = repo.load(false).unwrap();
repo.setup().await.unwrap();
let keys = repo.load(false).await.unwrap();

let mut model = mock_model();
model.encrypted_blob = keys.multi_fernet.encrypt(br#"{"access":"AKIA"}"#);

let cred = to_plaintext(&cfg, model).unwrap();
let cred = to_plaintext(&cfg, model).await.unwrap();
assert_eq!(cred.blob, r#"{"access":"AKIA"}"#);
assert_eq!(cred.id, "cred_id");
}
Expand Down
Loading
Loading