Skip to content

Commit e45b10d

Browse files
committed
Support ZFS encryption key rotation for Trust Quorum epoch commits
When Trust Quorum commits a new epoch, all U.2 crypt datasets must have their encryption keys rotated to use the new epoch's derived key. This change implements the key rotation flow triggered by epoch commits. ## Trust Quorum Integration - Add watch channel to `NodeTaskHandle` for epoch change notifications - Initialize channel with current committed epoch on startup - Notify subscribers via `send_if_modified()` when epoch changes ## Config Reconciler Integration - Accept `committed_epoch_rx` watch channel from trust quorum - Trigger reconciliation when epoch changes - Track per-disk encryption epoch in `ExternalDisks` - Add `rekey_for_epoch()` to coordinate key rotation: - Filter disks needing rekey (cached epoch < target OR unknown) - Derive keys for each disk via `StorageKeyRequester` - Send batch request to dataset task - Update cached epochs on success - Retry on failure via normal reconciliation retry logic ## Dataset Task Changes - Add `RekeyRequest`/`RekeyResult` types for batch rekey operations - Add `datasets_rekey()` with idempotency check (skip if already at target) - Use `Zfs::change_key()` for atomic key + epoch property update ## ZFS Utilities - Add `Zfs::change_key()` using `zfs_atomic_change_key` crate - Add `Zfs::load_key()`, `unload_key()`, `dataset_exists()` - Add `epoch` field to `DatasetProperties` - Add structured error types for key operations ## Crash Recovery - Add trial decryption recovery in `sled-storage` for datasets with missing epoch property (e.g., crash during initial creation) - Unload key before each trial attempt to handle crash-after-load-key - Set epoch property after successful recovery ## Safety Properties - Atomic: Key and epoch property set together via `zfs_atomic_change_key` - Idempotent: Skip rekey if dataset already at target epoch - Crash-safe: Epoch read from ZFS on restart rebuilds cache correctly - Conservative: Unknown epochs (None) trigger rekey attempt
1 parent 31f4fff commit e45b10d

File tree

15 files changed

+904
-50
lines changed

15 files changed

+904
-50
lines changed

Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,7 @@ wicketd-client = { path = "clients/wicketd-client" }
829829
xshell = "0.2.7"
830830
zerocopy = "0.8.26"
831831
zeroize = { version = "1.8.1", features = ["zeroize_derive", "std"] }
832+
zfs-atomic-change-key = { git = "https://github.com/oxidecomputer/zfs-atomic-change-key" }
832833
zfs-test-harness = { path = "sled-storage/zfs-test-harness" }
833834
zip = { version = "4.2.0", default-features = false, features = ["deflate","bzip2"] }
834835
zone = { version = "0.3.1", default-features = false, features = ["async"] }

illumos-utils/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ futures.workspace = true
2424
http.workspace = true
2525
ipnetwork.workspace = true
2626
itertools.workspace = true
27+
key-manager.workspace = true
2728
libc.workspace = true
2829
macaddr.workspace = true
2930
nix.workspace = true
@@ -44,6 +45,7 @@ tokio.workspace = true
4445
uuid.workspace = true
4546
whoami.workspace = true
4647
zone.workspace = true
48+
zfs-atomic-change-key.workspace = true
4749
tofino.workspace = true
4850
rustix.workspace = true
4951

