verify.rs (69825B)
1 //! Untrusted backup bundle verification. 2 3 use core::{fmt, num::NonZeroU64}; 4 use std::path::Path; 5 6 use crate::{ 7 BackupManifestSha256, ServiceBackupManifest, ServiceDatabaseIdentity, ServiceDatabaseMetadata, 8 ServiceSqliteError, ServiceSqliteErrorKind, 9 }; 10 11 #[cfg(any(target_os = "linux", target_os = "macos"))] 12 use { 13 core::num::NonZeroU32, 14 radroots_runtime_paths::{InstanceId, ServiceId}, 15 radroots_storage::event::SourceGeneration, 16 rustix::{ 17 fs::{Dir, FileType, Mode, OFlags, fstat, open, openat}, 18 process::geteuid, 19 }, 20 sha2::{Digest, Sha256}, 21 sqlx::{ConnectOptions, Connection as _, Row, SqliteConnection, sqlite::SqliteConnectOptions}, 22 std::{ 23 error::Error, 24 fs::File, 25 io::{Read, Seek, SeekFrom}, 26 os::fd::AsRawFd, 27 os::unix::ffi::OsStrExt, 28 path::PathBuf, 29 }, 30 }; 31 32 #[cfg(any(target_os = "linux", target_os = "macos"))] 33 const MAX_BUNDLE_PATH_BYTES: usize = 4_096; 34 #[cfg(any(target_os = "linux", target_os = "macos"))] 35 const HASH_BUFFER_BYTES: usize = 64 * 1_024; 36 37 /// Non-forgeable proof that one retained backup member passed v1 verification. 38 /// 39 /// The proof is bound to retained directory and file descriptors, not only to 40 /// their pathnames. It is not restore or replacement authority. Later restore 41 /// work must copy from the retained member and reverify the staged copy. 42 /// 43 /// External callers cannot construct the proof or obtain its raw handles: 44 /// 45 /// ```compile_fail 46 /// use radroots_service_sqlite::VerifiedServiceBackup; 47 /// let _forged = VerifiedServiceBackup {}; 48 /// ``` 49 /// 50 /// ```compile_fail 51 /// # fn inspect(proof: &radroots_service_sqlite::VerifiedServiceBackup) { 52 /// let _raw = proof.state_file(); 53 /// # } 54 /// ``` 55 pub struct VerifiedServiceBackup { 56 manifest: ServiceBackupManifest, 57 database_metadata: ServiceDatabaseMetadata, 58 #[cfg(any(target_os = "linux", target_os = "macos"))] 59 binding: VerifiedBundleBinding, 60 } 61 62 impl VerifiedServiceBackup { 63 /// Returns the exact canonical manifest bound by this proof. 64 #[must_use] 65 pub const fn manifest(&self) -> &ServiceBackupManifest { 66 &self.manifest 67 } 68 69 /// Returns the actual validated application metadata read from the member. 70 #[must_use] 71 pub const fn database_metadata(&self) -> &ServiceDatabaseMetadata { 72 &self.database_metadata 73 } 74 75 #[cfg(any(target_os = "linux", target_os = "macos"))] 76 #[allow(dead_code)] 77 pub(crate) fn state_file(&self) -> &File { 78 &self.binding.state 79 } 80 81 #[cfg(any(target_os = "linux", target_os = "macos"))] 82 #[allow(dead_code)] 83 pub(crate) fn validate_binding(&self) -> Result<(), ServiceSqliteError> { 84 self.binding.validate() 85 } 86 } 87 88 impl fmt::Debug for VerifiedServiceBackup { 89 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 90 formatter 91 .debug_struct("VerifiedServiceBackup") 92 .field("service", &"[redacted]") 93 .field("instance", &"[redacted]") 94 .field("manifest_digest", &"[redacted]") 95 .field( 96 "state_schema_version", 97 &self.database_metadata.state_schema_version(), 98 ) 99 .field("member", &"[retained]") 100 .finish() 101 } 102 } 103 104 /// Verifies untrusted canonical manifest bytes and their exact singleton bundle. 105 /// 106 /// The expected digest, expected database identity, and positive maximum member 107 /// size are trusted caller inputs. This function is deliberately synchronous: 108 /// callers own the supervised blocking worker and positive deadline. It never 109 /// creates, copies, deletes, restores, or replaces filesystem state. 110 pub fn verify_backup_bundle( 111 manifest_bytes: &[u8], 112 expected_manifest_digest: BackupManifestSha256, 113 bundle_directory: &Path, 114 expected_identity: &ServiceDatabaseIdentity, 115 maximum_state_bytes: NonZeroU64, 116 ) -> Result<VerifiedServiceBackup, ServiceSqliteError> { 117 #[cfg(any(target_os = "linux", target_os = "macos"))] 118 { 119 verify_backup_bundle_native( 120 manifest_bytes, 121 expected_manifest_digest, 122 bundle_directory, 123 expected_identity, 124 maximum_state_bytes, 125 ) 126 } 127 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 128 { 129 let _ = ( 130 manifest_bytes, 131 expected_manifest_digest, 132 bundle_directory, 133 expected_identity, 134 maximum_state_bytes, 135 ); 136 Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Backup)) 137 } 138 } 139 140 #[cfg(any(target_os = "linux", target_os = "macos"))] 141 fn verify_backup_bundle_native( 142 manifest_bytes: &[u8], 143 expected_manifest_digest: BackupManifestSha256, 144 bundle_directory: &Path, 145 expected_identity: &ServiceDatabaseIdentity, 146 maximum_state_bytes: NonZeroU64, 147 ) -> Result<VerifiedServiceBackup, ServiceSqliteError> { 148 require_verification_condition( 149 manifest_bytes.len() <= crate::BACKUP_MANIFEST_CANONICAL_MAX_BYTES, 150 VerificationFailureKind::Manifest, 151 )?; 152 let actual_manifest_digest: [u8; 32] = Sha256::digest(manifest_bytes).into(); 153 require_verification_condition( 154 &actual_manifest_digest == expected_manifest_digest.as_bytes(), 155 VerificationFailureKind::ManifestDigest, 156 )?; 157 let manifest = ServiceBackupManifest::from_canonical_bytes(manifest_bytes) 158 .map_err(|source| verification_source(VerificationFailureKind::Manifest, source))?; 159 verify_manifest_intent(&manifest, expected_identity)?; 160 161 let member = manifest 162 .members() 163 .first() 164 .ok_or_else(|| verification_error(VerificationFailureKind::Inventory))?; 165 require_verification_condition( 166 member.byte_length() <= i64::MAX as u64 167 && member.byte_length() <= maximum_state_bytes.get(), 168 VerificationFailureKind::MemberLength, 169 )?; 170 171 let binding = VerifiedBundleBinding::open(bundle_directory, member.byte_length())?; 172 binding.validate_inventory()?; 173 let first_digest = binding.hash_state(maximum_state_bytes)?; 174 require_verification_condition( 175 &first_digest == member.sha256().as_bytes(), 176 VerificationFailureKind::MemberDigest, 177 )?; 178 binding.validate()?; 179 180 let database_metadata = futures::executor::block_on(async { 181 let mut connection = open_sqlite_from_retained_state(&binding).await?; 182 apply_connection_policy(&mut connection).await?; 183 binding.validate()?; 184 verify_database_inventory(&mut connection).await?; 185 binding.validate()?; 186 let database_metadata = 187 verify_database_metadata(&mut connection, &manifest, expected_identity).await?; 188 binding.validate()?; 189 verify_integrity(&mut connection).await?; 190 binding.validate()?; 191 connection.close().await.map_err(integrity_source)?; 192 Ok::<_, ServiceSqliteError>(database_metadata) 193 })?; 194 195 binding.validate_inventory()?; 196 let final_digest = binding.hash_state(maximum_state_bytes)?; 197 require_backup_digests(&first_digest, &final_digest, member.sha256().as_bytes())?; 198 binding.validate()?; 199 200 Ok(VerifiedServiceBackup { 201 manifest, 202 database_metadata, 203 binding, 204 }) 205 } 206 207 #[cfg(any(target_os = "linux", target_os = "macos"))] 208 fn require_backup_digests( 209 first: &[u8; 32], 210 final_digest: &[u8; 32], 211 expected: &[u8; 32], 212 ) -> Result<(), ServiceSqliteError> { 213 require_verification_condition( 214 crate::all_constraints([ 215 first == expected, 216 final_digest == expected, 217 first == final_digest, 218 ]), 219 VerificationFailureKind::MemberDigest, 220 ) 221 } 222 223 #[cfg(any(target_os = "linux", target_os = "macos"))] 224 fn verify_manifest_intent( 225 manifest: &ServiceBackupManifest, 226 expected: &ServiceDatabaseIdentity, 227 ) -> Result<(), ServiceSqliteError> { 228 require_verification_condition( 229 crate::all_constraints([ 230 manifest.service() == expected.service(), 231 manifest.instance() == expected.instance(), 232 manifest.source_generation() == expected.source_generation(), 233 manifest.state_schema_version() <= expected.supported_state_schema_version(), 234 ]), 235 VerificationFailureKind::Intent, 236 )?; 237 Ok(()) 238 } 239 240 #[cfg(any(target_os = "linux", target_os = "macos"))] 241 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 242 struct FileIdentity { 243 device: u64, 244 inode: u64, 245 } 246 247 #[cfg(any(target_os = "linux", target_os = "macos"))] 248 struct VerifiedBundleBinding { 249 path: PathBuf, 250 directory: File, 251 directory_identity: FileIdentity, 252 state: File, 253 state_identity: FileIdentity, 254 state_length: u64, 255 } 256 257 #[cfg(any(target_os = "linux", target_os = "macos"))] 258 impl VerifiedBundleBinding { 259 fn open(path: &Path, expected_length: u64) -> Result<Self, ServiceSqliteError> { 260 validate_bundle_path(path)?; 261 let directory = File::from( 262 open( 263 path, 264 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, 265 Mode::empty(), 266 ) 267 .map_err(|source| { 268 verification_source(VerificationFailureKind::BundleDirectory, source) 269 })?, 270 ); 271 let directory_identity = validate_directory(&directory)?; 272 let state = File::from( 273 openat( 274 &directory, 275 crate::BACKUP_STATE_MEMBER_NAME, 276 OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC, 277 Mode::empty(), 278 ) 279 .map_err(|source| verification_source(VerificationFailureKind::Inventory, source))?, 280 ); 281 let (state_identity, state_length) = validate_state(&state)?; 282 require_verification_condition( 283 state_length == expected_length, 284 VerificationFailureKind::MemberLength, 285 )?; 286 let binding = Self { 287 path: path.to_path_buf(), 288 directory, 289 directory_identity, 290 state, 291 state_identity, 292 state_length, 293 }; 294 binding.validate()?; 295 Ok(binding) 296 } 297 298 fn validate(&self) -> Result<(), ServiceSqliteError> { 299 let current_directory = File::from( 300 open( 301 &self.path, 302 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, 303 Mode::empty(), 304 ) 305 .map_err(|source| { 306 verification_source(VerificationFailureKind::BindingChanged, source) 307 })?, 308 ); 309 require_verification_condition( 310 validate_directory(&self.directory)? == self.directory_identity, 311 VerificationFailureKind::BindingChanged, 312 )?; 313 require_verification_condition( 314 validate_directory(¤t_directory)? == self.directory_identity, 315 VerificationFailureKind::BindingChanged, 316 )?; 317 let current_state = File::from( 318 openat( 319 &self.directory, 320 crate::BACKUP_STATE_MEMBER_NAME, 321 OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC, 322 Mode::empty(), 323 ) 324 .map_err(|source| { 325 verification_source(VerificationFailureKind::BindingChanged, source) 326 })?, 327 ); 328 for state in [&self.state, ¤t_state] { 329 let (identity, length) = validate_state(state)?; 330 require_verification_condition( 331 (identity, length) == (self.state_identity, self.state_length), 332 VerificationFailureKind::BindingChanged, 333 )?; 334 } 335 Ok(()) 336 } 337 338 fn validate_inventory(&self) -> Result<(), ServiceSqliteError> { 339 self.validate()?; 340 let mut directory = Dir::read_from(&self.directory) 341 .map_err(|source| verification_source(VerificationFailureKind::Inventory, source))?; 342 let mut seen_state = false; 343 let mut meaningful = 0_u8; 344 while let Some(entry) = directory.read() { 345 let entry = entry.map_err(|source| { 346 verification_source(VerificationFailureKind::Inventory, source) 347 })?; 348 let name = entry.file_name().to_bytes(); 349 if name == b"." || name == b".." { 350 continue; 351 } 352 meaningful = meaningful 353 .checked_add(1) 354 .ok_or_else(|| verification_error(VerificationFailureKind::Inventory))?; 355 require_verification_condition( 356 crate::all_constraints([ 357 meaningful <= 1, 358 name == crate::BACKUP_STATE_MEMBER_NAME.as_bytes(), 359 ]), 360 VerificationFailureKind::Inventory, 361 )?; 362 seen_state = true; 363 } 364 require_verification_condition(seen_state, VerificationFailureKind::Inventory)?; 365 self.validate() 366 } 367 368 fn hash_state(&self, maximum: NonZeroU64) -> Result<[u8; 32], ServiceSqliteError> { 369 self.validate()?; 370 let mut state = self 371 .state 372 .try_clone() 373 .map_err(|source| verification_source(VerificationFailureKind::MemberDigest, source))?; 374 state 375 .seek(SeekFrom::Start(0)) 376 .map_err(|source| verification_source(VerificationFailureKind::MemberDigest, source))?; 377 let mut hasher = Sha256::new(); 378 let mut length = 0_u64; 379 let mut buffer = [0_u8; HASH_BUFFER_BYTES]; 380 loop { 381 let count = state.read(&mut buffer).map_err(|source| { 382 verification_source(VerificationFailureKind::MemberDigest, source) 383 })?; 384 if count == 0 { 385 break; 386 } 387 length = length 388 .checked_add( 389 u64::try_from(count) 390 .map_err(|_| verification_error(VerificationFailureKind::MemberLength))?, 391 ) 392 .ok_or_else(|| verification_error(VerificationFailureKind::MemberLength))?; 393 require_verification_condition( 394 crate::all_constraints([length <= i64::MAX as u64, length <= maximum.get()]), 395 VerificationFailureKind::MemberLength, 396 )?; 397 hasher.update(&buffer[..count]); 398 } 399 require_verification_condition( 400 crate::all_constraints([length != 0, length == self.state_length]), 401 VerificationFailureKind::MemberLength, 402 )?; 403 self.validate()?; 404 Ok(hasher.finalize().into()) 405 } 406 } 407 408 #[cfg(any(target_os = "linux", target_os = "macos"))] 409 fn validate_bundle_path(path: &Path) -> Result<(), ServiceSqliteError> { 410 require_verification_condition( 411 crate::all_constraints([ 412 path.is_absolute(), 413 !path.as_os_str().as_bytes().is_empty(), 414 path.as_os_str().as_bytes().len() <= MAX_BUNDLE_PATH_BYTES, 415 !path.components().any(|part| { 416 matches!( 417 part, 418 std::path::Component::CurDir | std::path::Component::ParentDir 419 ) 420 }), 421 ]), 422 VerificationFailureKind::BundleDirectory, 423 )?; 424 Ok(()) 425 } 426 427 #[cfg(any(target_os = "linux", target_os = "macos"))] 428 fn validate_directory(directory: &File) -> Result<FileIdentity, ServiceSqliteError> { 429 let status = fstat(directory) 430 .map_err(|source| verification_source(VerificationFailureKind::BundleDirectory, source))?; 431 let mode = crate::native_metadata::mode(status.st_mode) & 0o777; 432 require_verification_condition( 433 crate::native_metadata::restrictive_directory( 434 FileType::from_raw_mode(status.st_mode).is_dir(), 435 status.st_uid, 436 geteuid().as_raw(), 437 mode, 438 ), 439 VerificationFailureKind::Permissions, 440 )?; 441 file_identity(&status) 442 } 443 444 #[cfg(any(target_os = "linux", target_os = "macos"))] 445 fn validate_state(file: &File) -> Result<(FileIdentity, u64), ServiceSqliteError> { 446 let status = fstat(file) 447 .map_err(|source| verification_source(VerificationFailureKind::Inventory, source))?; 448 let mode = crate::native_metadata::mode(status.st_mode) & 0o777; 449 let length = u64::try_from(status.st_size) 450 .map_err(|_| verification_error(VerificationFailureKind::MemberLength))?; 451 require_verification_condition( 452 crate::native_metadata::restrictive_regular_file( 453 FileType::from_raw_mode(status.st_mode).is_file(), 454 crate::native_metadata::link_count(status.st_nlink), 455 status.st_uid, 456 geteuid().as_raw(), 457 mode, 458 ), 459 VerificationFailureKind::Permissions, 460 )?; 461 require_verification_condition( 462 crate::native_metadata::valid_artifact_length(length, None), 463 VerificationFailureKind::MemberLength, 464 )?; 465 Ok((file_identity(&status)?, length)) 466 } 467 468 #[cfg(any(target_os = "linux", target_os = "macos"))] 469 fn file_identity(status: &rustix::fs::Stat) -> Result<FileIdentity, ServiceSqliteError> { 470 Ok(FileIdentity { 471 device: crate::native_metadata::device(status.st_dev) 472 .map_err(|_| verification_error(VerificationFailureKind::BindingChanged))?, 473 inode: status.st_ino, 474 }) 475 } 476 477 #[cfg(any(target_os = "linux", target_os = "macos"))] 478 async fn open_sqlite_from_retained_state( 479 binding: &VerifiedBundleBinding, 480 ) -> Result<SqliteConnection, ServiceSqliteError> { 481 let descriptor = binding.state.as_raw_fd(); 482 #[cfg(target_os = "linux")] 483 let descriptor_path = format!("/proc/self/fd/{descriptor}"); 484 #[cfg(target_os = "macos")] 485 let descriptor_path = format!("/dev/fd/{descriptor}"); 486 let options = SqliteConnectOptions::new() 487 .filename(descriptor_path) 488 .read_only(true) 489 .immutable(true) 490 .create_if_missing(false) 491 .foreign_keys(false) 492 .disable_statement_logging(); 493 SqliteConnection::connect_with(&options) 494 .await 495 .map_err(|source| verification_source(VerificationFailureKind::Inventory, source)) 496 } 497 498 #[cfg(any(target_os = "linux", target_os = "macos"))] 499 async fn apply_connection_policy( 500 connection: &mut SqliteConnection, 501 ) -> Result<(), ServiceSqliteError> { 502 sqlx::query("PRAGMA query_only = ON") 503 .execute(&mut *connection) 504 .await 505 .map_err(integrity_source)?; 506 sqlx::query("PRAGMA trusted_schema = OFF") 507 .execute(&mut *connection) 508 .await 509 .map_err(integrity_source)?; 510 verify_connection_policy(connection).await 511 } 512 513 #[cfg(any(target_os = "linux", target_os = "macos"))] 514 async fn verify_connection_policy( 515 connection: &mut SqliteConnection, 516 ) -> Result<(), ServiceSqliteError> { 517 let query_only = sqlx::query_scalar::<_, i64>("PRAGMA query_only") 518 .fetch_one(&mut *connection) 519 .await 520 .map_err(integrity_source)?; 521 let trusted_schema = sqlx::query_scalar::<_, i64>("PRAGMA trusted_schema") 522 .fetch_one(connection) 523 .await 524 .map_err(integrity_source)?; 525 require_verification_connection_policy(query_only, trusted_schema) 526 } 527 528 #[cfg(any(target_os = "linux", target_os = "macos"))] 529 fn require_verification_connection_policy( 530 query_only: i64, 531 trusted_schema: i64, 532 ) -> Result<(), ServiceSqliteError> { 533 crate::all_constraints([query_only == 1, trusted_schema == 0]) 534 .then_some(()) 535 .ok_or_else(|| integrity_error(IntegrityFailureKind::Policy)) 536 } 537 538 #[cfg(any(target_os = "linux", target_os = "macos"))] 539 async fn verify_database_inventory( 540 connection: &mut SqliteConnection, 541 ) -> Result<(), ServiceSqliteError> { 542 let rows = sqlx::query( 543 "SELECT 544 seq, 545 typeof(name) = 'text' AS name_type_ok, 546 length(CAST(name AS BLOB)) AS name_length, 547 substr(CAST(name AS BLOB), 1, 5) AS name_prefix 548 FROM pragma_database_list 549 LIMIT 2", 550 ) 551 .fetch_all(connection) 552 .await 553 .map_err(integrity_source)?; 554 let Some(first) = rows.first() else { 555 return Err(integrity_error(IntegrityFailureKind::DatabaseInventory)); 556 }; 557 let sequence_matches = first.try_get::<i64, _>(0).ok() == Some(0); 558 let name_matches = crate::persisted_value::bounded_utf8( 559 first, 560 "name_type_ok", 561 "name_length", 562 "name_prefix", 563 1, 564 4, 565 ) == Some("main"); 566 require_verification_database_inventory(sequence_matches, name_matches, rows.len() > 1) 567 } 568 569 #[cfg(any(target_os = "linux", target_os = "macos"))] 570 fn require_verification_database_inventory( 571 sequence_matches: bool, 572 name_matches: bool, 573 has_extra: bool, 574 ) -> Result<(), ServiceSqliteError> { 575 crate::all_constraints([sequence_matches, name_matches, !has_extra]) 576 .then_some(()) 577 .ok_or_else(|| integrity_error(IntegrityFailureKind::DatabaseInventory)) 578 } 579 580 #[cfg(any(target_os = "linux", target_os = "macos"))] 581 async fn verify_database_metadata( 582 connection: &mut SqliteConnection, 583 manifest: &ServiceBackupManifest, 584 expected: &ServiceDatabaseIdentity, 585 ) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> { 586 let object_rows = sqlx::query( 587 "SELECT type 588 FROM main.sqlite_schema 589 WHERE name = 'radroots_service_metadata' 590 LIMIT 2", 591 ) 592 .fetch_all(&mut *connection) 593 .await 594 .map_err(metadata_source)?; 595 let object = object_rows.first().ok_or_else(metadata_error)?; 596 crate::require_condition( 597 object.try_get::<&str, _>(0).ok() == Some("table") && object_rows.len() == 1, 598 ServiceSqliteErrorKind::Metadata, 599 )?; 600 601 let application_id = sqlx::query_scalar::<_, i64>("PRAGMA application_id") 602 .fetch_one(&mut *connection) 603 .await 604 .map_err(metadata_source)?; 605 let application_id = u32::try_from(application_id) 606 .ok() 607 .and_then(|value| crate::ServiceSqliteApplicationId::new(value).ok()) 608 .ok_or_else(metadata_error)?; 609 610 let rows = sqlx::query( 611 "SELECT 612 CASE WHEN typeof(singleton) = 'integer' THEN singleton END, 613 typeof(service_id) = 'text' AS service_id_type_ok, 614 length(CAST(service_id AS BLOB)) AS service_id_length, 615 substr(CAST(service_id AS BLOB), 1, 129) AS service_id_prefix, 616 typeof(instance_id) = 'text' AS instance_id_type_ok, 617 length(CAST(instance_id AS BLOB)) AS instance_id_length, 618 substr(CAST(instance_id AS BLOB), 1, 129) AS instance_id_prefix, 619 typeof(source_generation) = 'blob' AS source_generation_type_ok, 620 length(source_generation) AS source_generation_length, 621 substr(source_generation, 1, 33) AS source_generation_prefix, 622 CASE WHEN typeof(state_schema_version) = 'integer' 623 THEN state_schema_version END, 624 CASE WHEN typeof(created_at_unix_ms) = 'integer' 625 THEN created_at_unix_ms END 626 FROM radroots_service_metadata 627 LIMIT 2", 628 ) 629 .fetch_all(connection) 630 .await 631 .map_err(metadata_source)?; 632 let row = rows.first().ok_or_else(metadata_error)?; 633 let singleton = row.try_get::<Option<i64>, _>(0).map_err(metadata_source)?; 634 crate::require_condition(rows.len() == 1, ServiceSqliteErrorKind::Metadata)?; 635 if singleton != Some(1) { 636 return Err(metadata_error()); 637 } 638 let service = crate::persisted_value::bounded_utf8( 639 row, 640 "service_id_type_ok", 641 "service_id_length", 642 "service_id_prefix", 643 1, 644 crate::persisted_value::MAX_IDENTIFIER_UTF8_BYTES, 645 ) 646 .and_then(|value| ServiceId::new(value).ok()) 647 .ok_or_else(metadata_error)?; 648 let instance = crate::persisted_value::bounded_utf8( 649 row, 650 "instance_id_type_ok", 651 "instance_id_length", 652 "instance_id_prefix", 653 1, 654 crate::persisted_value::MAX_IDENTIFIER_UTF8_BYTES, 655 ) 656 .and_then(|value| InstanceId::new(value).ok()) 657 .ok_or_else(metadata_error)?; 658 let generation = crate::persisted_value::bounded_bytes( 659 row, 660 "source_generation_type_ok", 661 "source_generation_length", 662 "source_generation_prefix", 663 32, 664 32, 665 ) 666 .and_then(|value| <[u8; 32]>::try_from(value).ok()) 667 .and_then(|value| SourceGeneration::new(value).ok()) 668 .ok_or_else(metadata_error)?; 669 let schema = row.try_get::<Option<i64>, _>(10).map_err(metadata_source)?; 670 let created_at = row.try_get::<Option<i64>, _>(11).map_err(metadata_source)?; 671 let (Some(schema), Some(created_at)) = (schema, created_at) else { 672 return Err(metadata_error()); 673 }; 674 let schema = NonZeroU32::new(u32::try_from(schema).map_err(|_| metadata_error())?) 675 .ok_or_else(metadata_error)?; 676 let created_at = u64::try_from(created_at).map_err(|_| metadata_error())?; 677 678 require_verification_metadata_projection([ 679 service == *expected.service(), 680 instance == *expected.instance(), 681 generation == expected.source_generation(), 682 application_id == expected.application_id(), 683 service == *manifest.service(), 684 instance == *manifest.instance(), 685 generation == manifest.source_generation(), 686 schema == manifest.state_schema_version(), 687 schema <= expected.supported_state_schema_version(), 688 ])?; 689 ServiceDatabaseMetadata::from_verified_backup( 690 service, 691 instance, 692 generation, 693 schema, 694 created_at, 695 application_id, 696 ) 697 .map_err(|_| metadata_error()) 698 } 699 700 #[cfg(any(target_os = "linux", target_os = "macos"))] 701 async fn verify_integrity(connection: &mut SqliteConnection) -> Result<(), ServiceSqliteError> { 702 let rows = sqlx::query(crate::persisted_value::INTEGRITY_CHECK_SQL) 703 .fetch_all(&mut *connection) 704 .await 705 .map_err(integrity_source)?; 706 let row = rows 707 .first() 708 .ok_or_else(|| integrity_error(IntegrityFailureKind::Sqlite))?; 709 let value = crate::persisted_value::bounded_integrity_bytes(row); 710 require_verification_integrity_projection(value == Some(b"ok"), rows.len() > 1)?; 711 let violation = sqlx::query_scalar::<_, i64>("SELECT 1 FROM pragma_foreign_key_check LIMIT 1") 712 .fetch_optional(connection) 713 .await 714 .map_err(integrity_source)?; 715 require_integrity_condition(violation.is_none(), IntegrityFailureKind::ForeignKeys)?; 716 Ok(()) 717 } 718 719 #[cfg(any(target_os = "linux", target_os = "macos"))] 720 fn require_verification_metadata_projection(matches: [bool; 9]) -> Result<(), ServiceSqliteError> { 721 crate::require_condition( 722 crate::all_constraints(matches), 723 ServiceSqliteErrorKind::Metadata, 724 ) 725 } 726 727 #[cfg(any(target_os = "linux", target_os = "macos"))] 728 fn require_verification_integrity_projection( 729 value_matches: bool, 730 has_extra: bool, 731 ) -> Result<(), ServiceSqliteError> { 732 crate::all_constraints([value_matches, !has_extra]) 733 .then_some(()) 734 .ok_or_else(|| integrity_error(IntegrityFailureKind::Sqlite)) 735 } 736 737 #[cfg(any(target_os = "linux", target_os = "macos"))] 738 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 739 enum VerificationFailureKind { 740 Manifest, 741 ManifestDigest, 742 Intent, 743 BundleDirectory, 744 Inventory, 745 Permissions, 746 MemberLength, 747 MemberDigest, 748 BindingChanged, 749 } 750 751 #[cfg(any(target_os = "linux", target_os = "macos"))] 752 struct VerificationFailure { 753 kind: VerificationFailureKind, 754 source: Option<Box<dyn Error + Send + Sync + 'static>>, 755 } 756 757 #[cfg(any(target_os = "linux", target_os = "macos"))] 758 impl fmt::Debug for VerificationFailure { 759 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 760 formatter 761 .debug_struct("BackupVerificationFailure") 762 .field("kind", &self.kind) 763 .field("source", &self.source.as_ref().map(|_| "[redacted]")) 764 .finish() 765 } 766 } 767 768 #[cfg(any(target_os = "linux", target_os = "macos"))] 769 impl fmt::Display for VerificationFailure { 770 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 771 formatter.write_str(match self.kind { 772 VerificationFailureKind::Manifest => "backup manifest is invalid", 773 VerificationFailureKind::ManifestDigest => "backup manifest digest does not match", 774 VerificationFailureKind::Intent => "backup intent does not match", 775 VerificationFailureKind::BundleDirectory => "backup bundle directory is invalid", 776 VerificationFailureKind::Inventory => "backup member inventory is invalid", 777 VerificationFailureKind::Permissions => "backup permissions are invalid", 778 VerificationFailureKind::MemberLength => "backup member length is invalid", 779 VerificationFailureKind::MemberDigest => "backup member digest does not match", 780 VerificationFailureKind::BindingChanged => "backup member binding changed", 781 }) 782 } 783 } 784 785 #[cfg(any(target_os = "linux", target_os = "macos"))] 786 impl Error for VerificationFailure { 787 fn source(&self) -> Option<&(dyn Error + 'static)> { 788 self.source 789 .as_deref() 790 .map(|source| source as &(dyn Error + 'static)) 791 } 792 } 793 794 #[cfg(any(target_os = "linux", target_os = "macos"))] 795 fn verification_error(kind: VerificationFailureKind) -> ServiceSqliteError { 796 ServiceSqliteError::with_source( 797 ServiceSqliteErrorKind::Backup, 798 VerificationFailure { kind, source: None }, 799 ) 800 } 801 802 #[cfg(any(target_os = "linux", target_os = "macos"))] 803 fn require_verification_condition( 804 condition: bool, 805 kind: VerificationFailureKind, 806 ) -> Result<(), ServiceSqliteError> { 807 if condition { 808 Ok(()) 809 } else { 810 Err(verification_error(kind)) 811 } 812 } 813 814 #[cfg(any(target_os = "linux", target_os = "macos"))] 815 fn verification_source( 816 kind: VerificationFailureKind, 817 source: impl Error + Send + Sync + 'static, 818 ) -> ServiceSqliteError { 819 ServiceSqliteError::with_source( 820 ServiceSqliteErrorKind::Backup, 821 VerificationFailure { 822 kind, 823 source: Some(Box::new(source)), 824 }, 825 ) 826 } 827 828 #[cfg(any(target_os = "linux", target_os = "macos"))] 829 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 830 enum IntegrityFailureKind { 831 Policy, 832 DatabaseInventory, 833 Sqlite, 834 ForeignKeys, 835 } 836 837 #[cfg(any(target_os = "linux", target_os = "macos"))] 838 #[derive(Debug)] 839 struct IntegrityFailure(IntegrityFailureKind); 840 841 #[cfg(any(target_os = "linux", target_os = "macos"))] 842 impl fmt::Display for IntegrityFailure { 843 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 844 formatter.write_str(match self.0 { 845 IntegrityFailureKind::Policy => "backup SQLite policy is invalid", 846 IntegrityFailureKind::DatabaseInventory => "backup database inventory is invalid", 847 IntegrityFailureKind::Sqlite => "backup SQLite integrity is invalid", 848 IntegrityFailureKind::ForeignKeys => "backup foreign-key integrity is invalid", 849 }) 850 } 851 } 852 853 #[cfg(any(target_os = "linux", target_os = "macos"))] 854 impl Error for IntegrityFailure {} 855 856 #[cfg(any(target_os = "linux", target_os = "macos"))] 857 fn integrity_error(kind: IntegrityFailureKind) -> ServiceSqliteError { 858 ServiceSqliteError::with_source(ServiceSqliteErrorKind::Integrity, IntegrityFailure(kind)) 859 } 860 861 #[cfg(any(target_os = "linux", target_os = "macos"))] 862 fn require_integrity_condition( 863 condition: bool, 864 kind: IntegrityFailureKind, 865 ) -> Result<(), ServiceSqliteError> { 866 if condition { 867 Ok(()) 868 } else { 869 Err(integrity_error(kind)) 870 } 871 } 872 873 #[cfg(any(target_os = "linux", target_os = "macos"))] 874 fn integrity_source(source: sqlx::Error) -> ServiceSqliteError { 875 ServiceSqliteError::with_source(ServiceSqliteErrorKind::Integrity, source) 876 } 877 878 #[cfg(any(target_os = "linux", target_os = "macos"))] 879 fn metadata_error() -> ServiceSqliteError { 880 ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata) 881 } 882 883 #[cfg(any(target_os = "linux", target_os = "macos"))] 884 fn metadata_source(source: sqlx::Error) -> ServiceSqliteError { 885 ServiceSqliteError::with_source(ServiceSqliteErrorKind::Metadata, source) 886 } 887 888 #[cfg(test)] 889 mod tests { 890 #[cfg(any(target_os = "linux", target_os = "macos"))] 891 use { 892 super::*, 893 crate::{ServiceSqliteApplicationId, ServiceSqlitePaths}, 894 radroots_runtime_paths::{ 895 RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, 896 RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, 897 }, 898 std::{ 899 collections::BTreeSet, fs, io::Write, os::unix::fs::PermissionsExt, path::Path, 900 process::Command, 901 }, 902 }; 903 904 #[cfg(any(target_os = "linux", target_os = "macos"))] 905 fn open_test_database(path: &Path) -> SqliteConnection { 906 futures::executor::block_on(SqliteConnection::connect_with( 907 &SqliteConnectOptions::new() 908 .filename(path) 909 .create_if_missing(true) 910 .foreign_keys(false) 911 .disable_statement_logging(), 912 )) 913 .expect("database") 914 } 915 916 #[cfg(any(target_os = "linux", target_os = "macos"))] 917 #[test] 918 fn verification_projection_helpers_reject_every_independent_drift() { 919 let digest = [7_u8; 32]; 920 assert!(require_backup_digests(&digest, &digest, &digest).is_ok()); 921 for changed in 0..3 { 922 let mut values = [digest; 3]; 923 values[changed][0] ^= 1; 924 assert!(require_backup_digests(&values[0], &values[1], &values[2]).is_err()); 925 } 926 927 assert!(require_verification_connection_policy(1, 0).is_ok()); 928 for values in [(0, 0), (1, 1), (0, 1)] { 929 assert!(require_verification_connection_policy(values.0, values.1).is_err()); 930 } 931 932 assert!(require_verification_database_inventory(true, true, false).is_ok()); 933 for (sequence, name, extra) in [ 934 (false, true, false), 935 (true, false, false), 936 (false, false, false), 937 (true, true, true), 938 ] { 939 assert!(require_verification_database_inventory(sequence, name, extra).is_err()); 940 } 941 942 assert!(require_verification_metadata_projection([true; 9]).is_ok()); 943 for changed in 0..9 { 944 let mut matches = [true; 9]; 945 matches[changed] = false; 946 assert!(require_verification_metadata_projection(matches).is_err()); 947 } 948 949 assert!(require_verification_integrity_projection(true, false).is_ok()); 950 for (value_matches, extra) in [(false, false), (true, true)] { 951 assert!(require_verification_integrity_projection(value_matches, extra).is_err()); 952 } 953 } 954 955 #[cfg(any(target_os = "linux", target_os = "macos"))] 956 #[test] 957 fn verification_and_integrity_failure_inventories_are_complete() { 958 let verification_cases = [ 959 ( 960 VerificationFailureKind::Manifest, 961 "backup manifest is invalid", 962 ), 963 ( 964 VerificationFailureKind::ManifestDigest, 965 "backup manifest digest does not match", 966 ), 967 ( 968 VerificationFailureKind::Intent, 969 "backup intent does not match", 970 ), 971 ( 972 VerificationFailureKind::BundleDirectory, 973 "backup bundle directory is invalid", 974 ), 975 ( 976 VerificationFailureKind::Inventory, 977 "backup member inventory is invalid", 978 ), 979 ( 980 VerificationFailureKind::Permissions, 981 "backup permissions are invalid", 982 ), 983 ( 984 VerificationFailureKind::MemberLength, 985 "backup member length is invalid", 986 ), 987 ( 988 VerificationFailureKind::MemberDigest, 989 "backup member digest does not match", 990 ), 991 ( 992 VerificationFailureKind::BindingChanged, 993 "backup member binding changed", 994 ), 995 ]; 996 for (kind, message) in verification_cases { 997 let plain = VerificationFailure { kind, source: None }; 998 assert_eq!(plain.to_string(), message); 999 assert!(plain.source().is_none()); 1000 let sourced = VerificationFailure { 1001 kind, 1002 source: Some(Box::new(std::io::Error::other("private-cause"))), 1003 }; 1004 assert_eq!(sourced.to_string(), message); 1005 assert!(sourced.source().is_some()); 1006 assert!(format!("{sourced:?}").contains("[redacted]")); 1007 assert!(require_verification_condition(true, kind).is_ok()); 1008 assert_eq!( 1009 require_verification_condition(false, kind) 1010 .expect_err("false condition") 1011 .kind(), 1012 ServiceSqliteErrorKind::Backup 1013 ); 1014 } 1015 1016 for (kind, message) in [ 1017 ( 1018 IntegrityFailureKind::Policy, 1019 "backup SQLite policy is invalid", 1020 ), 1021 ( 1022 IntegrityFailureKind::DatabaseInventory, 1023 "backup database inventory is invalid", 1024 ), 1025 ( 1026 IntegrityFailureKind::Sqlite, 1027 "backup SQLite integrity is invalid", 1028 ), 1029 ( 1030 IntegrityFailureKind::ForeignKeys, 1031 "backup foreign-key integrity is invalid", 1032 ), 1033 ] { 1034 let failure = IntegrityFailure(kind); 1035 assert_eq!(failure.to_string(), message); 1036 assert!(failure.source().is_none()); 1037 assert!(format!("{failure:?}").contains(&format!("{kind:?}"))); 1038 assert!(require_integrity_condition(true, kind).is_ok()); 1039 assert_eq!( 1040 require_integrity_condition(false, kind) 1041 .expect_err("false condition") 1042 .kind(), 1043 ServiceSqliteErrorKind::Integrity 1044 ); 1045 } 1046 } 1047 1048 #[cfg(any(target_os = "linux", target_os = "macos"))] 1049 struct Fixture { 1050 _root: tempfile::TempDir, 1051 bundle: PathBuf, 1052 paths: ServiceSqlitePaths, 1053 metadata: ServiceDatabaseMetadata, 1054 identity: ServiceDatabaseIdentity, 1055 manifest: ServiceBackupManifest, 1056 } 1057 1058 #[cfg(any(target_os = "linux", target_os = "macos"))] 1059 impl Fixture { 1060 fn new(bundle_name: &str) -> Self { 1061 let root = tempfile::tempdir().expect("temporary root"); 1062 let paths = paths(root.path(), "myc", "primary"); 1063 let metadata = ServiceDatabaseMetadata::new( 1064 &paths, 1065 SourceGeneration::new([9; 32]).expect("source generation"), 1066 NonZeroU32::new(1).expect("schema"), 1067 1_700_000_000_000, 1068 ServiceSqliteApplicationId::new(0x5244_5351).expect("application ID"), 1069 ) 1070 .expect("metadata"); 1071 let bundle = root.path().join(bundle_name); 1072 fs::create_dir(&bundle).expect("bundle"); 1073 fs::set_permissions(&bundle, fs::Permissions::from_mode(0o700)).expect("bundle mode"); 1074 create_database(&bundle.join(crate::BACKUP_STATE_MEMBER_NAME), &metadata); 1075 let manifest = manifest_for(&bundle, &metadata); 1076 let identity = metadata.identity(); 1077 Self { 1078 _root: root, 1079 bundle, 1080 paths, 1081 metadata, 1082 identity, 1083 manifest, 1084 } 1085 } 1086 1087 fn maximum(&self) -> NonZeroU64 { 1088 NonZeroU64::new(self.manifest.members()[0].byte_length()).expect("member length") 1089 } 1090 1091 fn verify(&self) -> Result<VerifiedServiceBackup, ServiceSqliteError> { 1092 verify_backup_bundle( 1093 self.manifest.canonical_bytes(), 1094 self.manifest.digest(), 1095 &self.bundle, 1096 &self.identity, 1097 self.maximum(), 1098 ) 1099 } 1100 1101 fn refresh_manifest(&mut self) { 1102 self.manifest = manifest_for(&self.bundle, &self.metadata); 1103 } 1104 } 1105 1106 #[cfg(any(target_os = "linux", target_os = "macos"))] 1107 fn paths(root: &Path, service: &str, instance: &str) -> ServiceSqlitePaths { 1108 let context = RuntimeContext::resolve( 1109 &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), 1110 RuntimeContextBootstrap::new( 1111 RadrootsPathProfile::RepoLocal, 1112 Some(root.to_path_buf()), 1113 RuntimeContextSource::BootstrapCli, 1114 RuntimeContextSource::BootstrapCli, 1115 ) 1116 .expect("bootstrap"), 1117 ServiceId::new(service).expect("service"), 1118 InstanceId::new(instance).expect("instance"), 1119 ) 1120 .expect("context"); 1121 ServiceSqlitePaths::from_runtime_context(&context).expect("paths") 1122 } 1123 1124 #[cfg(any(target_os = "linux", target_os = "macos"))] 1125 fn create_database(path: &Path, metadata: &ServiceDatabaseMetadata) { 1126 let mut connection = open_test_database(path); 1127 futures::executor::block_on(async { 1128 sqlx::raw_sql( 1129 "CREATE TABLE radroots_service_metadata ( 1130 singleton INTEGER PRIMARY KEY, 1131 service_id TEXT NOT NULL, 1132 instance_id TEXT NOT NULL, 1133 source_generation BLOB NOT NULL, 1134 state_schema_version INTEGER NOT NULL, 1135 created_at_unix_ms INTEGER NOT NULL 1136 ); 1137 CREATE TABLE verify_probe (value INTEGER NOT NULL); 1138 INSERT INTO verify_probe (value) VALUES (41), (42);", 1139 ) 1140 .execute(&mut connection) 1141 .await 1142 .expect("schema"); 1143 sqlx::query( 1144 "INSERT INTO radroots_service_metadata ( 1145 singleton, service_id, instance_id, source_generation, 1146 state_schema_version, created_at_unix_ms 1147 ) VALUES (1, ?, ?, ?, ?, ?)", 1148 ) 1149 .bind(metadata.service().as_str()) 1150 .bind(metadata.instance().as_str()) 1151 .bind(metadata.source_generation().as_bytes().as_slice()) 1152 .bind(i64::from(metadata.state_schema_version().get())) 1153 .bind(i64::try_from(metadata.created_at_unix_ms()).expect("creation time")) 1154 .execute(&mut connection) 1155 .await 1156 .expect("metadata row"); 1157 let application_id = format!( 1158 "PRAGMA application_id = {}", 1159 metadata.application_id().get() 1160 ); 1161 sqlx::query(sqlx::AssertSqlSafe(application_id.as_str())) 1162 .execute(&mut connection) 1163 .await 1164 .expect("application ID"); 1165 connection.close().await.expect("close fixture"); 1166 }); 1167 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("state mode"); 1168 } 1169 1170 #[cfg(any(target_os = "linux", target_os = "macos"))] 1171 fn manifest_for(bundle: &Path, metadata: &ServiceDatabaseMetadata) -> ServiceBackupManifest { 1172 let bytes = fs::read(bundle.join(crate::BACKUP_STATE_MEMBER_NAME)).expect("state bytes"); 1173 ServiceBackupManifest::from_capture( 1174 metadata, 1175 crate::BackupCreatedAtUnixMs::new(1_700_000_000_100).expect("capture time"), 1176 u64::try_from(bytes.len()).expect("length"), 1177 crate::BackupMemberSha256::from_bytes(Sha256::digest(&bytes).into()), 1178 ) 1179 .expect("manifest") 1180 } 1181 1182 #[cfg(any(target_os = "linux", target_os = "macos"))] 1183 fn digest(bytes: &[u8]) -> BackupManifestSha256 { 1184 BackupManifestSha256::from_bytes(Sha256::digest(bytes).into()) 1185 } 1186 1187 #[cfg(any(target_os = "linux", target_os = "macos"))] 1188 fn inventory(path: &Path) -> BTreeSet<std::ffi::OsString> { 1189 fs::read_dir(path) 1190 .expect("inventory") 1191 .map(|entry| entry.expect("entry").file_name()) 1192 .collect() 1193 } 1194 1195 #[cfg(any(target_os = "linux", target_os = "macos"))] 1196 #[test] 1197 fn verifies_exact_bundle_without_mutating_it_and_redacts_the_capability() { 1198 let fixture = Fixture::new("verified bundle %?#"); 1199 let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1200 let before_bytes = fs::read(&state).expect("before bytes"); 1201 let before_modified = fs::metadata(&state) 1202 .expect("before metadata") 1203 .modified() 1204 .expect("before mtime"); 1205 let before_modes = ( 1206 fs::metadata(&fixture.bundle) 1207 .expect("directory metadata") 1208 .permissions() 1209 .mode() 1210 & 0o777, 1211 fs::metadata(&state) 1212 .expect("state metadata") 1213 .permissions() 1214 .mode() 1215 & 0o777, 1216 ); 1217 let before_inventory = inventory(&fixture.bundle); 1218 1219 let verified = fixture.verify().expect("verified backup"); 1220 assert_eq!(verified.manifest(), &fixture.manifest); 1221 assert_eq!(verified.database_metadata(), &fixture.metadata); 1222 let debug = format!("{verified:?}"); 1223 for forbidden in [ 1224 fixture.bundle.to_string_lossy().as_ref(), 1225 "myc", 1226 "primary", 1227 "52445351", 1228 ] { 1229 assert!(!debug.contains(forbidden)); 1230 } 1231 assert_eq!(fs::read(&state).expect("after bytes"), before_bytes); 1232 assert_eq!( 1233 fs::metadata(&state) 1234 .expect("after metadata") 1235 .modified() 1236 .expect("after mtime"), 1237 before_modified 1238 ); 1239 assert_eq!( 1240 ( 1241 fs::metadata(&fixture.bundle) 1242 .expect("directory metadata") 1243 .permissions() 1244 .mode() 1245 & 0o777, 1246 fs::metadata(&state) 1247 .expect("state metadata") 1248 .permissions() 1249 .mode() 1250 & 0o777, 1251 ), 1252 before_modes 1253 ); 1254 assert_eq!(inventory(&fixture.bundle), before_inventory); 1255 } 1256 1257 #[cfg(any(target_os = "linux", target_os = "macos"))] 1258 #[test] 1259 fn digest_intent_schema_and_member_size_fail_closed() { 1260 let fixture = Fixture::new("intent"); 1261 let wrong_digest = BackupManifestSha256::from_bytes([7; 32]); 1262 assert_eq!( 1263 verify_backup_bundle( 1264 fixture.manifest.canonical_bytes(), 1265 wrong_digest, 1266 &fixture.bundle, 1267 &fixture.identity, 1268 fixture.maximum(), 1269 ) 1270 .expect_err("digest mismatch") 1271 .kind(), 1272 ServiceSqliteErrorKind::Backup 1273 ); 1274 1275 let tampered_time = String::from_utf8(fixture.manifest.canonical_bytes().to_vec()) 1276 .expect("manifest text") 1277 .replace("1700000000100", "1700000000101"); 1278 assert_eq!( 1279 verify_backup_bundle( 1280 tampered_time.as_bytes(), 1281 fixture.manifest.digest(), 1282 &fixture.bundle, 1283 &fixture.identity, 1284 fixture.maximum(), 1285 ) 1286 .expect_err("creation time tamper") 1287 .kind(), 1288 ServiceSqliteErrorKind::Backup 1289 ); 1290 1291 let smaller = NonZeroU64::new(fixture.maximum().get() - 1).expect("smaller limit"); 1292 assert_eq!( 1293 verify_backup_bundle( 1294 fixture.manifest.canonical_bytes(), 1295 fixture.manifest.digest(), 1296 &fixture.bundle, 1297 &fixture.identity, 1298 smaller, 1299 ) 1300 .expect_err("member over caller limit") 1301 .kind(), 1302 ServiceSqliteErrorKind::Backup 1303 ); 1304 1305 let wrong_generation = ServiceDatabaseIdentity::new( 1306 &fixture.paths, 1307 SourceGeneration::new([8; 32]).expect("generation"), 1308 NonZeroU32::new(1).expect("schema"), 1309 fixture.identity.application_id(), 1310 ); 1311 assert_eq!( 1312 verify_backup_bundle( 1313 fixture.manifest.canonical_bytes(), 1314 fixture.manifest.digest(), 1315 &fixture.bundle, 1316 &wrong_generation, 1317 fixture.maximum(), 1318 ) 1319 .expect_err("generation intent") 1320 .kind(), 1321 ServiceSqliteErrorKind::Backup 1322 ); 1323 1324 for (service, instance) in [("rhi", "primary"), ("myc", "secondary")] { 1325 let wrong_paths = paths(fixture._root.path(), service, instance); 1326 let wrong_identity = ServiceDatabaseIdentity::new( 1327 &wrong_paths, 1328 fixture.identity.source_generation(), 1329 fixture.identity.supported_state_schema_version(), 1330 fixture.identity.application_id(), 1331 ); 1332 assert_eq!( 1333 verify_backup_bundle( 1334 fixture.manifest.canonical_bytes(), 1335 fixture.manifest.digest(), 1336 &fixture.bundle, 1337 &wrong_identity, 1338 fixture.maximum(), 1339 ) 1340 .expect_err("service-instance intent") 1341 .kind(), 1342 ServiceSqliteErrorKind::Backup 1343 ); 1344 } 1345 1346 let wrong_application = ServiceDatabaseIdentity::new( 1347 &fixture.paths, 1348 fixture.identity.source_generation(), 1349 NonZeroU32::new(1).expect("schema"), 1350 ServiceSqliteApplicationId::new(7).expect("application"), 1351 ); 1352 assert_eq!( 1353 verify_backup_bundle( 1354 fixture.manifest.canonical_bytes(), 1355 fixture.manifest.digest(), 1356 &fixture.bundle, 1357 &wrong_application, 1358 fixture.maximum(), 1359 ) 1360 .expect_err("application intent") 1361 .kind(), 1362 ServiceSqliteErrorKind::Metadata 1363 ); 1364 1365 let schema_two = String::from_utf8(fixture.manifest.canonical_bytes().to_vec()) 1366 .expect("manifest text") 1367 .replace("\"state_schema_version\":1", "\"state_schema_version\":2"); 1368 let supports_two = ServiceDatabaseIdentity::new( 1369 &fixture.paths, 1370 fixture.identity.source_generation(), 1371 NonZeroU32::new(2).expect("schema"), 1372 fixture.identity.application_id(), 1373 ); 1374 assert_eq!( 1375 verify_backup_bundle( 1376 schema_two.as_bytes(), 1377 digest(schema_two.as_bytes()), 1378 &fixture.bundle, 1379 &supports_two, 1380 fixture.maximum(), 1381 ) 1382 .expect_err("manifest and database schema mismatch") 1383 .kind(), 1384 ServiceSqliteErrorKind::Metadata 1385 ); 1386 1387 let too_large = String::from_utf8(fixture.manifest.canonical_bytes().to_vec()) 1388 .expect("manifest text") 1389 .replace( 1390 &format!("\"byte_length\":{}", fixture.maximum().get()), 1391 "\"byte_length\":9223372036854775808", 1392 ); 1393 assert_eq!( 1394 verify_backup_bundle( 1395 too_large.as_bytes(), 1396 digest(too_large.as_bytes()), 1397 &fixture.bundle, 1398 &fixture.identity, 1399 NonZeroU64::new(u64::MAX).expect("maximum"), 1400 ) 1401 .expect_err("signed representation overflow") 1402 .kind(), 1403 ServiceSqliteErrorKind::Backup 1404 ); 1405 } 1406 1407 #[cfg(any(target_os = "linux", target_os = "macos"))] 1408 #[test] 1409 fn restrictive_read_only_modes_are_allowed_and_broader_modes_reject() { 1410 for (directory_mode, state_mode) in [ 1411 (0o700, 0o600), 1412 (0o700, 0o400), 1413 (0o500, 0o600), 1414 (0o500, 0o400), 1415 ] { 1416 let fixture = Fixture::new("allowed-modes"); 1417 let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1418 fs::set_permissions(&state, fs::Permissions::from_mode(state_mode)) 1419 .expect("state mode"); 1420 fs::set_permissions(&fixture.bundle, fs::Permissions::from_mode(directory_mode)) 1421 .expect("directory mode"); 1422 fixture.verify().expect("restrictive mode verifies"); 1423 } 1424 for directory_mode in [0o400, 0o710, 0o750] { 1425 let fixture = Fixture::new("bad-directory-mode"); 1426 fs::set_permissions(&fixture.bundle, fs::Permissions::from_mode(directory_mode)) 1427 .expect("directory mode"); 1428 assert_eq!( 1429 fixture.verify().expect_err("invalid directory mode").kind(), 1430 ServiceSqliteErrorKind::Backup 1431 ); 1432 } 1433 for state_mode in [0o000, 0o500, 0o640, 0o700] { 1434 let fixture = Fixture::new("bad-state-mode"); 1435 fs::set_permissions( 1436 fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME), 1437 fs::Permissions::from_mode(state_mode), 1438 ) 1439 .expect("state mode"); 1440 assert_eq!( 1441 fixture.verify().expect_err("invalid state mode").kind(), 1442 ServiceSqliteErrorKind::Backup 1443 ); 1444 } 1445 } 1446 1447 #[cfg(any(target_os = "linux", target_os = "macos"))] 1448 #[test] 1449 fn inventory_links_and_same_length_tampering_reject() { 1450 let fixture = Fixture::new("extra"); 1451 fs::write(fixture.bundle.join("state.sqlite-wal"), b"foreign").expect("sidecar"); 1452 assert_eq!( 1453 fixture.verify().expect_err("extra member").kind(), 1454 ServiceSqliteErrorKind::Backup 1455 ); 1456 1457 let fixture = Fixture::new("hardlink"); 1458 fs::hard_link( 1459 fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME), 1460 fixture.bundle.join("other"), 1461 ) 1462 .expect("hard link"); 1463 assert_eq!( 1464 fixture.verify().expect_err("hard-linked member").kind(), 1465 ServiceSqliteErrorKind::Backup 1466 ); 1467 1468 let fixture = Fixture::new("missing"); 1469 fs::remove_file(fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME)) 1470 .expect("remove state"); 1471 assert_eq!( 1472 fixture.verify().expect_err("missing member").kind(), 1473 ServiceSqliteErrorKind::Backup 1474 ); 1475 1476 let fixture = Fixture::new("symlink"); 1477 let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1478 let held = fixture.bundle.join("held-state"); 1479 fs::rename(&state, &held).expect("move state"); 1480 std::os::unix::fs::symlink(&held, &state).expect("symlink state"); 1481 assert_eq!( 1482 fixture.verify().expect_err("symlink member").kind(), 1483 ServiceSqliteErrorKind::Backup 1484 ); 1485 1486 let fixture = Fixture::new("directory-member"); 1487 let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1488 fs::remove_file(&state).expect("remove state"); 1489 fs::create_dir(&state).expect("directory member"); 1490 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)) 1491 .expect("directory member mode"); 1492 assert_eq!( 1493 fixture.verify().expect_err("directory member").kind(), 1494 ServiceSqliteErrorKind::Backup 1495 ); 1496 1497 let fixture = Fixture::new("fifo-member"); 1498 let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1499 fs::remove_file(&state).expect("remove state"); 1500 assert!( 1501 Command::new("mkfifo") 1502 .arg(&state) 1503 .status() 1504 .expect("mkfifo") 1505 .success() 1506 ); 1507 fs::set_permissions(&state, fs::Permissions::from_mode(0o600)).expect("fifo mode"); 1508 assert_eq!( 1509 fixture.verify().expect_err("fifo member").kind(), 1510 ServiceSqliteErrorKind::Backup 1511 ); 1512 1513 let fixture = Fixture::new("tamper"); 1514 let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1515 let mut bytes = fs::read(&state).expect("state"); 1516 let index = bytes.len() - 1; 1517 bytes[index] ^= 1; 1518 fs::write(&state, bytes).expect("same-length tamper"); 1519 fs::set_permissions(&state, fs::Permissions::from_mode(0o600)).expect("state mode"); 1520 assert_eq!( 1521 fixture.verify().expect_err("member digest mismatch").kind(), 1522 ServiceSqliteErrorKind::Backup 1523 ); 1524 } 1525 1526 #[cfg(any(target_os = "linux", target_os = "macos"))] 1527 #[test] 1528 fn bounded_metadata_and_foreign_key_integrity_reject() { 1529 for statement in [ 1530 "UPDATE radroots_service_metadata SET singleton = 2", 1531 "UPDATE radroots_service_metadata SET state_schema_version = 'invalid'", 1532 "UPDATE radroots_service_metadata SET created_at_unix_ms = 'invalid'", 1533 ] { 1534 let mut fixture = Fixture::new("malformed-metadata"); 1535 fixture.verify().expect("valid original bundle"); 1536 let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1537 let mut connection = open_test_database(&state); 1538 futures::executor::block_on(async { 1539 sqlx::query(sqlx::AssertSqlSafe(statement)) 1540 .execute(&mut connection) 1541 .await 1542 .expect("substituted metadata value"); 1543 connection.close().await.expect("close fixture"); 1544 }); 1545 fixture.refresh_manifest(); 1546 assert_eq!( 1547 fixture.verify().expect_err("invalid metadata").kind(), 1548 ServiceSqliteErrorKind::Metadata 1549 ); 1550 } 1551 let mut metadata_fixture = Fixture::new("oversized-metadata"); 1552 let state = metadata_fixture 1553 .bundle 1554 .join(crate::BACKUP_STATE_MEMBER_NAME); 1555 let mut connection = open_test_database(&state); 1556 futures::executor::block_on(async { 1557 sqlx::query("UPDATE radroots_service_metadata SET service_id = ?") 1558 .bind("x".repeat(129)) 1559 .execute(&mut connection) 1560 .await 1561 .expect("oversized metadata"); 1562 connection.close().await.expect("close"); 1563 }); 1564 metadata_fixture.refresh_manifest(); 1565 assert_eq!( 1566 metadata_fixture 1567 .verify() 1568 .expect_err("oversized metadata") 1569 .kind(), 1570 ServiceSqliteErrorKind::Metadata 1571 ); 1572 1573 let mut view_fixture = Fixture::new("metadata-view"); 1574 let state = view_fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1575 let mut connection = open_test_database(&state); 1576 let generation = "09".repeat(32); 1577 futures::executor::block_on(async { 1578 let statement = format!( 1579 "DROP TABLE radroots_service_metadata; 1580 CREATE VIEW radroots_service_metadata AS 1581 SELECT 1582 1 AS singleton, 1583 'myc' AS service_id, 1584 'primary' AS instance_id, 1585 X'{generation}' AS source_generation, 1586 1 AS state_schema_version, 1587 1700000000000 AS created_at_unix_ms;" 1588 ); 1589 sqlx::raw_sql(sqlx::AssertSqlSafe(statement.as_str())) 1590 .execute(&mut connection) 1591 .await 1592 .expect("metadata view"); 1593 connection.close().await.expect("close"); 1594 }); 1595 view_fixture.refresh_manifest(); 1596 assert_eq!( 1597 view_fixture.verify().expect_err("metadata view").kind(), 1598 ServiceSqliteErrorKind::Metadata 1599 ); 1600 1601 let mut foreign_key_fixture = Fixture::new("foreign-key"); 1602 let state = foreign_key_fixture 1603 .bundle 1604 .join(crate::BACKUP_STATE_MEMBER_NAME); 1605 let mut connection = open_test_database(&state); 1606 futures::executor::block_on(async { 1607 sqlx::raw_sql( 1608 "PRAGMA foreign_keys = OFF; 1609 CREATE TABLE parent (id INTEGER PRIMARY KEY); 1610 CREATE TABLE child ( 1611 id INTEGER PRIMARY KEY, 1612 parent_id INTEGER NOT NULL REFERENCES parent(id) 1613 ); 1614 INSERT INTO child (id, parent_id) VALUES (1, 99);", 1615 ) 1616 .execute(&mut connection) 1617 .await 1618 .expect("foreign-key violation"); 1619 connection.close().await.expect("close"); 1620 }); 1621 foreign_key_fixture.refresh_manifest(); 1622 assert_eq!( 1623 foreign_key_fixture 1624 .verify() 1625 .expect_err("foreign-key violation") 1626 .kind(), 1627 ServiceSqliteErrorKind::Integrity 1628 ); 1629 1630 let mut corrupt_fixture = Fixture::new("corrupt-sqlite"); 1631 let state = corrupt_fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1632 let mut connection = open_test_database(&state); 1633 let (page_size, root_page) = futures::executor::block_on(async { 1634 let page_size = sqlx::query_scalar::<_, i64>("PRAGMA page_size") 1635 .fetch_one(&mut connection) 1636 .await 1637 .expect("page size"); 1638 let root_page = sqlx::query_scalar::<_, i64>( 1639 "SELECT rootpage FROM sqlite_schema WHERE name = 'verify_probe'", 1640 ) 1641 .fetch_one(&mut connection) 1642 .await 1643 .expect("probe root page"); 1644 connection.close().await.expect("close"); 1645 (page_size, root_page) 1646 }); 1647 let page_size = u64::try_from(page_size).expect("positive page size"); 1648 let root_page = u64::try_from(root_page).expect("positive root page"); 1649 let corrupt_offset = root_page 1650 .checked_sub(1) 1651 .and_then(|page| page.checked_mul(page_size)) 1652 .expect("corrupt offset"); 1653 let mut state_file = fs::OpenOptions::new() 1654 .write(true) 1655 .open(&state) 1656 .expect("open corrupt state"); 1657 state_file 1658 .seek(SeekFrom::Start(corrupt_offset)) 1659 .expect("seek corrupt page"); 1660 state_file.write_all(&[0xff]).expect("corrupt page type"); 1661 state_file.sync_all().expect("sync corruption"); 1662 drop(state_file); 1663 corrupt_fixture.refresh_manifest(); 1664 assert_eq!( 1665 corrupt_fixture 1666 .verify() 1667 .expect_err("corrupt SQLite with trusted hashes") 1668 .kind(), 1669 ServiceSqliteErrorKind::Integrity 1670 ); 1671 } 1672 1673 #[cfg(any(target_os = "linux", target_os = "macos"))] 1674 #[test] 1675 fn sqlite_connection_policy_drift_fails_closed() { 1676 futures::executor::block_on(async { 1677 let mut connection = SqliteConnection::connect("sqlite::memory:") 1678 .await 1679 .expect("memory database"); 1680 apply_connection_policy(&mut connection) 1681 .await 1682 .expect("governed policy"); 1683 verify_connection_policy(&mut connection) 1684 .await 1685 .expect("policy readback"); 1686 1687 sqlx::query("PRAGMA trusted_schema = ON") 1688 .execute(&mut connection) 1689 .await 1690 .expect("drift trusted schema"); 1691 assert_eq!( 1692 verify_connection_policy(&mut connection) 1693 .await 1694 .expect_err("trusted-schema drift") 1695 .kind(), 1696 ServiceSqliteErrorKind::Integrity 1697 ); 1698 1699 sqlx::query("PRAGMA trusted_schema = OFF") 1700 .execute(&mut connection) 1701 .await 1702 .expect("restore trusted schema"); 1703 sqlx::query("PRAGMA query_only = OFF") 1704 .execute(&mut connection) 1705 .await 1706 .expect("drift query-only"); 1707 assert_eq!( 1708 verify_connection_policy(&mut connection) 1709 .await 1710 .expect_err("query-only drift") 1711 .kind(), 1712 ServiceSqliteErrorKind::Integrity 1713 ); 1714 }); 1715 } 1716 1717 #[cfg(any(target_os = "linux", target_os = "macos"))] 1718 #[test] 1719 fn attached_database_and_replaced_bindings_fail_closed() { 1720 futures::executor::block_on(async { 1721 let mut connection = SqliteConnection::connect("sqlite::memory:") 1722 .await 1723 .expect("memory database"); 1724 sqlx::query("ATTACH DATABASE ':memory:' AS extra") 1725 .execute(&mut connection) 1726 .await 1727 .expect("attach"); 1728 assert_eq!( 1729 verify_database_inventory(&mut connection) 1730 .await 1731 .expect_err("extra attachment") 1732 .kind(), 1733 ServiceSqliteErrorKind::Integrity 1734 ); 1735 }); 1736 1737 let fixture = Fixture::new("replace-directory"); 1738 let binding = VerifiedBundleBinding::open( 1739 &fixture.bundle, 1740 fixture.manifest.members()[0].byte_length(), 1741 ) 1742 .expect("binding"); 1743 let moved = fixture.bundle.with_extension("held"); 1744 fs::rename(&fixture.bundle, &moved).expect("move held directory"); 1745 fs::create_dir(&fixture.bundle).expect("replacement directory"); 1746 fs::set_permissions(&fixture.bundle, fs::Permissions::from_mode(0o700)) 1747 .expect("replacement mode"); 1748 fs::write( 1749 fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME), 1750 b"replacement", 1751 ) 1752 .expect("replacement state"); 1753 fs::set_permissions( 1754 fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME), 1755 fs::Permissions::from_mode(0o600), 1756 ) 1757 .expect("replacement state mode"); 1758 1759 futures::executor::block_on(async { 1760 let mut retained_connection = open_sqlite_from_retained_state(&binding) 1761 .await 1762 .expect("open retained member"); 1763 apply_connection_policy(&mut retained_connection) 1764 .await 1765 .expect("retained connection policy"); 1766 verify_database_inventory(&mut retained_connection) 1767 .await 1768 .expect("retained database inventory"); 1769 assert_eq!( 1770 verify_database_metadata( 1771 &mut retained_connection, 1772 &fixture.manifest, 1773 &fixture.identity, 1774 ) 1775 .await 1776 .expect("retained metadata"), 1777 fixture.metadata 1778 ); 1779 verify_integrity(&mut retained_connection) 1780 .await 1781 .expect("retained integrity"); 1782 retained_connection 1783 .close() 1784 .await 1785 .expect("close retained member"); 1786 }); 1787 1788 assert_eq!( 1789 binding 1790 .validate() 1791 .expect_err("directory replacement") 1792 .kind(), 1793 ServiceSqliteErrorKind::Backup 1794 ); 1795 let retained = fs::read(moved.join(crate::BACKUP_STATE_MEMBER_NAME)) 1796 .expect("retained member through moved directory"); 1797 let replacement = fs::read(fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME)) 1798 .expect("replacement member"); 1799 assert_ne!(retained, replacement); 1800 assert_eq!(replacement, b"replacement"); 1801 1802 let fixture = Fixture::new("replace-member"); 1803 let binding = VerifiedBundleBinding::open( 1804 &fixture.bundle, 1805 fixture.manifest.members()[0].byte_length(), 1806 ) 1807 .expect("binding"); 1808 let state = fixture.bundle.join(crate::BACKUP_STATE_MEMBER_NAME); 1809 let moved = fixture.bundle.join("held-state"); 1810 fs::rename(&state, &moved).expect("move held member"); 1811 fs::copy(&moved, &state).expect("replacement member"); 1812 fs::set_permissions(&state, fs::Permissions::from_mode(0o600)).expect("replacement mode"); 1813 assert_eq!( 1814 binding.validate().expect_err("member replacement").kind(), 1815 ServiceSqliteErrorKind::Backup 1816 ); 1817 assert_eq!( 1818 fs::read(&moved).expect("held member"), 1819 fs::read(&state).expect("replacement") 1820 ); 1821 1822 fs::remove_file(&state).expect("remove replacement"); 1823 assert!( 1824 Command::new("mkfifo") 1825 .arg(&state) 1826 .status() 1827 .expect("mkfifo") 1828 .success() 1829 ); 1830 fs::set_permissions(&state, fs::Permissions::from_mode(0o600)).expect("fifo mode"); 1831 assert_eq!( 1832 binding.validate().expect_err("replacement fifo").kind(), 1833 ServiceSqliteErrorKind::Backup 1834 ); 1835 } 1836 1837 #[cfg(any(target_os = "linux", target_os = "macos"))] 1838 #[test] 1839 fn public_diagnostics_do_not_render_untrusted_paths_or_contents() { 1840 let fixture = Fixture::new("secret-path-value"); 1841 fs::write(fixture.bundle.join("secret-extra"), b"secret-content").expect("extra"); 1842 let error = fixture.verify().expect_err("invalid inventory"); 1843 assert_eq!(error.kind(), ServiceSqliteErrorKind::Backup); 1844 for rendered in [error.to_string(), format!("{error:?}")] { 1845 assert!(!rendered.contains("secret-path-value")); 1846 assert!(!rendered.contains("secret-extra")); 1847 assert!(!rendered.contains("secret-content")); 1848 } 1849 } 1850 }