use super::{
get_bounded_context, get_event_type, insert_event, next_sequence,
require_admin_bounded_context, schema_ident, upsert_event_type, version_column, Pool,
};
use crate::encryption::provider::{KeyProvider, WrappedDataKey};
use crate::encryption::trail::{
master_key_event_type, MasterKeyChange, MasterKeyEventKind, RotationReason,
};
use crate::encryption::{self, KeyService, StoredWrap};
use crate::event_store::BoundedContext;
use chrono::{DateTime, Utc};
use sqlx::{Postgres, Transaction};
use std::collections::{BTreeMap, BTreeSet};
#[tracing::instrument(skip_all)]
pub async fn register_master_key_event_types(pool: &Pool) -> crate::error::Result<()> {
let admin = require_admin_bounded_context(pool).await?;
for kind in MasterKeyEventKind::ALL {
let wanted = master_key_event_type(&admin, kind);
let current = get_event_type(pool, &admin.name, kind.name()).await?;
let up_to_date = current.as_ref().is_some_and(|current| {
let parse = |schema: &str| serde_json::from_str::<serde_json::Value>(schema).ok();
parse(¤t.schema).is_some() && parse(¤t.schema) == parse(&wanted.schema)
});
if !up_to_date {
upsert_event_type(pool, &wanted).await?;
}
}
Ok(())
}
async fn record(
tx: &mut Transaction<'_, Postgres>,
admin: &BoundedContext,
change: MasterKeyChange,
) -> crate::error::Result<()> {
let sequence = next_sequence(&mut **tx, &admin.name).await?;
let event = change.into_event(admin, sequence, Utc::now());
insert_event(&mut **tx, &event, None).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MasterKeyState {
pub provider: String,
pub key: Option<String>,
pub version: u32,
pub rotated_at: DateTime<Utc>,
pub retire_immediately: bool,
pub rewrapped_at: Option<DateTime<Utc>>,
pub retired_below: u32,
pub created_by_skilj: bool,
pub delete_after: Option<DateTime<Utc>>,
pub assignment_epoch: i64,
}
type StateRow = (
String,
Option<String>,
i32,
DateTime<Utc>,
bool,
Option<DateTime<Utc>>,
i32,
bool,
Option<DateTime<Utc>>,
i64,
);
fn to_state(row: StateRow) -> MasterKeyState {
let (
provider,
key,
version,
rotated_at,
retire_immediately,
rewrapped_at,
retired_below,
created_by_skilj,
delete_after,
assignment_epoch,
) = row;
MasterKeyState {
provider,
key,
version: u32::try_from(version).unwrap_or(0),
rotated_at,
retire_immediately,
rewrapped_at,
retired_below: u32::try_from(retired_below).unwrap_or(0),
created_by_skilj,
delete_after,
assignment_epoch,
}
}
const STATE_COLUMNS: &str = "provider, key, version, rotated_at, retire_immediately, \
rewrapped_at, retired_below, created_by_skilj, delete_after, assignment_epoch";
#[tracing::instrument(skip_all)]
pub async fn master_key_states(pool: &Pool) -> crate::error::Result<Vec<MasterKeyState>> {
let rows: Vec<StateRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT {STATE_COLUMNS} FROM encryption_master_keys ORDER BY key NULLS FIRST, provider"
)))
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(to_state).collect())
}
#[tracing::instrument(skip_all)]
pub async fn master_key_state(
pool: &Pool,
provider: &str,
) -> crate::error::Result<Option<MasterKeyState>> {
let row: Option<StateRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT {STATE_COLUMNS} FROM encryption_master_keys WHERE provider = $1"
)))
.bind(provider)
.fetch_optional(pool)
.await?;
Ok(row.map(to_state))
}
async fn lock_key(tx: &mut Transaction<'_, Postgres>, provider: &str) -> crate::error::Result<()> {
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('skilj_encryption_master_key:' || $1))")
.bind(provider)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn locked_state(
tx: &mut Transaction<'_, Postgres>,
provider: &str,
) -> crate::error::Result<Option<MasterKeyState>> {
let row: Option<StateRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT {STATE_COLUMNS} FROM encryption_master_keys WHERE provider = $1 FOR UPDATE"
)))
.bind(provider)
.fetch_optional(&mut **tx)
.await?;
Ok(row.map(to_state))
}
pub async fn register_known_keys<'e>(
executor: impl sqlx::PgExecutor<'e>,
key_service: &KeyService,
) -> crate::error::Result<()> {
let keys: Vec<String> =
sqlx::query_scalar("SELECT key FROM encryption_master_keys WHERE key IS NOT NULL")
.fetch_all(executor)
.await?;
for key in keys {
key_service.provider_for(Some(&key))?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Rotation {
Record(RotationReason),
Rotate(RotationReason),
}
#[tracing::instrument(skip_all, fields(provider = %provider.name()))]
pub async fn rotate_master_key(
pool: &Pool,
provider: &dyn KeyProvider,
key: Option<&str>,
rotation: Rotation,
retire_immediately: bool,
actor_role_id: Option<&str>,
due: Option<chrono::Duration>,
) -> crate::error::Result<Option<u32>> {
let admin = require_admin_bounded_context(pool).await?;
let asked = match rotation {
Rotation::Record(_) => {
let versions = provider.versions().await?;
let recorded = master_key_state(pool, provider.name()).await?;
if recorded.is_some_and(|s| s.version >= versions.latest) {
return Ok(None);
}
Some(versions)
}
Rotation::Rotate(_) => None,
};
let mut tx = pool.begin().await?;
lock_key(&mut tx, provider.name()).await?;
let current = locked_state(&mut tx, provider.name()).await?;
let now = Utc::now();
if let (Some(every), Some(state)) = (due, ¤t) {
if state.rewrapped_at.is_none() || state.rotated_at + every > now {
return Ok(None);
}
}
let (version, reason, versions) = match (rotation, asked) {
(Rotation::Record(reason), Some(versions)) => {
if current
.as_ref()
.is_some_and(|s| s.version >= versions.latest)
{
return Ok(None);
}
(versions.latest, reason, Some(versions))
}
(Rotation::Rotate(reason), _) => (provider.rotate().await?, reason, None),
(Rotation::Record(_), None) => unreachable!("a recording asked the provider above"),
};
let retired_below = match (¤t, versions) {
(Some(state), _) => state.retired_below,
(None, Some(versions)) => versions.min_decryption,
(None, None) => provider.versions().await?.min_decryption,
};
sqlx::query(
"INSERT INTO encryption_master_keys \
(provider, key, version, rotated_at, retire_immediately, rewrapped_at, retired_below) \
VALUES ($1, $2, $3, $4, $5, NULL, $6) \
ON CONFLICT (provider) DO UPDATE SET version = EXCLUDED.version, \
rotated_at = EXCLUDED.rotated_at, retire_immediately = EXCLUDED.retire_immediately, \
rewrapped_at = NULL",
)
.bind(provider.name())
.bind(key)
.bind(version_column(version)?)
.bind(now)
.bind(retire_immediately)
.bind(version_column(retired_below)?)
.execute(&mut *tx)
.await?;
record(
&mut tx,
&admin,
MasterKeyChange::rotated(
provider.name(),
version,
current.as_ref().map(|s| s.version),
reason,
retire_immediately,
actor_role_id,
),
)
.await?;
tx.commit().await?;
Ok(Some(version))
}
pub async fn mark_data_keys_rewrapped(
pool: &Pool,
provider: &str,
version: u32,
epoch: i64,
) -> crate::error::Result<bool> {
let updated = sqlx::query(
"UPDATE encryption_master_keys SET rewrapped_at = $1 \
WHERE provider = $2 AND version = $3 AND rewrapped_at IS NULL \
AND assignment_epoch = $4",
)
.bind(Utc::now())
.bind(provider)
.bind(version_column(version)?)
.bind(epoch)
.execute(pool)
.await?;
Ok(updated.rows_affected() == 1)
}
pub async fn mark_data_keys_behind(pool: &Pool, provider: &str) -> crate::error::Result<()> {
sqlx::query("UPDATE encryption_master_keys SET rewrapped_at = NULL WHERE provider = $1")
.bind(provider)
.execute(pool)
.await?;
Ok(())
}
#[tracing::instrument(skip_all, fields(provider = %provider.name()))]
pub async fn retire_master_key_versions(
pool: &Pool,
key_service: &KeyService,
provider: &dyn KeyProvider,
version: u32,
) -> crate::error::Result<bool> {
let admin = require_admin_bounded_context(pool).await?;
let mut tx = pool.begin().await?;
lock_key(&mut tx, provider.name()).await?;
let Some(state) = locked_state(&mut tx, provider.name()).await? else {
return Ok(false);
};
if state.version != version || state.retired_below >= version {
return Ok(false);
}
if key_rows_below_on(&mut tx, provider.name(), version, state.rotated_at).await? {
tracing::info!(
provider = provider.name(),
below = version,
"retirement postponed: data keys are still wrapped under an older version"
);
return Ok(false);
}
provider.retire_below(version).await?;
sqlx::query("UPDATE encryption_master_keys SET retired_below = $1 WHERE provider = $2")
.bind(version_column(version)?)
.bind(provider.name())
.execute(&mut *tx)
.await?;
record(
&mut tx,
&admin,
MasterKeyChange::retired(provider.name(), version),
)
.await?;
tx.commit().await?;
key_service.clear_cache();
Ok(true)
}
pub async fn bounded_context_key<'e>(
executor: impl sqlx::PgExecutor<'e>,
bounded_context: &str,
) -> crate::error::Result<Option<String>> {
Ok(
sqlx::query_scalar("SELECT key FROM encryption_key_assignments WHERE bounded_context = $1")
.bind(bounded_context)
.fetch_optional(executor)
.await?,
)
}
pub(super) async fn key_recorded<'e>(
executor: impl sqlx::PgExecutor<'e>,
provider: &str,
) -> crate::error::Result<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM encryption_master_keys WHERE provider = $1)",
)
.bind(provider)
.fetch_one(executor)
.await?)
}
pub async fn encryption_key_assignments(
pool: &Pool,
) -> crate::error::Result<BTreeMap<String, String>> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT bounded_context, key FROM encryption_key_assignments")
.fetch_all(pool)
.await?;
Ok(rows.into_iter().collect())
}
#[tracing::instrument(skip_all, fields(bounded_context = %bounded_context))]
pub async fn assign_encryption_key(
pool: &Pool,
key_service: &KeyService,
bounded_context: &str,
key: Option<&str>,
create: bool,
actor_role_id: Option<&str>,
) -> crate::error::Result<MasterKeyState> {
get_bounded_context(pool, bounded_context)
.await?
.ok_or_else(crate::error::Error::row_not_found)?;
let provider = key_service.provider_for(key)?;
if key.is_some() && provider.name() == key_service.provider_name() {
return Err(encryption::Error::ProviderRefused {
provider: provider.name().to_string(),
message: "that is the default key; assign no key to use it".into(),
}
.into());
}
let created = if create && key.is_some() {
provider.create().await?
} else {
false
};
let versions = provider.versions().await?;
let admin = require_admin_bounded_context(pool).await?;
let mut tx = pool.begin().await?;
lock_key(&mut tx, provider.name()).await?;
let current = locked_state(&mut tx, provider.name()).await?;
if current.is_none() {
sqlx::query(
"INSERT INTO encryption_master_keys \
(provider, key, version, rotated_at, retire_immediately, rewrapped_at, \
retired_below) VALUES ($1, $2, $3, $4, false, NULL, $5)",
)
.bind(provider.name())
.bind(key)
.bind(version_column(versions.latest)?)
.bind(Utc::now())
.bind(version_column(versions.min_decryption)?)
.execute(&mut *tx)
.await?;
record(
&mut tx,
&admin,
MasterKeyChange::rotated(
provider.name(),
versions.latest,
None,
RotationReason::Adopted,
false,
actor_role_id,
),
)
.await?;
}
let previous: Option<String> = sqlx::query_scalar(
"SELECT provider FROM encryption_key_assignments WHERE bounded_context = $1 FOR UPDATE",
)
.bind(bounded_context)
.fetch_optional(&mut *tx)
.await?;
let previous = previous.unwrap_or_else(|| key_service.provider_name().to_string());
match key {
Some(key) => {
sqlx::query(
"INSERT INTO encryption_key_assignments \
(bounded_context, key, provider, assigned_at) VALUES ($1, $2, $3, $4) \
ON CONFLICT (bounded_context) DO UPDATE SET key = EXCLUDED.key, \
provider = EXCLUDED.provider, assigned_at = EXCLUDED.assigned_at",
)
.bind(bounded_context)
.bind(key)
.bind(provider.name())
.bind(Utc::now())
.execute(&mut *tx)
.await?;
}
None => {
sqlx::query("DELETE FROM encryption_key_assignments WHERE bounded_context = $1")
.bind(bounded_context)
.execute(&mut *tx)
.await?;
}
}
sqlx::query(
"UPDATE encryption_master_keys SET rewrapped_at = NULL, delete_after = NULL, \
created_by_skilj = created_by_skilj OR $2, \
assignment_epoch = assignment_epoch + 1 WHERE provider = $1",
)
.bind(provider.name())
.bind(created)
.execute(&mut *tx)
.await?;
record(
&mut tx,
&admin,
MasterKeyChange::assigned(
provider.name(),
bounded_context,
&previous,
created,
actor_role_id,
),
)
.await?;
tx.commit().await?;
master_key_state(pool, provider.name())
.await?
.ok_or_else(crate::error::Error::row_not_found)
}
pub async fn forget_assignments_of_deleted_contexts(
pool: &Pool,
existing: &BTreeSet<String>,
) -> crate::error::Result<()> {
for bounded_context in encryption_key_assignments(pool).await?.into_keys() {
if !existing.contains(&bounded_context) {
sqlx::query("DELETE FROM encryption_key_assignments WHERE bounded_context = $1")
.bind(&bounded_context)
.execute(pool)
.await?;
}
}
Ok(())
}
pub async fn schedule_key_deletion(
pool: &Pool,
provider: &str,
at: DateTime<Utc>,
) -> crate::error::Result<()> {
sqlx::query(
"UPDATE encryption_master_keys SET delete_after = $2 \
WHERE provider = $1 AND key IS NOT NULL AND delete_after IS NULL",
)
.bind(provider)
.bind(at)
.execute(pool)
.await?;
Ok(())
}
#[tracing::instrument(skip_all, fields(provider = %provider.name()))]
pub async fn delete_unused_key(
pool: &Pool,
key_service: &KeyService,
provider: &dyn KeyProvider,
) -> crate::error::Result<bool> {
let admin = require_admin_bounded_context(pool).await?;
let mut tx = pool.begin().await?;
lock_key(&mut tx, provider.name()).await?;
let Some(state) = locked_state(&mut tx, provider.name()).await? else {
return Ok(false);
};
let assigned: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM encryption_key_assignments WHERE provider = $1)",
)
.bind(provider.name())
.fetch_one(&mut *tx)
.await?;
let due = state.delete_after.is_some_and(|at| at <= Utc::now());
if state.key.is_none() || assigned || !due {
return Ok(false);
}
if key_in_use_on(&mut tx, provider.name()).await? {
sqlx::query("UPDATE encryption_master_keys SET delete_after = NULL WHERE provider = $1")
.bind(provider.name())
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Ok(false);
}
if state.created_by_skilj {
provider.delete().await?;
}
sqlx::query("DELETE FROM encryption_master_keys WHERE provider = $1")
.bind(provider.name())
.execute(&mut *tx)
.await?;
record(
&mut tx,
&admin,
MasterKeyChange::deleted(provider.name(), state.created_by_skilj),
)
.await?;
tx.commit().await?;
key_service.clear_cache();
Ok(true)
}
async fn stored_providers(
pool: &Pool,
bounded_context: &str,
) -> crate::error::Result<(Vec<String>, bool)> {
let schema = schema_ident(bounded_context);
let providers: Vec<String> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
"WITH RECURSIVE named(provider) AS ( \
(SELECT key_provider FROM {schema}.encryption_keys \
WHERE status = 'active' AND key_provider IS NOT NULL \
AND unreadable_since IS NULL \
ORDER BY key_provider LIMIT 1) \
UNION ALL \
SELECT (SELECT k.key_provider FROM {schema}.encryption_keys k \
WHERE k.status = 'active' AND k.key_provider > named.provider \
AND k.unreadable_since IS NULL \
ORDER BY k.key_provider LIMIT 1) \
FROM named WHERE named.provider IS NOT NULL \
) SELECT provider FROM named WHERE provider IS NOT NULL"
)))
.fetch_all(pool)
.await?;
let legacy: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
"SELECT EXISTS (SELECT 1 FROM {schema}.encryption_keys \
WHERE status = 'active' AND key_provider IS NULL AND wrapped_key IS NOT NULL \
AND unreadable_since IS NULL)"
)))
.fetch_one(pool)
.await?;
Ok((providers, legacy))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DataKeyWraps {
pub legacy: bool,
}
#[tracing::instrument(skip_all, fields(bounded_context = %bounded_context))]
pub async fn check_data_key_wraps(
pool: &Pool,
bounded_context: &str,
key_service: &KeyService,
settled: &mut BTreeSet<String>,
) -> crate::error::Result<DataKeyWraps> {
let (providers, legacy) = stored_providers(pool, bounded_context).await?;
if let Some(stored) = providers
.iter()
.find(|p| key_service.provider_named(p).is_none())
{
return Err(encryption::Error::ProviderChanged {
stored: stored.clone(),
configured: key_service.provider_name().to_string(),
}
.into());
}
let schema = schema_ident(bounded_context);
let pending: Vec<&String> = providers.iter().filter(|p| !settled.contains(*p)).collect();
for provider in pending {
let newest: Vec<(Vec<u8>, i32)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT wrapped_key, master_key_version FROM {schema}.encryption_keys \
WHERE status = 'active' AND key_provider = $1 AND wrapped_key IS NOT NULL \
AND unreadable_since IS NULL ORDER BY id DESC LIMIT {KEY_PROBES}"
)))
.bind(provider)
.fetch_all(pool)
.await?;
let mut refused = 0;
let mut settle = false;
for (wrapped, version) in &newest {
let probe = key_service
.unwrap(StoredWrap {
provider: Some(provider),
version: u32::try_from(*version).ok(),
ciphertext: wrapped,
nonce: None,
})
.await;
match probe {
Ok(_) => {
settle = true;
break;
}
Err(encryption::Error::DecryptFailed) => refused += 1,
Err(encryption::Error::KeyVersionRetired { .. }) => {}
Err(err) => {
tracing::warn!(
provider = %provider,
error = %err,
"could not check the key's data keys at startup"
);
settle = true;
break;
}
}
}
if !settle && refused > 0 {
return Err(encryption::Error::KeyMaterialChanged {
provider: provider.clone(),
bounded_context: bounded_context.to_string(),
}
.into());
}
if settle {
settled.insert(provider.clone());
}
}
if !legacy {
return Ok(DataKeyWraps { legacy: false });
}
let newest: Vec<(Vec<u8>, Option<Vec<u8>>)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT wrapped_key, wrap_nonce FROM {schema}.encryption_keys \
WHERE status = 'active' AND key_provider IS NULL AND wrapped_key IS NOT NULL \
AND unreadable_since IS NULL ORDER BY id DESC LIMIT {LEGACY_PROBES}"
)))
.fetch_all(pool)
.await?;
for (wrapped, nonce) in &newest {
match key_service
.unwrap(StoredWrap {
provider: None,
version: None,
ciphertext: wrapped,
nonce: nonce.as_deref(),
})
.await
{
Ok(_) => return Ok(DataKeyWraps { legacy: true }),
Err(encryption::Error::DecryptFailed) => {}
Err(err) => return Err(err.into()),
}
}
Err(encryption::Error::LegacyMasterKeyChanged {
bounded_context: bounded_context.to_string(),
}
.into())
}
const LEGACY_PROBES: i64 = 5;
const KEY_PROBES: i64 = 5;
fn readable_since(since: &str) -> String {
format!("(unreadable_since IS NULL OR unreadable_since < {since})")
}
pub async fn data_keys_behind(
pool: &Pool,
bounded_context: &str,
provider: &str,
version: u32,
since: DateTime<Utc>,
) -> crate::error::Result<bool> {
let schema = schema_ident(bounded_context);
let counts = readable_since("$3");
let behind: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
"SELECT EXISTS (SELECT 1 FROM {schema}.encryption_keys \
WHERE status = 'active' AND key_provider IS NULL AND wrapped_key IS NOT NULL \
AND {counts}) \
OR EXISTS (SELECT 1 FROM {schema}.encryption_keys \
WHERE status = 'active' AND (key_provider < $1 OR key_provider > $1) \
AND {counts}) \
OR EXISTS (SELECT 1 FROM {schema}.encryption_keys \
WHERE status = 'active' AND key_provider = $1 AND master_key_version < $2 \
AND {counts})"
)))
.bind(provider)
.bind(version_column(version)?)
.bind(since)
.fetch_one(pool)
.await?;
Ok(behind)
}
pub async fn key_in_use(pool: &Pool, provider: &str) -> crate::error::Result<bool> {
let mut tx = pool.begin().await?;
let used = key_in_use_on(&mut tx, provider).await?;
tx.rollback().await?;
Ok(used)
}
async fn key_rows_below_on(
conn: &mut sqlx::PgConnection,
provider: &str,
version: u32,
since: DateTime<Utc>,
) -> crate::error::Result<bool> {
let names: Vec<String> = sqlx::query_scalar("SELECT name FROM bounded_contexts")
.fetch_all(&mut *conn)
.await?;
let counts = readable_since("$3");
for name in names {
let schema = schema_ident(&name);
sqlx::query("SAVEPOINT key_rows_below")
.execute(&mut *conn)
.await?;
let below = sqlx::query_scalar::<_, bool>(sqlx::AssertSqlSafe(format!(
"SELECT EXISTS (SELECT 1 FROM {schema}.encryption_keys \
WHERE status = 'active' AND key_provider = $1 AND master_key_version < $2 \
AND {counts})"
)))
.bind(provider)
.bind(version_column(version)?)
.bind(since)
.fetch_one(&mut *conn)
.await
.map_err(crate::error::Error::from);
match below {
Ok(true) => {
sqlx::query("RELEASE SAVEPOINT key_rows_below")
.execute(&mut *conn)
.await?;
return Ok(true);
}
Ok(false) => {}
Err(err) if super::is_missing_schema(&err) => {
sqlx::query("ROLLBACK TO SAVEPOINT key_rows_below")
.execute(&mut *conn)
.await?;
}
Err(err) => return Err(err),
}
sqlx::query("RELEASE SAVEPOINT key_rows_below")
.execute(&mut *conn)
.await?;
}
Ok(false)
}
async fn key_in_use_on(
conn: &mut sqlx::PgConnection,
provider: &str,
) -> crate::error::Result<bool> {
let names: Vec<String> = sqlx::query_scalar("SELECT name FROM bounded_contexts")
.fetch_all(&mut *conn)
.await?;
for name in names {
let schema = schema_ident(&name);
sqlx::query("SAVEPOINT key_in_use")
.execute(&mut *conn)
.await?;
let used = sqlx::query_scalar::<_, bool>(sqlx::AssertSqlSafe(format!(
"SELECT EXISTS (SELECT 1 FROM {schema}.encryption_keys \
WHERE status = 'active' AND key_provider = $1)"
)))
.bind(provider)
.fetch_one(&mut *conn)
.await
.map_err(crate::error::Error::from);
match used {
Ok(true) => {
sqlx::query("RELEASE SAVEPOINT key_in_use")
.execute(&mut *conn)
.await?;
return Ok(true);
}
Ok(false) => {}
Err(err) if super::is_missing_schema(&err) => {
sqlx::query("ROLLBACK TO SAVEPOINT key_in_use")
.execute(&mut *conn)
.await?;
}
Err(err) => return Err(err),
}
sqlx::query("RELEASE SAVEPOINT key_in_use")
.execute(&mut *conn)
.await?;
}
Ok(false)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RewrapOutcome {
pub rewrapped: u64,
pub unreadable: u64,
}
const REWRAP_BATCH: i64 = 200;
type RewrapRow = (i64, Vec<u8>, Option<Vec<u8>>, Option<String>, Option<i32>);
fn definitively_unreadable(err: &encryption::Error) -> bool {
matches!(
err,
encryption::Error::DecryptFailed
| encryption::Error::KeyVersionRetired { .. }
| encryption::Error::ProviderChanged { .. }
)
}
pub async fn rewrap_data_keys_once(
pool: &Pool,
bounded_context: &str,
key_service: &KeyService,
target: &dyn KeyProvider,
version: u32,
since: DateTime<Utc>,
) -> crate::error::Result<Option<RewrapOutcome>> {
let mut lock = pool.acquire().await?;
let name = format!("skilj_rewrap:{}:{bounded_context}", target.name());
let held: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock(hashtext($1))")
.bind(&name)
.fetch_one(&mut *lock)
.await?;
if !held {
return Ok(None);
}
let outcome =
rewrap_data_keys(pool, bounded_context, key_service, target, version, since).await;
let released = sqlx::query("SELECT pg_advisory_unlock(hashtext($1))")
.bind(&name)
.execute(&mut *lock)
.await;
if released.is_err() {
lock.detach();
}
outcome.map(Some)
}
#[tracing::instrument(skip_all, fields(bounded_context = %bounded_context))]
pub async fn rewrap_data_keys(
pool: &Pool,
bounded_context: &str,
key_service: &KeyService,
target: &dyn KeyProvider,
version: u32,
since: DateTime<Utc>,
) -> crate::error::Result<RewrapOutcome> {
let admin = require_admin_bounded_context(pool).await?;
let schema = schema_ident(bounded_context);
let target_name = target.name().to_string();
let behind_filter = format!(
"status = 'active' AND wrapped_key IS NOT NULL AND {} \
AND (key_provider IS NULL OR key_provider <> $1 OR master_key_version < $2)",
readable_since("$3")
);
let event = |outcome: RewrapOutcome| {
MasterKeyChange::rewrapped(
&target_name,
version,
bounded_context,
outcome.rewrapped,
outcome.unreadable,
)
};
let mut outcome = RewrapOutcome::default();
let mut after = 0i64;
loop {
let rows: Vec<RewrapRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT id, wrapped_key, wrap_nonce, key_provider, master_key_version \
FROM {schema}.encryption_keys WHERE {behind_filter} AND id > $4 \
ORDER BY id LIMIT $5"
)))
.bind(&target_name)
.bind(version_column(version)?)
.bind(since)
.bind(after)
.bind(REWRAP_BATCH)
.fetch_all(pool)
.await?;
let Some(last) = rows.last() else {
if outcome != RewrapOutcome::default() {
let mut tx = pool.begin().await?;
record(&mut tx, &admin, event(outcome)).await?;
tx.commit().await?;
}
break;
};
after = last.0;
let mut done: Vec<(i64, Vec<u8>, WrappedDataKey)> = Vec::with_capacity(rows.len());
let mut unreadable: Vec<(i64, Vec<u8>)> = Vec::new();
let mut same_key = Vec::new();
for (id, wrapped, nonce, provider, row_version) in rows {
if provider.as_deref() == Some(target_name.as_str()) {
same_key.push((
id,
WrappedDataKey {
version: row_version.and_then(|v| u32::try_from(v).ok()).unwrap_or(0),
ciphertext: wrapped,
},
));
continue;
}
let unwrapped = key_service
.unwrap(StoredWrap {
provider: provider.as_deref(),
version: row_version.and_then(|v| u32::try_from(v).ok()),
ciphertext: &wrapped,
nonce: nonce.as_deref(),
})
.await;
match unwrapped {
Ok(data_key) => {
let rewrapped = target.wrap(data_key.bytes()).await?;
done.push((id, wrapped, rewrapped));
}
Err(err) if definitively_unreadable(&err) => {
tracing::warn!(encryption_key_id = id, error = %err, "data key unreadable, marked");
unreadable.push((id, wrapped));
}
Err(err) => return Err(err.into()),
}
}
if !same_key.is_empty() {
let inputs: Vec<WrappedDataKey> = same_key.iter().map(|(_, w)| w.clone()).collect();
let results = target.rewrap(&inputs).await?;
for ((id, old), result) in same_key.into_iter().zip(results) {
match result {
Ok(rewrapped) => done.push((id, old.ciphertext, rewrapped)),
Err(err) if definitively_unreadable(&err) => {
tracing::warn!(encryption_key_id = id, error = %err, "data key unreadable, marked");
unreadable.push((id, old.ciphertext));
}
Err(err) => return Err(err.into()),
}
}
}
let mut tx = pool.begin().await?;
let target_kept: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM encryption_master_keys WHERE provider = $1 FOR SHARE)",
)
.bind(&target_name)
.fetch_one(&mut *tx)
.await?;
if !target_kept {
return Err(encryption::Error::ProviderRefused {
provider: target_name.clone(),
message: "the key was deleted while its data keys were being rewrapped".into(),
}
.into());
}
if !done.is_empty() {
let ids: Vec<i64> = done.iter().map(|(id, _, _)| *id).collect();
let old: Vec<Vec<u8>> = done.iter().map(|(_, old, _)| old.clone()).collect();
let new: Vec<Vec<u8>> = done.iter().map(|(_, _, w)| w.ciphertext.clone()).collect();
let versions: Vec<i32> = done
.iter()
.map(|(_, _, w)| version_column(w.version))
.collect::<crate::error::Result<_>>()?;
let updated = sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {schema}.encryption_keys k \
SET wrapped_key = u.new, wrap_nonce = NULL, key_provider = $1, \
master_key_version = u.version, unreadable_since = NULL \
FROM UNNEST($2::BIGINT[], $3::BYTEA[], $4::BYTEA[], $5::INTEGER[]) \
AS u(id, old, new, version) \
WHERE k.id = u.id AND k.status = 'active' AND k.wrapped_key = u.old"
)))
.bind(&target_name)
.bind(&ids)
.bind(&old)
.bind(&new)
.bind(&versions)
.execute(&mut *tx)
.await?;
outcome.rewrapped += updated.rows_affected();
}
if !unreadable.is_empty() {
let ids: Vec<i64> = unreadable.iter().map(|(id, _)| *id).collect();
let old: Vec<Vec<u8>> = unreadable.iter().map(|(_, old)| old.clone()).collect();
let marked = sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {schema}.encryption_keys k SET unreadable_since = $1 \
FROM UNNEST($2::BIGINT[], $3::BYTEA[]) AS u(id, old) \
WHERE k.id = u.id AND k.status = 'active' AND k.wrapped_key = u.old"
)))
.bind(Utc::now())
.bind(&ids)
.bind(&old)
.execute(&mut *tx)
.await?;
outcome.unreadable += marked.rows_affected();
}
let more: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
"SELECT EXISTS (SELECT 1 FROM {schema}.encryption_keys \
WHERE {behind_filter} AND id > $4)"
)))
.bind(&target_name)
.bind(version_column(version)?)
.bind(since)
.bind(after)
.fetch_one(&mut *tx)
.await?;
if !more && outcome != RewrapOutcome::default() {
record(&mut tx, &admin, event(outcome)).await?;
}
tx.commit().await?;
if !more {
break;
}
}
Ok(outcome)
}