illumos-utils/src/zfs.rs

Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,42 @@ pub struct GetValueError {
206206
#[error("Failed to list snapshots: {0}")]
207207
pub struct ListSnapshotsError(#[from] crate::ExecutionError);
208208

209+
/// Error returned by [`Zfs::change_key`].
210+
#[derive(thiserror::Error, Debug)]
211+
#[error("Failed to change encryption key for dataset '{name}'")]
212+
pub struct ChangeKeyError {
213+
pub name: String,
214+
#[source]
215+
pub err: anyhow::Error,
216+
}
217+
218+
/// Error returned by [`Zfs::load_key`].
219+
#[derive(thiserror::Error, Debug)]
220+
#[error("Failed to load encryption key for dataset '{name}'")]
221+
pub struct LoadKeyError {
222+
pub name: String,
223+
#[source]
224+
pub err: crate::ExecutionError,
225+
}
226+
227+
/// Error returned by [`Zfs::dataset_exists`].
228+
#[derive(thiserror::Error, Debug)]
229+
#[error("Failed to check if dataset '{name}' exists")]
230+
pub struct DatasetExistsError {
231+
pub name: String,
232+
#[source]
233+
pub err: crate::ExecutionError,
234+
}
235+
236+
/// Error returned by [`Zfs::unload_key`].
237+
#[derive(thiserror::Error, Debug)]
238+
#[error("Failed to unload encryption key for dataset '{name}'")]
239+
pub struct UnloadKeyError {
240+
pub name: String,
241+
#[source]
242+
pub err: crate::ExecutionError,
243+
}
244+
209245
#[derive(Debug, thiserror::Error)]
210246
#[error(
211247
"Failed to create snapshot '{snap_name}' from filesystem '{filesystem}': {err}"
@@ -523,11 +559,14 @@ pub struct DatasetProperties {
523559
/// string so that unexpected compression formats don't prevent inventory
524560
/// from being collected.
525561
pub compression: String,
562+
/// The encryption key epoch for this dataset.
563+
///
564+
/// Only present on encrypted datasets (e.g., crypt datasets on U.2s).
565+
pub epoch: Option<u64>,
526566
}
527567

528568
impl DatasetProperties {
529-
const ZFS_GET_PROPS: &'static str =
530-
"oxide:uuid,name,mounted,avail,used,quota,reservation,compression";
569+
const ZFS_GET_PROPS: &'static str = "oxide:uuid,oxide:epoch,name,mounted,avail,used,quota,reservation,compression";
531570
}
532571

533572
impl TryFrom<&DatasetProperties> for SharedDatasetConfig {
@@ -648,6 +687,18 @@ impl DatasetProperties {
648687
.get("compression")
649688
.map(|(prop, _source)| prop.to_string())
650689
.ok_or_else(|| anyhow!("Missing 'compression'"))?;
690+
// The epoch property is only present on encrypted datasets.
691+
// Like oxide:uuid, we ignore inherited values.
692+
let epoch = props
693+
.get("oxide:epoch")
694+
.filter(|(prop, source)| {
695+
!source.starts_with("inherited") && *prop != "-"
696+
})
697+
.map(|(prop, _source)| {
698+
prop.parse::<u64>()
699+
.context("Failed to parse 'oxide:epoch'")
700+
})
701+
.transpose()?;
651702

652703
Ok(DatasetProperties {
653704
id,
@@ -658,6 +709,7 @@ impl DatasetProperties {
658709
quota,
659710
reservation,
660711
compression,
712+
epoch,
661713
})
662714
})
663715
.collect::<Result<Vec<_>, _>>()
@@ -1197,7 +1249,7 @@ impl Zfs {
11971249
name: &str,
11981250
mountpoint: &Mountpoint,
11991251
) -> Result<(), EnsureDatasetErrorRaw> {
1200-
let mount_info = Self::dataset_exists(name, mountpoint).await?;
1252+
let mount_info = Self::dataset_mount_info(name, mountpoint).await?;
12011253
if !mount_info.exists {
12021254
return Err(EnsureDatasetErrorRaw::DoesNotExist);
12031255
}
@@ -1246,7 +1298,7 @@ impl Zfs {
12461298
additional_options,
12471299
}: DatasetEnsureArgs<'_>,
12481300
) -> Result<(), EnsureDatasetErrorRaw> {
1249-
let dataset_info = Self::dataset_exists(name, &mountpoint).await?;
1301+
let dataset_info = Self::dataset_mount_info(name, &mountpoint).await?;
12501302

12511303
// Non-zoned datasets with an explicit mountpoint and the
12521304
// "canmount=on" property should be mounted within the global zone.
@@ -1365,9 +1417,29 @@ impl Zfs {
13651417
Ok(())
13661418
}
13671419

1368-
// Return (true, mounted) if the dataset exists, (false, false) otherwise,
1369-
// where mounted is if the dataset is mounted.
1370-
async fn dataset_exists(
1420+
/// Check if a ZFS dataset exists.
1421+
pub async fn dataset_exists(
1422+
name: &str,
1423+
) -> Result<bool, DatasetExistsError> {
1424+
let mut cmd = Command::new(ZFS);
1425+
cmd.args(&["list", "-H", name]);
1426+
match execute_async(&mut cmd).await {
1427+
Ok(_) => Ok(true),
1428+
Err(crate::ExecutionError::CommandFailure(ref info))
1429+
if info.stderr.contains("does not exist") =>
1430+
{
1431+
Ok(false)
1432+
}
1433+
Err(err) => Err(DatasetExistsError { name: name.to_string(), err }),
1434+
}
1435+
}
1436+
1437+
/// Get mount info for a dataset, validating its mountpoint.
1438+
///
1439+
/// Returns (exists=true, mounted) if the dataset exists with the expected
1440+
/// mountpoint, (exists=false, mounted=false) if it doesn't exist.
1441+
/// Returns an error if the dataset exists but has an unexpected mountpoint.
1442+
async fn dataset_mount_info(
13711443
name: &str,
13721444
mountpoint: &Mountpoint,
13731445
) -> Result<DatasetMountInfo, EnsureDatasetErrorRaw> {
@@ -1523,6 +1595,62 @@ impl Zfs {
15231595
})
15241596
}
15251597

1598+
/// Atomically change the encryption key and set the oxide:epoch property.
1599+
///
1600+
/// This operation is used for ZFS key rotation when a new Trust Quorum
1601+
/// epoch is committed.
1602+
pub async fn change_key(
1603+
dataset: &str,
1604+
key: &key_manager::VersionedAes256GcmDiskEncryptionKey,
1605+
) -> Result<(), ChangeKeyError> {
1606+
// FIXME: Replace the use of `zfs_atomic_change_key` with a native
1607+
// invocation of `zfs change-key` using the `-o oxide:epoch` option to
1608+
// set the epoch. At time of writing, the `zfs change-key` command does
1609+
// not support setting user properties inline, but a patch is pending to
1610+
// add this feature.
1611+
1612+
let ds = zfs_atomic_change_key::Dataset::new(dataset).map_err(|e| {
1613+
ChangeKeyError {
1614+
name: dataset.to_string(),
1615+
err: anyhow::anyhow!("invalid dataset name: {e}"),
1616+
}
1617+
})?;
1618+
1619+
ds.change_key(zfs_atomic_change_key::Key::hex(*key.expose_secret()))
1620+
.property("oxide:epoch", key.epoch().to_string())
1621+
.await
1622+
.map_err(|e| ChangeKeyError {
1623+
name: dataset.to_string(),
1624+
err: anyhow::anyhow!("{e}"),
1625+
})
1626+
}
1627+
1628+
/// Load the encryption key for an encrypted ZFS dataset.
1629+
///
1630+
/// This makes the dataset accessible for mounting. The key must have
1631+
/// previously been written to the dataset's keylocation.
1632+
pub async fn load_key(name: &str) -> Result<(), LoadKeyError> {
1633+
let mut cmd = Command::new(PFEXEC);
1634+
cmd.args(&[ZFS, "load-key", name]);
1635+
execute_async(&mut cmd)
1636+
.await
1637+
.map(|_| ())
1638+
.map_err(|err| LoadKeyError { name: name.to_string(), err })
1639+
}
1640+
1641+
/// Unload the encryption key for an encrypted ZFS dataset.
1642+
///
1643+
/// This is used for cleanup after failed key operations or during
1644+
/// trial decryption recovery. The dataset must not be mounted.
1645+
pub async fn unload_key(name: &str) -> Result<(), UnloadKeyError> {
1646+
let mut cmd = Command::new(PFEXEC);
1647+
cmd.args(&[ZFS, "unload-key", name]);
1648+
execute_async(&mut cmd)
1649+
.await
1650+
.map(|_| ())
1651+
.map_err(|err| UnloadKeyError { name: name.to_string(), err })
1652+
}
1653+
15261654
/// Calls "zfs get" to acquire multiple values
15271655
///
15281656
/// - `names`: The properties being acquired

key-manager/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,12 @@ pub enum Error {
5353
}
5454

5555
/// Derived Disk Encryption key
56-
#[derive(Default)]
56+
#[derive(Debug, Default)]
5757
struct Aes256GcmDiskEncryptionKey(SecretBox<[u8; 32]>);
5858

5959
/// A Disk encryption key for a given epoch to be used with ZFS datasets for
6060
/// U.2 devices
61+
#[derive(Debug)]
6162
pub struct VersionedAes256GcmDiskEncryptionKey {
6263
epoch: u64,
6364
key: Aes256GcmDiskEncryptionKey,

sled-agent/config-reconciler/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ omicron-common.workspace = true
3131
omicron-uuid-kinds.workspace = true
3232
rand.workspace = true
3333
regex.workspace = true
34+
secrecy.workspace = true
3435
serde.workspace = true
3536
sha2.workspace = true
3637
sled-agent-api.workspace = true
@@ -42,6 +43,7 @@ slog-error-chain.workspace = true
4243
strum.workspace = true
4344
thiserror.workspace = true
4445
tokio.workspace = true
46+
trust-quorum-types.workspace = true
4547
tufaceous-artifact.workspace = true
4648
zone.workspace = true
4749

0 commit comments

Comments
 (0)