commit 831f1eaf21ddbfd08c90e82fd4a4d4b9e0b46ba6
parent f8a33b695a9234ffd0c6bd57b5ba0102f3d3819f
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Jul 2026 21:39:02 +0000
release-product: harden backup restore manifests
- add backup member hashes and recovery manifest fields
- validate restore manifests before atomic restore finalization
- cover backup tamper and recovery manifest rejection paths
- export the hardened backup manifest types from the SDK
Diffstat:
3 files changed, 430 insertions(+), 13 deletions(-)
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -143,11 +143,13 @@ pub use crate::product_clients::{
pub use crate::runtime::{
BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsClient,
RadrootsClientBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths,
- RadrootsSdkTimestamp, RestoreArchive, RestoreReceipt, RestoreRequest, SdkBackupManifest,
- SdkBackupManifestKind, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus,
- SdkOutboxStorageStatus, SdkPrivateStoreStorageStatus, SdkRestoreState, SdkSqliteStoreStatus,
- SdkSqliteWalCheckpointReceipt, SdkSqliteWalStatus, SdkStorageKind, SdkStudioStoreStorageStatus,
- StorageCheckpointReceipt, StorageCheckpointRequest, StorageStatusReceipt, StorageStatusRequest,
+ RadrootsSdkTimestamp, RestoreArchive, RestoreReceipt, RestoreRequest, SdkBackupKeyReference,
+ SdkBackupManifest, SdkBackupManifestKind, SdkBackupMemberHash, SdkBackupMemberHashes,
+ SdkBackupRecoveryManifest, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus,
+ SdkOutboxStorageStatus, SdkPrivateStoreStorageStatus, SdkRestoreFinalization, SdkRestoreState,
+ SdkSqliteStoreStatus, SdkSqliteWalCheckpointReceipt, SdkSqliteWalStatus, SdkStorageKind,
+ SdkStudioStoreStorageStatus, StorageCheckpointReceipt, StorageCheckpointRequest,
+ StorageStatusReceipt, StorageStatusRequest,
};
#[cfg(all(feature = "runtime", feature = "signer-adapters"))]
pub use crate::signer_provider::{
diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs
@@ -18,13 +18,15 @@ use radroots_event_store::RadrootsEventStore;
#[cfg(feature = "runtime")]
use radroots_outbox::RadrootsOutbox;
#[cfg(feature = "runtime")]
+use sha2::{Digest, Sha256};
+#[cfg(feature = "runtime")]
use sqlx::{
Row, SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
#[cfg(feature = "runtime")]
use std::{
- fs,
+ env, fs,
io::ErrorKind,
path::{Component, Path, PathBuf},
str::FromStr,
@@ -407,6 +409,8 @@ pub struct SdkBackupManifest {
pub backup_paths: RadrootsSdkStoragePaths,
pub source_status: StorageStatusReceipt,
pub backup_verification: SdkBackupVerification,
+ pub member_hashes: SdkBackupMemberHashes,
+ pub recovery_manifest: SdkBackupRecoveryManifest,
}
#[cfg(feature = "runtime")]
@@ -423,6 +427,47 @@ pub struct SdkBackupVerification {
}
#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct SdkBackupMemberHashes {
+ pub runtime_store: SdkBackupMemberHash,
+ pub private_store: SdkBackupMemberHash,
+ pub studio_store: SdkBackupMemberHash,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct SdkBackupMemberHash {
+ pub path: PathBuf,
+ pub sha256: String,
+ pub byte_count: u64,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct SdkBackupRecoveryManifest {
+ pub protected_private_store_path: PathBuf,
+ pub protected_private_store_ciphertext_preserved: bool,
+ pub key_reference: SdkBackupKeyReference,
+ pub restore_finalization: SdkRestoreFinalization,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct SdkBackupKeyReference {
+ pub backend: String,
+ pub key_material_included: bool,
+ pub recovery_material_required: bool,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
+pub enum SdkRestoreFinalization {
+ AtomicStagingInstall,
+}
+
+#[cfg(feature = "runtime")]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct IntegrityRequest {}
@@ -882,6 +927,17 @@ impl RadrootsClient {
&backup_paths,
)
.await?;
+ let member_hashes = backup_member_hashes(&backup_paths, &manifest_backup_paths).await?;
+ let recovery_manifest = SdkBackupRecoveryManifest {
+ protected_private_store_path: manifest_backup_paths.private_path.clone(),
+ protected_private_store_ciphertext_preserved: true,
+ key_reference: SdkBackupKeyReference {
+ backend: "configured_protected_store_key_reference".to_owned(),
+ key_material_included: false,
+ recovery_material_required: true,
+ },
+ restore_finalization: SdkRestoreFinalization::AtomicStagingInstall,
+ };
let manifest = SdkBackupManifest {
manifest_kind: SDK_STORAGE_MANIFEST_KIND,
manifest_version: SDK_STORAGE_MANIFEST_VERSION,
@@ -892,6 +948,8 @@ impl RadrootsClient {
backup_paths: manifest_backup_paths,
source_status,
backup_verification,
+ member_hashes,
+ recovery_manifest,
};
write_backup_receipt(request.destination, backup_paths, manifest_path, manifest)
}
@@ -1322,6 +1380,126 @@ async fn backup_sqlite_stores(
}
#[cfg(feature = "runtime")]
+async fn backup_member_hashes(
+ backup_paths: &RadrootsSdkStoragePaths,
+ manifest_paths: &RadrootsSdkStoragePaths,
+) -> Result<SdkBackupMemberHashes, RadrootsSdkError> {
+ Ok(SdkBackupMemberHashes {
+ runtime_store: backup_member_hash(
+ &backup_paths.runtime_path,
+ manifest_paths.runtime_path.clone(),
+ SqliteStoreRole::RuntimeStore,
+ )
+ .await?,
+ private_store: backup_member_hash(
+ &backup_paths.private_path,
+ manifest_paths.private_path.clone(),
+ SqliteStoreRole::PrivateStore,
+ )
+ .await?,
+ studio_store: backup_member_hash(
+ &backup_paths.studio_path,
+ manifest_paths.studio_path.clone(),
+ SqliteStoreRole::StudioStore,
+ )
+ .await?,
+ })
+}
+
+#[cfg(feature = "runtime")]
+async fn backup_member_hash(
+ source_path: &Path,
+ manifest_path: PathBuf,
+ store_role: SqliteStoreRole,
+) -> Result<SdkBackupMemberHash, RadrootsSdkError> {
+ let scratch_dir = unique_backup_member_hash_dir(source_path)?;
+ let canonical_path = scratch_dir.join("member.sqlite");
+ let result = async {
+ let pool = open_read_only_sqlite_pool(source_path, store_role).await?;
+ let vacuum_result = sqlite_vacuum_into(&pool, &canonical_path, store_role).await;
+ pool.close().await;
+ vacuum_result?;
+ hash_backup_member_file(&canonical_path, manifest_path)
+ }
+ .await;
+ let cleanup_result = cleanup_backup_member_hash_dir(&scratch_dir);
+ match (result, cleanup_result) {
+ (Ok(member_hash), Ok(())) => Ok(member_hash),
+ (Err(error), Ok(())) => Err(error),
+ (Ok(_), Err(error)) => Err(error),
+ (Err(error), Err(_)) => Err(error),
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn hash_backup_member_file(
+ source_path: &Path,
+ manifest_path: PathBuf,
+) -> Result<SdkBackupMemberHash, RadrootsSdkError> {
+ let bytes = fs::read(source_path).map_err(|error| RadrootsSdkError::Io {
+ path: source_path.to_path_buf(),
+ message: error.to_string(),
+ })?;
+ let byte_count = u64::try_from(bytes.len()).map_err(|_| RadrootsSdkError::InvalidRequest {
+ message: "backup member size is larger than supported".to_owned(),
+ })?;
+ Ok(SdkBackupMemberHash {
+ path: manifest_path,
+ sha256: hex::encode(Sha256::digest(&bytes)),
+ byte_count,
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn unique_backup_member_hash_dir(source_path: &Path) -> Result<PathBuf, RadrootsSdkError> {
+ let file_name = source_path
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or("sqlite");
+ let nanos = system_time_nanos_since_unix_epoch(SystemTime::now())?;
+ for attempt in 0..100u8 {
+ let path = env::temp_dir().join(format!(
+ ".radroots-sdk-backup-member-hash-{file_name}-{nanos}-{attempt}"
+ ));
+ match create_private_backup_member_hash_dir(&path) {
+ Ok(()) => return Ok(path),
+ Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
+ Err(error) => {
+ return Err(RadrootsSdkError::Io {
+ path,
+ message: error.to_string(),
+ });
+ }
+ }
+ }
+ Err(RadrootsSdkError::InvalidRequest {
+ message: "backup member hash scratch directory could not be reserved".to_owned(),
+ })
+}
+
+#[cfg(all(feature = "runtime", unix))]
+fn create_private_backup_member_hash_dir(path: &Path) -> Result<(), std::io::Error> {
+ use std::os::unix::fs::DirBuilderExt;
+
+ let mut builder = fs::DirBuilder::new();
+ builder.mode(0o700);
+ builder.create(path)
+}
+
+#[cfg(all(feature = "runtime", not(unix)))]
+fn create_private_backup_member_hash_dir(path: &Path) -> Result<(), std::io::Error> {
+ fs::create_dir(path)
+}
+
+#[cfg(feature = "runtime")]
+fn cleanup_backup_member_hash_dir(path: &Path) -> Result<(), RadrootsSdkError> {
+ fs::remove_dir_all(path).map_err(|error| RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })
+}
+
+#[cfg(feature = "runtime")]
fn write_backup_receipt(
destination: PathBuf,
backup_paths: RadrootsSdkStoragePaths,
@@ -1393,12 +1571,18 @@ async fn inspect_restore_archive(source: PathBuf) -> Result<RestoreArchive, Radr
&manifest.backup_paths.private_path,
"private store",
)?;
- let verification = verify_backup_paths(&RadrootsSdkStoragePaths {
+ let archive_paths = RadrootsSdkStoragePaths {
runtime_path: runtime_path.clone(),
studio_path: studio_path.clone(),
private_path: private_path.clone(),
- })
+ };
+ validate_restore_member_hashes(
+ &manifest.member_hashes,
+ &manifest.backup_paths,
+ &archive_paths,
+ )
.await?;
+ let verification = verify_backup_paths(&archive_paths).await?;
validate_restore_verification(&verification, &manifest.backup_verification)?;
Ok(RestoreArchive {
source,
@@ -1508,10 +1692,115 @@ fn validate_restore_manifest(manifest: &SdkBackupManifest) -> Result<(), Radroot
),
});
}
+ if manifest.recovery_manifest.protected_private_store_path != manifest.backup_paths.private_path
+ {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "restore recovery manifest private store path does not match backup paths"
+ .to_owned(),
+ });
+ }
+ if !manifest
+ .recovery_manifest
+ .protected_private_store_ciphertext_preserved
+ {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "restore recovery manifest must preserve protected private-store ciphertext"
+ .to_owned(),
+ });
+ }
+ if manifest
+ .recovery_manifest
+ .key_reference
+ .key_material_included
+ {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "restore recovery manifest must not include key material".to_owned(),
+ });
+ }
+ if !manifest
+ .recovery_manifest
+ .key_reference
+ .recovery_material_required
+ {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "restore recovery manifest must require recovery material".to_owned(),
+ });
+ }
Ok(())
}
#[cfg(feature = "runtime")]
+async fn validate_restore_member_hashes(
+ member_hashes: &SdkBackupMemberHashes,
+ manifest_paths: &RadrootsSdkStoragePaths,
+ archive_paths: &RadrootsSdkStoragePaths,
+) -> Result<(), RadrootsSdkError> {
+ validate_restore_member_hash(
+ "runtime store",
+ &member_hashes.runtime_store,
+ &manifest_paths.runtime_path,
+ &archive_paths.runtime_path,
+ SqliteStoreRole::RuntimeStore,
+ )
+ .await?;
+ validate_restore_member_hash(
+ "private store",
+ &member_hashes.private_store,
+ &manifest_paths.private_path,
+ &archive_paths.private_path,
+ SqliteStoreRole::PrivateStore,
+ )
+ .await?;
+ validate_restore_member_hash(
+ "studio store",
+ &member_hashes.studio_store,
+ &manifest_paths.studio_path,
+ &archive_paths.studio_path,
+ SqliteStoreRole::StudioStore,
+ )
+ .await
+}
+
+#[cfg(feature = "runtime")]
+async fn validate_restore_member_hash(
+ label: &'static str,
+ expected: &SdkBackupMemberHash,
+ manifest_path: &Path,
+ archive_path: &Path,
+ store_role: SqliteStoreRole,
+) -> Result<(), RadrootsSdkError> {
+ if expected.path != manifest_path {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("restore {label} hash path does not match manifest backup path"),
+ });
+ }
+ let actual = backup_member_hash(archive_path, expected.path.clone(), store_role)
+ .await
+ .map_err(|error| RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "restore {label} hash does not match manifest: canonical member hash failed: {error}"
+ ),
+ })?;
+ if actual != *expected {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "restore {label} hash does not match manifest: expected {} bytes {} actual {} bytes {}",
+ expected.byte_count,
+ hash_prefix(expected.sha256.as_str()),
+ actual.byte_count,
+ hash_prefix(actual.sha256.as_str()),
+ ),
+ });
+ }
+ Ok(())
+}
+
+#[cfg(feature = "runtime")]
+fn hash_prefix(value: &str) -> &str {
+ value.get(..12).unwrap_or(value)
+}
+
+#[cfg(feature = "runtime")]
fn validate_restore_verification(
actual: &SdkBackupVerification,
manifest: &SdkBackupVerification,
@@ -2392,6 +2681,10 @@ async fn verify_backup_paths(
let outbox_summary = outbox.status_summary(i64::MAX).await?;
let private_summary = private_store.status_summary().await?;
let studio_summary = studio_store.status_summary().await?;
+ event_store.pool().close().await;
+ outbox.pool().close().await;
+ private_store.pool().close().await;
+ studio_store.pool().close().await;
Ok(SdkBackupVerification {
event_store_ok: event_store_integrity.ok,
outbox_ok: outbox_integrity.ok,
diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs
@@ -166,6 +166,11 @@ fn nul_path() -> PathBuf {
}
fn manifest() -> SdkBackupManifest {
+ let backup_paths = RadrootsSdkStoragePaths {
+ runtime_path: PathBuf::from(RUNTIME_SQLITE_FILE),
+ studio_path: PathBuf::from(STUDIO_SQLITE_FILE),
+ private_path: PathBuf::from(PRIVATE_SQLITE_FILE),
+ };
SdkBackupManifest {
manifest_kind: SDK_STORAGE_MANIFEST_KIND,
manifest_version: SDK_STORAGE_MANIFEST_VERSION,
@@ -173,13 +178,36 @@ fn manifest() -> SdkBackupManifest {
created_at_ms: 1_700_000_000_000,
source_storage: SdkStorageKind::Memory,
source_paths: None,
- backup_paths: RadrootsSdkStoragePaths {
- runtime_path: PathBuf::from(RUNTIME_SQLITE_FILE),
- studio_path: PathBuf::from(STUDIO_SQLITE_FILE),
- private_path: PathBuf::from(PRIVATE_SQLITE_FILE),
- },
+ backup_paths: backup_paths.clone(),
source_status: storage_status(),
backup_verification: verification(true, true),
+ member_hashes: SdkBackupMemberHashes {
+ runtime_store: SdkBackupMemberHash {
+ path: backup_paths.runtime_path.clone(),
+ sha256: "0".repeat(64),
+ byte_count: 1,
+ },
+ private_store: SdkBackupMemberHash {
+ path: backup_paths.private_path.clone(),
+ sha256: "1".repeat(64),
+ byte_count: 1,
+ },
+ studio_store: SdkBackupMemberHash {
+ path: backup_paths.studio_path.clone(),
+ sha256: "2".repeat(64),
+ byte_count: 1,
+ },
+ },
+ recovery_manifest: SdkBackupRecoveryManifest {
+ protected_private_store_path: backup_paths.private_path,
+ protected_private_store_ciphertext_preserved: true,
+ key_reference: SdkBackupKeyReference {
+ backend: "configured_protected_store_key_reference".to_owned(),
+ key_material_included: false,
+ recovery_material_required: true,
+ },
+ restore_finalization: SdkRestoreFinalization::AtomicStagingInstall,
+ },
}
}
@@ -592,6 +620,46 @@ async fn runtime_public_surface_covers_builders_status_integrity_backup_and_rest
.await
.expect("restore archive");
assert_eq!(archive.manifest, backup.manifest);
+ assert_eq!(
+ archive.manifest.member_hashes.runtime_store.path,
+ PathBuf::from(RUNTIME_SQLITE_FILE)
+ );
+ assert_eq!(
+ archive.manifest.member_hashes.runtime_store.sha256.len(),
+ 64
+ );
+ assert!(archive.manifest.member_hashes.runtime_store.byte_count > 0);
+ assert_eq!(
+ archive
+ .manifest
+ .recovery_manifest
+ .protected_private_store_path,
+ PathBuf::from(PRIVATE_SQLITE_FILE)
+ );
+ assert!(
+ archive
+ .manifest
+ .recovery_manifest
+ .protected_private_store_ciphertext_preserved
+ );
+ assert!(
+ !archive
+ .manifest
+ .recovery_manifest
+ .key_reference
+ .key_material_included
+ );
+ assert!(
+ archive
+ .manifest
+ .recovery_manifest
+ .key_reference
+ .recovery_material_required
+ );
+ assert_eq!(
+ archive.manifest.recovery_manifest.restore_finalization,
+ SdkRestoreFinalization::AtomicStagingInstall
+ );
let restore_destination = tempdir.path().join("restore");
let dry_run = RadrootsClient::restore(
@@ -1005,6 +1073,40 @@ fn restore_archive_path_validators_cover_missing_outside_and_manifest_edges() {
invalid_request_message(validate_restore_manifest(&unsupported_version))
.contains("version")
);
+ let mut mismatched_recovery_path = manifest();
+ mismatched_recovery_path
+ .recovery_manifest
+ .protected_private_store_path = PathBuf::from("other.sqlite");
+ assert!(
+ invalid_request_message(validate_restore_manifest(&mismatched_recovery_path))
+ .contains("private store path")
+ );
+ let mut plaintext_recovery = manifest();
+ plaintext_recovery
+ .recovery_manifest
+ .protected_private_store_ciphertext_preserved = false;
+ assert!(
+ invalid_request_message(validate_restore_manifest(&plaintext_recovery))
+ .contains("private-store ciphertext")
+ );
+ let mut exported_key_material = manifest();
+ exported_key_material
+ .recovery_manifest
+ .key_reference
+ .key_material_included = true;
+ assert!(
+ invalid_request_message(validate_restore_manifest(&exported_key_material))
+ .contains("key material")
+ );
+ let mut missing_recovery_material = manifest();
+ missing_recovery_material
+ .recovery_manifest
+ .key_reference
+ .recovery_material_required = false;
+ assert!(
+ invalid_request_message(validate_restore_manifest(&missing_recovery_material))
+ .contains("recovery material")
+ );
let ok = verification(true, true);
assert!(validate_restore_verification(&ok, &ok).is_ok());
@@ -1651,6 +1753,9 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges()
.expect("invalid private studio member");
let mut invalid_private_manifest = manifest();
invalid_private_manifest.backup_paths.private_path = PathBuf::from("../outside.sqlite");
+ invalid_private_manifest
+ .recovery_manifest
+ .protected_private_store_path = invalid_private_manifest.backup_paths.private_path.clone();
write_backup_manifest(
&invalid_private_member_source.join(BACKUP_MANIFEST_FILE),
&invalid_private_manifest,
@@ -1689,6 +1794,23 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges()
let archive = inspect_restore_archive(backup_destination.clone())
.await
.expect("archive");
+ let hash_mismatch_sdk = RadrootsClient::builder()
+ .directory_storage(tempdir.path().join("hash-mismatch-sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_003))
+ .build()
+ .await
+ .expect("hash mismatch sdk");
+ let hash_mismatch_backup = tempdir.path().join("hash-mismatch-backup");
+ hash_mismatch_sdk
+ .backup(BackupRequest::new(&hash_mismatch_backup))
+ .await
+ .expect("hash mismatch backup");
+ fs::write(hash_mismatch_backup.join(PRIVATE_SQLITE_FILE), b"tampered")
+ .expect("tamper private store");
+ assert!(
+ invalid_request_message(inspect_restore_archive(hash_mismatch_backup).await)
+ .contains("hash does not match")
+ );
let public_protected_parent = tempdir.path().join("public-protected-parent");
fs::create_dir(&public_protected_parent).expect("public protected parent");
let public_protected_destination = public_protected_parent.join("restore");