commit 2f306f1b8fb43bdab969745f3d4c75de2e9bc028 parent 5d53eb8da344e92453c032609ebef3add0b33fc5 Author: triesap <tyson@radroots.org> Date: Thu, 6 Aug 2026 11:05:16 +0000 studio-security: harden data and ffi boundaries - validate canonical identities and quarantine invalid persisted data - authenticate backup repair and rollback with retained evidence - zeroize secret inputs and reject symlink based storage attacks - generate ffi contracts and bound observer lifecycle failures Diffstat:
40 files changed, 2043 insertions(+), 192 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock @@ -3975,6 +3975,7 @@ name = "radroots_studio_domain" version = "0.1.0-alpha" dependencies = [ "bech32", + "radroots_identity", "secrecy", "url", "zeroize", @@ -3988,11 +3989,14 @@ dependencies = [ "nostr 0.44.1", "nostr-relay-builder", "nostr-sdk 0.44.0", + "quote", "radroots_studio_application", "radroots_studio_domain", "radroots_studio_nostr", "radroots_studio_runtime", "radroots_studio_storage", + "sha2", + "syn 2.0.117", "tempfile", "tokio", "uniffi 0.32.0", @@ -4041,11 +4045,15 @@ name = "radroots_studio_storage" version = "0.1.0-alpha" dependencies = [ "fs2", + "getrandom 0.2.17", + "hmac", "keyring 4.1.6", "radroots_studio_application", "radroots_studio_domain", "refinery", "rusqlite", + "rustix 1.1.4", + "sha2", "tempfile", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml @@ -201,6 +201,7 @@ flate2 = { version = "1" } fs2 = { version = "0.4" } getrandom = { version = "0.2", default-features = false } hkdf = { version = "0.12", default-features = false } +hmac = { version = "0.12", default-features = false } hex = { version = "0.4" } jiff-tzdb = { version = "=0.1.8", default-features = false } jsonschema = { version = "0.48.1", default-features = false } diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -1328,7 +1328,14 @@ mod tests { Some(first) ); let missing = core - .select_account(PublicKey::from_bytes([0xff; 32]), &accounts, &accounts) + .select_account( + PublicKey::from_hex( + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + ) + .expect("unknown public key"), + &accounts, + &accounts, + ) .expect_err("missing account"); assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); assert_eq!(core.snapshot(), selected); diff --git a/crates/studio_application/src/actor.rs b/crates/studio_application/src/actor.rs @@ -572,9 +572,7 @@ mod tests { use std::num::NonZeroUsize; use std::time::{Duration, Instant}; - use radroots_studio_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, - }; + use radroots_studio_domain::{AccountIdentity, BindingAvailability, LocalSignerBinding}; use crate::{ ActorMailbox, CommandContext, CommandReceipt, CommandRejection, CommandResult, @@ -592,7 +590,7 @@ mod tests { #[test] fn foreground_session_requires_matching_available_binding_and_generation() { - let public_key = PublicKey::from_bytes([3_u8; 32]); + let public_key = crate::test_support::valid_test_public_key(3).expect("valid public key"); let identity = AccountIdentity::derive(public_key).expect("identity"); let generation = SessionGeneration::from_value(4); let session = ForegroundSessionBinding::new( @@ -622,7 +620,10 @@ mod tests { ForegroundSessionBinding::new( identity.clone(), LocalSignerBinding::new( - PublicKey::from_bytes([4_u8; 32]), + radroots_studio_domain::PublicKey::from_hex( + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + ) + .expect("different valid public key"), BindingAvailability::Available, ), generation, diff --git a/crates/studio_application/src/app_core.rs b/crates/studio_application/src/app_core.rs @@ -263,7 +263,7 @@ const fn account_not_found() -> SafeError { mod tests { use radroots_studio_domain::{ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, UnixTimestamp, + UnixTimestamp, }; use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition}; @@ -293,7 +293,7 @@ mod tests { #[test] fn removal_impact_matches_missing_local_binding() { let core = AppCore::in_memory(RelayConfiguration::default()); - let public_key = PublicKey::from_bytes([9; 32]); + let public_key = crate::test_support::valid_test_public_key(9).expect("valid public key"); let account = AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), diff --git a/crates/studio_application/src/ports.rs b/crates/studio_application/src/ports.rs @@ -722,7 +722,7 @@ mod tests { let request = DurableRequestId::parse("create:desktop:0001").expect("request id"); let receipt = DurableOperationReceipt::new( request.clone(), - PublicKey::from_bytes([3; 32]), + PublicKey::from_bytes([7; 32]).expect("valid public key"), DurableTerminalOutcome::Completed, Some(42), ); @@ -874,11 +874,13 @@ mod tests { let ports = FakePorts::default(); ports - .save_selected_account(Some(PublicKey::from_bytes([1_u8; 32]))) + .save_selected_account(Some( + PublicKey::from_bytes([7_u8; 32]).expect("valid public key"), + )) .expect("save selection"); assert_eq!( ports.load_selected_account().expect("load selection"), - Some(PublicKey::from_bytes([1_u8; 32])) + Some(PublicKey::from_bytes([7_u8; 32]).expect("valid public key")) ); assert_eq!(ports.now().as_seconds(), 1); } diff --git a/crates/studio_application/src/secrets.rs b/crates/studio_application/src/secrets.rs @@ -211,7 +211,7 @@ mod tests { #[test] fn secret_store_puts_loads_checks_and_deletes_redacted_credentials() { let store = InMemorySecretStore::default(); - let public_key = PublicKey::from_bytes([1; 32]); + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); assert!(!store.contains(public_key).expect("contains")); store .put( @@ -229,7 +229,7 @@ mod tests { #[test] fn secret_store_rejects_duplicates_and_reports_missing_credentials() { let store = InMemorySecretStore::default(); - let public_key = PublicKey::from_bytes([2; 32]); + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); let Err(missing) = store.load(public_key) else { panic!("missing credential was returned"); }; @@ -255,7 +255,7 @@ mod tests { #[test] fn failure_secret_store_injects_each_boundary_without_mutating_state() { let store = FailureSecretStore::default(); - let public_key = PublicKey::from_bytes([3; 32]); + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); store.fail_next(SecretStoreOperation::Put); let error = store .put( @@ -293,7 +293,7 @@ mod tests { #[test] fn failure_secret_store_call_log_contains_only_public_identity() { let store = FailureSecretStore::default(); - let public_key = PublicKey::from_bytes([4; 32]); + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); store .put( public_key, diff --git a/crates/studio_application/src/snapshot.rs b/crates/studio_application/src/snapshot.rs @@ -296,7 +296,7 @@ const fn invalid_snapshot() -> SafeError { mod tests { use radroots_studio_domain::{ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, RelayDestinationPolicy, RelayUrl, SafeErrorCode, UnixTimestamp, + RelayDestinationPolicy, RelayUrl, SafeErrorCode, UnixTimestamp, }; use super::{ @@ -305,7 +305,8 @@ mod tests { }; fn account(key_byte: u8) -> AccountSummary { - let public_key = PublicKey::from_bytes([key_byte; 32]); + let public_key = + crate::test_support::valid_test_public_key(key_byte).expect("valid public key"); AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), LocalSignerBinding::new(public_key, BindingAvailability::Available), diff --git a/crates/studio_application/src/state_machine.rs b/crates/studio_application/src/state_machine.rs @@ -358,7 +358,7 @@ const fn account_not_found() -> SafeError { mod tests { use radroots_studio_domain::{ AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, + SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, }; use crate::{ @@ -367,7 +367,8 @@ mod tests { }; fn account(key_byte: u8) -> AccountSummary { - let public_key = PublicKey::from_bytes([key_byte; 32]); + let public_key = + crate::test_support::valid_test_public_key(key_byte).expect("valid public key"); AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), LocalSignerBinding::new(public_key, BindingAvailability::Available), @@ -477,7 +478,9 @@ mod tests { let error = machine .apply( - StateTransition::Select(PublicKey::from_bytes([9_u8; 32])), + StateTransition::Select( + crate::test_support::valid_test_public_key(9).expect("valid public key"), + ), &relays, ) .expect_err("missing account"); diff --git a/crates/studio_application/src/test_support.rs b/crates/studio_application/src/test_support.rs @@ -13,8 +13,12 @@ pub(crate) struct TestKeyMaterialProvider { impl KeyMaterialProvider for TestKeyMaterialProvider { fn generate(&self) -> Result<GeneratedKeyMaterial, SafeError> { - let candidate = self.next.fetch_add(1, Ordering::Relaxed).wrapping_add(9); - let public_key = PublicKey::from_bytes([candidate; 32]); + let public_key = (0..=u8::MAX) + .find_map(|_| { + let candidate = self.next.fetch_add(1, Ordering::Relaxed).wrapping_add(9); + PublicKey::from_bytes([candidate; 32]).ok() + }) + .ok_or_else(invalid_secret_key)?; let secret_byte = public_key.as_bytes()[0]; Ok(GeneratedKeyMaterial::new( public_key, @@ -31,7 +35,7 @@ impl KeyMaterialProvider for TestKeyMaterialProvider { if input.with_exposed_secret(|value| value.starts_with("nsec1qq")) { return Err(invalid_secret_key()); } - let public_key = PublicKey::from_bytes([discriminator; 32]); + let public_key = valid_test_public_key(discriminator)?; Ok(ImportedKeyMaterial::new( public_key, Npub::derive(public_key)?, @@ -40,6 +44,12 @@ impl KeyMaterialProvider for TestKeyMaterialProvider { } } +pub(crate) fn valid_test_public_key(discriminator: u8) -> Result<PublicKey, SafeError> { + (0..=u8::MAX) + .find_map(|offset| PublicKey::from_bytes([discriminator.wrapping_add(offset); 32]).ok()) + .ok_or_else(invalid_secret_key) +} + const fn invalid_secret_key() -> SafeError { SafeError::new( SafeErrorCode::InvalidSecretKey, diff --git a/crates/studio_application/tests/redaction.rs b/crates/studio_application/tests/redaction.rs @@ -17,9 +17,10 @@ fn assert_redacted(text: &str) { #[test] fn redaction_guards_public_snapshot_and_safe_error_debug() { let account = AccountSummary::new( - AccountIdentity::derive(PublicKey::from_bytes([2; 32])).expect("identity"), + AccountIdentity::derive(PublicKey::from_bytes([7; 32]).expect("valid public key")) + .expect("identity"), LocalSignerBinding::new( - PublicKey::from_bytes([2; 32]), + PublicKey::from_bytes([7; 32]).expect("valid public key"), BindingAvailability::Available, ), None, diff --git a/crates/studio_domain/Cargo.toml b/crates/studio_domain/Cargo.toml @@ -13,6 +13,7 @@ include = ["src/**", "Cargo.toml"] [dependencies] bech32 = "=0.11.1" +radroots_identity.workspace = true secrecy = "=0.10.3" url = "=2.5.8" zeroize = "=1.9.0" diff --git a/crates/studio_domain/src/account.rs b/crates/studio_domain/src/account.rs @@ -311,8 +311,12 @@ mod tests { const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"; const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + fn public_key() -> PublicKey { + PublicKey::from_bytes([7_u8; 32]).expect("valid public key") + } + fn account(label: Option<AccountLabel>) -> AccountSummary { - let public_key = PublicKey::from_bytes([7_u8; 32]); + let public_key = public_key(); AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), LocalSignerBinding::new(public_key, BindingAvailability::Available), @@ -354,7 +358,7 @@ mod tests { assert!(account.label().is_none()); assert!(account.last_used_at().is_none()); assert_eq!(account.created_at().timestamp().as_seconds(), 10); - assert_eq!(account.public_key(), PublicKey::from_bytes([7_u8; 32])); + assert_eq!(account.public_key(), public_key()); assert_eq!(account.npub().as_str(), DERIVED_NPUB); assert!(!debug.contains("nsec1")); assert!(!debug.contains(&"11".repeat(32))); @@ -362,7 +366,7 @@ mod tests { #[test] fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() { - let public_key = PublicKey::from_bytes([7_u8; 32]); + let public_key = public_key(); let identity = AccountIdentity::derive(public_key).expect("identity"); assert_eq!(identity.public_key(), public_key); assert_eq!(identity.npub().as_str(), DERIVED_NPUB); @@ -373,7 +377,10 @@ mod tests { assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err()); assert!( AccountIdentity::verify( - PublicKey::from_bytes([8_u8; 32]), + PublicKey::from_hex( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + ) + .expect("second public key"), MISMATCHED_NPUB.to_owned() ) .is_err() @@ -382,7 +389,7 @@ mod tests { #[test] fn local_signer_binding_carries_only_canonical_account_identity() { - let public_key = PublicKey::from_bytes([9_u8; 32]); + let public_key = public_key(); let identity = AccountIdentity::derive(public_key).expect("identity"); let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); @@ -392,7 +399,7 @@ mod tests { #[test] fn local_binding_repair_transitions_are_typed_and_fail_closed() { - let public_key = PublicKey::from_bytes([9_u8; 32]); + let public_key = public_key(); let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); assert_eq!(binding.repair_action(), None); assert!(binding.repair_credential().is_err()); diff --git a/crates/studio_domain/src/error.rs b/crates/studio_domain/src/error.rs @@ -2,7 +2,6 @@ use std::error::Error; use std::fmt::{self, Debug, Display, Formatter}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] pub enum SafeErrorCode { InvalidPublicKey, InvalidSecretKey, @@ -15,6 +14,10 @@ pub enum SafeErrorCode { CredentialMissing, StorageUnavailable, StorageCorrupt, + StorageQuarantined, + StorageBackupInvalid, + UnsupportedSchemaVersion, + RepairUnauthorized, PendingOperationRecoveryRequired, InvalidRelayConfiguration, RelayConnectionFailed, diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs @@ -135,6 +135,7 @@ impl SecretKeyInput { /// Returns a safe invalid-secret-key error when the input is neither an /// nsec-looking value nor exactly 64 lowercase hexadecimal characters. pub fn parse(value: String) -> Result<Self, SafeError> { + let mut value = Zeroizing::new(value); let kind = if value.len() == PUBLIC_KEY_HEX_LENGTH && value .bytes() @@ -148,7 +149,7 @@ impl SecretKeyInput { }; Ok(Self { - value: SecretString::from(value), + value: SecretString::from(std::mem::take(&mut *value)), kind, }) } @@ -164,12 +165,27 @@ impl SecretKeyInput { } #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct PublicKey([u8; PUBLIC_KEY_BYTE_LENGTH]); +pub struct PublicKey(radroots_identity::PublicKey); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PersistedPublicKeyClassification { + Canonical(PublicKey), + NonCanonicalEncoding, + InvalidCurvePoint, + MalformedEncoding, +} impl PublicKey { - #[must_use] - pub const fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self { - Self(bytes) + /// Validates canonical x-only secp256k1 public-key bytes. + /// + /// # Errors + /// + /// Returns a safe invalid-public-key error when the bytes are not a valid + /// x-only secp256k1 point. + pub fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Result<Self, SafeError> { + radroots_identity::PublicKey::from_bytes(bytes) + .map(Self) + .map_err(|_| invalid_public_key()) } /// Parses a canonical lowercase hexadecimal Nostr public key. @@ -187,29 +203,29 @@ impl PublicKey { return Err(invalid_public_key()); } - let mut bytes = [0_u8; PUBLIC_KEY_BYTE_LENGTH]; - for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { - let high = decode_hex_digit(pair[0]).ok_or_else(invalid_public_key)?; - let low = decode_hex_digit(pair[1]).ok_or_else(invalid_public_key)?; - bytes[index] = (high << 4) | low; - } - Ok(Self(bytes)) + radroots_identity::PublicKey::from_hex(value) + .map(Self) + .map_err(|_| invalid_public_key()) } #[must_use] pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] { - &self.0 + self.0.as_bytes() + } + + #[must_use] + pub const fn canonical(self) -> radroots_identity::PublicKey { + self.0 + } + + #[must_use] + pub const fn from_canonical(public_key: radroots_identity::PublicKey) -> Self { + Self(public_key) } #[must_use] pub fn to_hex(self) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(PUBLIC_KEY_HEX_LENGTH); - for byte in self.0 { - output.push(char::from(HEX[usize::from(byte >> 4)])); - output.push(char::from(HEX[usize::from(byte & 0x0f)])); - } - output + self.0.to_hex() } #[must_use] @@ -225,9 +241,15 @@ impl Display for PublicKey { } } -impl From<[u8; PUBLIC_KEY_BYTE_LENGTH]> for PublicKey { - fn from(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self { - Self::from_bytes(bytes) +impl From<radroots_identity::PublicKey> for PublicKey { + fn from(value: radroots_identity::PublicKey) -> Self { + Self::from_canonical(value) + } +} + +impl From<PublicKey> for radroots_identity::PublicKey { + fn from(value: PublicKey) -> Self { + value.canonical() } } @@ -253,11 +275,20 @@ const fn invalid_secret_key() -> SafeError { ) } -const fn decode_hex_digit(byte: u8) -> Option<u8> { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - _ => None, +#[must_use] +pub fn classify_persisted_public_key(value: &str) -> PersistedPublicKeyClassification { + if value.len() != PUBLIC_KEY_HEX_LENGTH || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return PersistedPublicKeyClassification::MalformedEncoding; + } + if !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return PersistedPublicKeyClassification::NonCanonicalEncoding; + } + match PublicKey::from_hex(value) { + Ok(public_key) => PersistedPublicKeyClassification::Canonical(public_key), + Err(_) => PersistedPublicKeyClassification::InvalidCurvePoint, } } @@ -274,8 +305,9 @@ mod tests { use std::str::FromStr; use super::{ - MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput, - SecretKeyInputKind, + MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, + PersistedPublicKeyClassification, PublicKey, SecretKeyInput, SecretKeyInputKind, + classify_persisted_public_key, }; use crate::SafeErrorCode; @@ -290,7 +322,10 @@ mod tests { assert_eq!(key.to_hex(), HEX); assert_eq!(key.to_string(), HEX); assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7"); - assert_eq!(PublicKey::from_bytes(*key.as_bytes()), key); + assert_eq!( + PublicKey::from_bytes(*key.as_bytes()).expect("valid bytes"), + key + ); assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH); } @@ -302,6 +337,7 @@ mod tests { "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7", "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + "00e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", ] { let error = PublicKey::from_hex(value).expect_err("invalid public key"); assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey); @@ -310,13 +346,37 @@ mod tests { #[test] fn public_keys_are_ordered_by_canonical_bytes() { - let low = PublicKey::from_bytes([0_u8; PUBLIC_KEY_BYTE_LENGTH]); - let high = PublicKey::from_bytes([1_u8; PUBLIC_KEY_BYTE_LENGTH]); + let low = + PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") + .expect("low key"); + let high = + PublicKey::from_hex("e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af") + .expect("high key"); assert!(low < high); } #[test] + fn persisted_public_key_classification_is_explicit_and_fail_closed() { + assert!(matches!( + classify_persisted_public_key(HEX), + PersistedPublicKeyClassification::Canonical(_) + )); + assert_eq!( + classify_persisted_public_key(HEX.to_ascii_uppercase().as_str()), + PersistedPublicKeyClassification::NonCanonicalEncoding + ); + assert_eq!( + classify_persisted_public_key(&"00".repeat(PUBLIC_KEY_BYTE_LENGTH)), + PersistedPublicKeyClassification::InvalidCurvePoint + ); + assert_eq!( + classify_persisted_public_key("not-a-public-key"), + PersistedPublicKeyClassification::MalformedEncoding + ); + } + + #[test] fn secret_input_is_redacted_and_exposed_only_to_a_scoped_operation() { let secret = "11".repeat(PUBLIC_KEY_BYTE_LENGTH); let input = SecretKeyInput::parse(secret.clone()).expect("valid secret hex"); diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs @@ -13,7 +13,8 @@ pub use account::{ }; pub use error::{SafeError, SafeErrorCode, SafeMessage}; pub use key::{ - MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind, + MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PersistedPublicKeyClassification, PublicKey, + SecretKeyInput, SecretKeyInputKind, classify_persisted_public_key, }; pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; pub use relay::{RelayDestinationPolicy, RelayUrl, normalize_relay_urls}; diff --git a/crates/studio_domain/src/profile.rs b/crates/studio_domain/src/profile.rs @@ -233,7 +233,7 @@ mod tests { fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate { Kind0ProfileCandidate::new( EventId::from_bytes([id_byte; 32]), - PublicKey::from_bytes([9_u8; 32]), + PublicKey::from_bytes([7_u8; 32]).expect("valid public key"), UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), profile(name), ) @@ -280,7 +280,10 @@ mod tests { assert_eq!(selected.metadata().name(), Some("low-id")); assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]); - assert_eq!(selected.author(), PublicKey::from_bytes([9_u8; 32])); + assert_eq!( + selected.author(), + PublicKey::from_bytes([7_u8; 32]).expect("valid public key") + ); assert_eq!(selected.created_at().as_seconds(), 20); } diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_ffi/Cargo.toml @@ -9,7 +9,8 @@ license = "GPL-3.0-only" repository.workspace = true homepage.workspace = true publish = false -include = ["src/**", "uniffi.toml", "Cargo.toml"] +build = "build.rs" +include = ["build.rs", "src/**", "uniffi.toml", "Cargo.toml"] [lib] crate-type = ["cdylib", "rlib"] @@ -24,6 +25,11 @@ radroots_studio_storage.workspace = true tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } uniffi = "=0.32.0" +[build-dependencies] +quote.workspace = true +sha2.workspace = true +syn.workspace = true + [dev-dependencies] nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } diff --git a/crates/studio_ffi/build.rs b/crates/studio_ffi/build.rs @@ -0,0 +1,95 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use quote::ToTokens; +use sha2::{Digest, Sha256}; +use syn::{ImplItem, Item, Visibility}; + +const CONTRACT_SOURCES: &[&str] = &[ + "src/commands.rs", + "src/contract.rs", + "src/dto.rs", + "src/lib.rs", + "src/observer.rs", +]; + +fn main() { + for source in CONTRACT_SOURCES { + println!("cargo:rerun-if-changed={source}"); + } + println!("cargo:rerun-if-changed=../studio_storage/migrations"); + + let mut metadata = Vec::new(); + for source in CONTRACT_SOURCES { + collect_public_metadata(Path::new(source), &mut metadata); + } + let mut migrations = fs::read_dir("../studio_storage/migrations") + .expect("read Studio migration catalog") + .map(|entry| entry.expect("read migration entry").path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "sql")) + .collect::<Vec<_>>(); + migrations.sort(); + for migration in migrations { + metadata.push(format!( + "migration:{}:{}", + migration + .file_name() + .expect("migration filename") + .to_string_lossy(), + hex_digest(&fs::read(&migration).expect("read migration")) + )); + } + metadata.sort(); + metadata.dedup(); + let normalized = metadata.join("\n"); + println!( + "cargo:rustc-env=RADROOTS_STUDIO_FFI_CONTRACT_DIGEST={}", + hex_digest(normalized.as_bytes()) + ); + fs::write( + PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")) + .join("ffi_contract_metadata.txt"), + normalized, + ) + .expect("write normalized FFI metadata"); +} + +fn collect_public_metadata(path: &Path, output: &mut Vec<String>) { + let source = fs::read_to_string(path).expect("read FFI source"); + let file = syn::parse_file(&source).expect("parse FFI source"); + for item in file.items { + match item { + Item::Const(item) if is_public(&item.vis) => push_tokens("const", item, output), + Item::Enum(item) if is_public(&item.vis) => push_tokens("enum", item, output), + Item::Fn(item) if is_public(&item.vis) => push_tokens("fn", item.sig, output), + Item::Struct(item) if is_public(&item.vis) => push_tokens("struct", item, output), + Item::Trait(item) if is_public(&item.vis) => push_tokens("trait", item, output), + Item::Impl(item) => { + let owner = item.self_ty.to_token_stream().to_string(); + for member in item.items { + if let ImplItem::Fn(function) = member + && is_public(&function.vis) + { + output.push(format!("method:{owner}:{}", function.sig.to_token_stream())); + } + } + } + _ => {} + } + } +} + +fn push_tokens(kind: &str, value: impl ToTokens, output: &mut Vec<String>) { + output.push(format!("{kind}:{}", value.to_token_stream())); +} + +const fn is_public(visibility: &Visibility) -> bool { + matches!(visibility, Visibility::Public(_)) +} + +fn hex_digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -20,6 +20,10 @@ use radroots_studio_storage::OsKeyringSecretStore; use crate::{ AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, + contract::{ + FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION, + PRODUCT_VERSION, + }, dto::error_policy, }; @@ -29,9 +33,6 @@ const DATABASE_APPLICATION: &str = "studio"; const DATABASE_FILENAME: &str = "studio.sqlite3"; const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR"; pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64; -pub const FFI_CONTRACT_MAJOR: u16 = 2; -pub const FFI_CONTRACT_MINOR: u16 = 0; -pub const FFI_CONTRACT_HASH: &str = "radroots-studio-native-v2-2026-08-03"; const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000; #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] @@ -50,6 +51,8 @@ pub struct AccountCommandReceiptDto { #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct CompatibilityDescriptor { + pub product_version: String, + pub cargo_package_version: String, pub contract_major: u16, pub contract_minor: u16, pub contract_hash: String, @@ -69,10 +72,12 @@ pub struct CompatibilityExpectation { #[uniffi::export] pub fn compatibility_descriptor() -> CompatibilityDescriptor { CompatibilityDescriptor { + product_version: PRODUCT_VERSION.to_owned(), + cargo_package_version: env!("CARGO_PKG_VERSION").to_owned(), contract_major: FFI_CONTRACT_MAJOR, contract_minor: FFI_CONTRACT_MINOR, contract_hash: FFI_CONTRACT_HASH.to_owned(), - minimum_schema_version: 5, + minimum_schema_version: MINIMUM_SCHEMA_VERSION, current_schema_version: radroots_studio_storage::CURRENT_SCHEMA_VERSION, } } @@ -187,7 +192,10 @@ impl RemovalRequest { pub(crate) struct RuntimeCore { pub(crate) actor: RuntimeActorHandle, pub(crate) observers: Mutex< - BTreeMap<radroots_studio_application::ChangeSubscriptionId, tokio::task::JoinHandle<()>>, + BTreeMap< + radroots_studio_application::ChangeSubscriptionId, + Option<tokio::task::JoinHandle<()>>, + >, >, pub(crate) closed: AtomicBool, pub(crate) startup_relay_problem: Option<SafeError>, diff --git a/crates/studio_ffi/src/contract.rs b/crates/studio_ffi/src/contract.rs @@ -0,0 +1,9 @@ +pub const PRODUCT_VERSION: &str = "0.1.0-alpha"; +pub const FFI_CONTRACT_MAJOR: u16 = 3; +pub const FFI_CONTRACT_MINOR: u16 = 0; +pub const MINIMUM_SCHEMA_VERSION: u32 = 5; +pub const FFI_CONTRACT_HASH: &str = env!("RADROOTS_STUDIO_FFI_CONTRACT_DIGEST"); + +#[cfg(test)] +pub(crate) const NORMALIZED_CONTRACT_METADATA: &str = + include_str!(concat!(env!("OUT_DIR"), "/ffi_contract_metadata.txt")); diff --git a/crates/studio_ffi/src/dto.rs b/crates/studio_ffi/src/dto.rs @@ -19,6 +19,10 @@ pub enum WireErrorCode { CredentialMissing, StorageUnavailable, StorageCorrupt, + StorageQuarantined, + StorageBackupInvalid, + UnsupportedSchemaVersion, + RepairUnauthorized, PendingOperationRecoveryRequired, InvalidRelayConfiguration, RelayConnectionFailed, @@ -46,6 +50,9 @@ pub enum WireRecoveryAction { None, Retry, RepairCredential, + Authenticate, + RepairStorage, + RestoreBackup, CheckConfiguration, RestartApplication, UpdateApplication, @@ -295,6 +302,10 @@ impl From<SafeErrorCode> for WireErrorCode { SafeErrorCode::CredentialMissing => Self::CredentialMissing, SafeErrorCode::StorageUnavailable => Self::StorageUnavailable, SafeErrorCode::StorageCorrupt => Self::StorageCorrupt, + SafeErrorCode::StorageQuarantined => Self::StorageQuarantined, + SafeErrorCode::StorageBackupInvalid => Self::StorageBackupInvalid, + SafeErrorCode::UnsupportedSchemaVersion => Self::UnsupportedSchemaVersion, + SafeErrorCode::RepairUnauthorized => Self::RepairUnauthorized, SafeErrorCode::PendingOperationRecoveryRequired => { Self::PendingOperationRecoveryRequired } @@ -303,7 +314,6 @@ impl From<SafeErrorCode> for WireErrorCode { SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed, SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed, SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed, - _ => Self::Internal, } } } @@ -341,6 +351,26 @@ pub(crate) const fn error_policy( false, WireRecoveryAction::RestartApplication, ), + SafeErrorCode::StorageQuarantined => ( + WireErrorCategory::Storage, + false, + WireRecoveryAction::RepairStorage, + ), + SafeErrorCode::StorageBackupInvalid => ( + WireErrorCategory::Storage, + false, + WireRecoveryAction::RestoreBackup, + ), + SafeErrorCode::UnsupportedSchemaVersion => ( + WireErrorCategory::Compatibility, + false, + WireRecoveryAction::UpdateApplication, + ), + SafeErrorCode::RepairUnauthorized => ( + WireErrorCategory::Credential, + false, + WireRecoveryAction::Authenticate, + ), SafeErrorCode::InvalidRelayConfiguration => ( WireErrorCategory::Network, false, @@ -359,7 +389,6 @@ pub(crate) const fn error_policy( false, WireRecoveryAction::RestartApplication, ), - _ => (WireErrorCategory::Internal, false, WireRecoveryAction::None), } } @@ -404,7 +433,9 @@ mod tests { use radroots_studio_application::{AppCore, RelayConfiguration}; use radroots_studio_nostr::NostrKeyMaterialProvider; - use super::AppSnapshotDto; + use radroots_studio_domain::{SafeErrorCode, SafeMessage}; + + use super::{AppSnapshotDto, SafeErrorDto, WireErrorCode, WireRecoveryAction}; #[test] fn snapshot_dto_is_revisioned_public_and_secret_free() { @@ -422,4 +453,38 @@ mod tests { assert!(!debug.contains("secret_key")); assert!(!debug.contains("server_url")); } + + #[test] + fn security_errors_have_explicit_stable_wire_mappings() { + for (code, expected_code, expected_recovery) in [ + ( + SafeErrorCode::StorageQuarantined, + WireErrorCode::StorageQuarantined, + WireRecoveryAction::RepairStorage, + ), + ( + SafeErrorCode::StorageBackupInvalid, + WireErrorCode::StorageBackupInvalid, + WireRecoveryAction::RestoreBackup, + ), + ( + SafeErrorCode::UnsupportedSchemaVersion, + WireErrorCode::UnsupportedSchemaVersion, + WireRecoveryAction::UpdateApplication, + ), + ( + SafeErrorCode::RepairUnauthorized, + WireErrorCode::RepairUnauthorized, + WireRecoveryAction::Authenticate, + ), + ] { + let dto = SafeErrorDto::from(radroots_studio_domain::SafeError::new( + code, + SafeMessage::new("Safe compatibility failure."), + )); + assert_eq!(dto.code, expected_code); + assert_eq!(dto.recovery_action, expected_recovery); + assert!(!dto.retryable); + } + } } diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs @@ -1,6 +1,7 @@ #![doc = "Radroots Studio `UniFFI` boundary."] mod commands; +mod contract; mod dto; mod observer; @@ -8,6 +9,10 @@ pub use commands::{ AccountCommandReceiptDto, GeneratedRecoveryRequest, RemovalRequest, RequestContextDto, StudioAppCore, StudioError, }; +pub use contract::{ + FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION, + PRODUCT_VERSION, +}; pub use dto::{ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, @@ -22,13 +27,18 @@ uniffi::setup_scaffolding!(); #[uniffi::export] #[must_use] pub fn native_runtime_version() -> String { - env!("CARGO_PKG_VERSION").to_owned() + PRODUCT_VERSION.to_owned() } #[cfg(test)] mod tests { #[test] - fn native_runtime_reports_the_crate_version() { + fn native_runtime_reports_the_product_version_independently() { assert_eq!(super::native_runtime_version(), "0.1.0-alpha"); + assert_eq!(super::PRODUCT_VERSION, "0.1.0-alpha"); + assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.0-alpha"); + assert_eq!(super::FFI_CONTRACT_MAJOR, 3); + assert_eq!(super::FFI_CONTRACT_HASH.len(), 64); + assert!(!super::contract::NORMALIZED_CONTRACT_METADATA.is_empty()); } } diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs @@ -1,4 +1,5 @@ use std::num::NonZeroUsize; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex, Weak}; @@ -8,6 +9,7 @@ use crate::commands::RuntimeCore; use crate::{AppSnapshotDto, StudioAppCore, StudioError}; const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = NonZeroUsize::MIN.saturating_add(63); +const MAX_OBSERVERS: usize = 32; #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct SnapshotChangeDto { @@ -49,7 +51,7 @@ impl ObserverSubscription { .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(&id) }; - if let Some(task) = task { + if let Some(Some(task)) = task { task.abort(); let _ = task.await; } @@ -80,12 +82,33 @@ impl StudioAppCore { let id = subscription.id(); let observer: Arc<dyn StudioChangeObserver> = Arc::from(observer); let runtime_core = Arc::downgrade(&self.inner); + let admitted = { + let mut observers = self + .inner + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.inner.closed.load(Ordering::Acquire) || observers.len() >= MAX_OBSERVERS { + false + } else { + observers.insert(id, None); + true + } + }; + if !admitted { + self.inner + .actor + .unsubscribe_changes(id) + .await + .map_err(StudioError::from)?; + return Err(observer_registration_error()); + } let task = crate::commands::runtime()?.spawn(async move { while let Some(change) = subscription.receive().await { let Some(runtime_core) = runtime_core.upgrade() else { break; }; - observer.on_change(SnapshotChangeDto { + let delivery = SnapshotChangeDto { snapshot: AppSnapshotDto::from_runtime( change.snapshot(), runtime_core.effective_lifecycle(), @@ -93,14 +116,38 @@ impl StudioAppCore { previous_revision: change .previous_revision() .map(radroots_studio_application::SnapshotRevision::value), - }); + }; + if catch_unwind(AssertUnwindSafe(|| observer.on_change(delivery))).is_err() { + break; + } + } + if let Some(runtime_core) = runtime_core.upgrade() { + let _ = runtime_core.actor.unsubscribe_changes(id).await; + runtime_core + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id); } }); - self.inner - .observers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(id, task); + let retained = { + let mut observers = self + .inner + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(slot) = observers.get_mut(&id) { + *slot = Some(task); + true + } else { + task.abort(); + false + } + }; + if !retained { + let _ = self.inner.actor.unsubscribe_changes(id).await; + return Err(closed_error()); + } Ok(Arc::new(ObserverSubscription { core: Arc::downgrade(&self.inner), id: Mutex::new(Some(id)), @@ -124,8 +171,10 @@ impl StudioAppCore { .unwrap_or_else(std::sync::PoisonError::into_inner), ); for (_, task) in handles { - task.abort(); - let _ = task.await; + if let Some(task) = task { + task.abort(); + let _ = task.await; + } } self.inner.actor.close().await.map_err(StudioError::from)?; Ok(ShutdownReceiptDto { @@ -146,6 +195,17 @@ fn closed_error() -> StudioError { } } +fn observer_registration_error() -> StudioError { + StudioError::Failure { + code: crate::WireErrorCode::ObserverRegistrationFailed, + category: crate::WireErrorCategory::Lifecycle, + retryable: true, + recovery_action: crate::WireRecoveryAction::Retry, + correlation_id: None, + safe_message: "The change observer could not be registered.".to_owned(), + } +} + #[cfg(test)] mod tests { use std::num::NonZeroUsize; @@ -175,6 +235,14 @@ mod tests { core: Mutex<Option<Arc<StudioAppCore>>>, } + struct PanickingObserver; + + impl StudioChangeObserver for PanickingObserver { + fn on_change(&self, _change: SnapshotChangeDto) { + panic!("injected host callback failure"); + } + } + impl StudioChangeObserver for RecordingObserver { fn on_change(&self, change: SnapshotChangeDto) { let snapshot = change.snapshot; @@ -258,6 +326,39 @@ mod tests { }); } + #[test] + fn observer_registration_is_bounded_and_callback_panics_are_contained() { + runtime().expect("runtime").block_on(async { + let core = core().await; + let panic_subscription = core + .subscribe_changes_v2(Box::new(PanickingObserver)) + .await + .expect("panic observer registration"); + tokio::time::sleep(Duration::from_millis(10)).await; + assert!(core.inner.observers.lock().expect("observers").is_empty()); + panic_subscription.unsubscribe().await; + + let observer = Arc::new(RecordingObserver::default()); + let mut subscriptions = Vec::new(); + for _ in 0..super::MAX_OBSERVERS { + subscriptions.push( + core.subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) + .await + .expect("bounded observer registration"), + ); + } + assert!( + core.subscribe_changes_v2(Box::new(ArcObserver(observer))) + .await + .is_err() + ); + for subscription in subscriptions { + subscription.unsubscribe().await; + } + assert!(core.inner.observers.lock().expect("observers").is_empty()); + }); + } + #[tokio::test] async fn ffi_callback_receives_async_profile_refresh_and_stops_after_unsubscribe() { let local_relay = MockRelay::run().await.expect("local relay"); diff --git a/crates/studio_nostr/src/client.rs b/crates/studio_nostr/src/client.rs @@ -43,8 +43,7 @@ impl NostrClient for SdkNostrClient { return Err(invalid_relay_configuration()); } - let author = radroots_identity::PublicKey::from_bytes(*public_key.as_bytes()) - .map_err(|_| profile_refresh_failed())?; + let author = public_key.canonical(); let deadline = deadline.min(Instant::now() + self.timeout); let mut candidates = Vec::new(); let mut successful_relays = 0usize; @@ -169,13 +168,6 @@ const fn relay_connection_failed() -> SafeError { ) } -const fn profile_refresh_failed() -> SafeError { - SafeError::new( - SafeErrorCode::ProfileRefreshFailed, - SafeMessage::new("The Nostr profile could not be refreshed."), - ) -} - #[cfg(test)] mod tests { use std::time::Duration; @@ -211,7 +203,8 @@ mod tests { let adapter = SdkNostrClient::new(Duration::from_secs(2)); let domain_relay = RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local) .expect("domain relay URL"); - let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let public_key = + PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); let fetched = adapter .fetch_profile( public_key, @@ -237,7 +230,7 @@ mod tests { async fn sdk_client_rejects_empty_configuration_without_network_access() { let error = SdkNostrClient::new(Duration::from_millis(10)) .fetch_profile( - PublicKey::from_bytes([1; 32]), + PublicKey::from_bytes([7; 32]).expect("valid public key"), &[], std::time::Instant::now() + Duration::from_millis(10), ) @@ -270,7 +263,7 @@ mod tests { ]; let fetched = SdkNostrClient::new(Duration::from_millis(250)) .fetch_profile( - PublicKey::from_bytes(keys.public_key().to_bytes()), + PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"), &configured, std::time::Instant::now() + Duration::from_secs(1), ) diff --git a/crates/studio_nostr/src/keys.rs b/crates/studio_nostr/src/keys.rs @@ -25,7 +25,7 @@ impl KeyMaterialProvider for NostrKeyMaterialProvider { .with_exposed_secret(Keys::parse) .map_err(|_| invalid_secret_key())?; drop(input); - let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?; let npub = keys .public_key() .to_bech32() @@ -37,7 +37,7 @@ impl KeyMaterialProvider for NostrKeyMaterialProvider { } fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> { - let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes())?; let npub = keys .public_key() .to_bech32() diff --git a/crates/studio_nostr/src/profile.rs b/crates/studio_nostr/src/profile.rs @@ -93,7 +93,8 @@ mod tests { #[test] fn profile_event_verifies_signature_author_kind_and_metadata() { let (keys, json) = signed_profile(); - let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes()); + let expected_author = + PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile"); @@ -111,8 +112,10 @@ mod tests { #[test] fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() { let (keys, json) = signed_profile(); - let expected_author = PublicKey::from_bytes(keys.public_key().to_bytes()); - let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes()); + let expected_author = + PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); + let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes()) + .expect("valid public key"); let tampered = json.replace("Local grower", "Remote grower"); let note = EventBuilder::text_note("not metadata") .sign_with_keys(&keys) @@ -147,7 +150,7 @@ mod tests { .sign_with_keys(&keys) .expect("signed invalid metadata") .as_json(); - let author = PublicKey::from_bytes(keys.public_key().to_bytes()); + let author = PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key"); assert_eq!( parse_verified_kind0(&malformed, author) diff --git a/crates/studio_runtime/src/persistence.rs b/crates/studio_runtime/src/persistence.rs @@ -333,7 +333,7 @@ mod tests { use super::PersistentAppCore; fn account() -> AccountSummary { - let public_key = PublicKey::from_bytes([4; 32]); + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), LocalSignerBinding::new(public_key, BindingAvailability::Available), diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_storage/Cargo.toml @@ -17,9 +17,15 @@ keyring = "=4.1.6" radroots_studio_application.workspace = true radroots_studio_domain.workspace = true refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } -rusqlite = { version = "=0.39.0", features = ["bundled"] } +getrandom.workspace = true +hmac.workspace = true +rusqlite = { version = "=0.39.0", features = ["backup", "bundled"] } +sha2.workspace = true zeroize = "=1.9.0" +[target.'cfg(unix)'.dependencies] +rustix.workspace = true + [dev-dependencies] tempfile = "=3.23.0" diff --git a/crates/studio_storage/src/account_namespace.rs b/crates/studio_storage/src/account_namespace.rs @@ -86,8 +86,17 @@ mod tests { use crate::Database; + fn public_key(byte: u8) -> PublicKey { + let value = match byte { + 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + }; + PublicKey::from_hex(value).expect("valid public key") + } + fn account(byte: u8) -> AccountSummary { - let public_key = PublicKey::from_bytes([byte; 32]); + let public_key = public_key(byte); AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), LocalSignerBinding::new(public_key, BindingAvailability::Available), @@ -101,8 +110,8 @@ mod tests { #[test] fn namespace_partitions_same_typed_key_by_owner_and_selection() { let database = Database::in_memory().expect("database"); - let owner_a = PublicKey::from_bytes([1; 32]); - let owner_b = PublicKey::from_bytes([2; 32]); + let owner_a = public_key(1); + let owner_b = public_key(2); database.insert_account(&account(1)).expect("account a"); database.insert_account(&account(2)).expect("account b"); database @@ -136,7 +145,7 @@ mod tests { #[test] fn namespace_updates_and_cascades_with_owner_removal() { let database = Database::in_memory().expect("database"); - let owner = PublicKey::from_bytes([3; 32]); + let owner = public_key(3); database.insert_account(&account(3)).expect("account"); database .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before") diff --git a/crates/studio_storage/src/accounts.rs b/crates/studio_storage/src/accounts.rs @@ -309,8 +309,17 @@ mod tests { use crate::Database; + fn public_key(key_byte: u8) -> PublicKey { + let value = match key_byte { + 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + }; + PublicKey::from_hex(value).expect("valid public key") + } + fn account(key_byte: u8, created_at: i64) -> AccountSummary { - let public_key = PublicKey::from_bytes([key_byte; 32]); + let public_key = public_key(key_byte); AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), LocalSignerBinding::new(public_key, BindingAvailability::Available), diff --git a/crates/studio_storage/src/compatibility.rs b/crates/studio_storage/src/compatibility.rs @@ -0,0 +1,330 @@ +use std::path::Path; + +use radroots_studio_domain::{ + AccountIdentity, PersistedPublicKeyClassification, SafeError, SafeErrorCode, SafeMessage, + classify_persisted_public_key, +}; +use rusqlite::{Connection, OpenFlags}; +use sha2::{Digest, Sha256}; + +use crate::CURRENT_SCHEMA_VERSION; + +const KNOWN_TABLES: &[(&str, u32)] = &[ + ("application_schema", 1), + ("accounts", 2), + ("app_state", 2), + ("profile_cache", 3), + ("account_namespace", 4), + ("operation_journal", 5), + ("account_identities", 6), + ("local_signer_bindings", 6), + ("runtime_state", 6), + ("profile_cache_v6", 6), + ("durable_operations", 6), + ("account_preferences", 8), + ("installation_identity", 10), +]; + +const PUBLIC_KEY_COLUMNS: &[(&str, &str)] = &[ + ("accounts", "pubkey"), + ("app_state", "selected_pubkey"), + ("profile_cache", "subject_pubkey"), + ("account_namespace", "owner_pubkey"), + ("operation_journal", "subject_pubkey"), + ("account_identities", "public_key"), + ("local_signer_bindings", "account_public_key"), + ("local_signer_bindings", "binding_public_key"), + ("runtime_state", "selected_public_key"), + ("runtime_state", "active_account_public_key"), + ("runtime_state", "active_binding_public_key"), + ("profile_cache_v6", "subject_public_key"), + ("durable_operations", "account_public_key"), + ("durable_operations", "binding_public_key"), + ("durable_operations", "prior_selected_public_key"), + ("account_preferences", "owner_public_key"), +]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DatabasePreflight { + Fresh, + Ready { + schema_version: u32, + }, + Quarantined { + schema_version: u32, + issues: Vec<PersistedIdentityIssue>, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PersistedIdentityIssueKind { + MalformedEncoding, + NonCanonicalEncoding, + InvalidCurvePoint, + DisplayIdentityMismatch, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PersistedIdentityIssue { + table: &'static str, + column: &'static str, + row_id: i64, + kind: PersistedIdentityIssueKind, + fingerprint: [u8; 32], +} + +impl PersistedIdentityIssue { + #[must_use] + pub const fn table(&self) -> &'static str { + self.table + } + + #[must_use] + pub const fn column(&self) -> &'static str { + self.column + } + + #[must_use] + pub const fn row_id(&self) -> i64 { + self.row_id + } + + #[must_use] + pub const fn kind(&self) -> PersistedIdentityIssueKind { + self.kind + } + + #[must_use] + pub const fn fingerprint(&self) -> &[u8; 32] { + &self.fingerprint + } +} + +pub(crate) fn preflight(path: &Path) -> Result<DatabasePreflight, SafeError> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(corrupt_storage_error()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(DatabasePreflight::Fresh); + } + Err(_) => return Err(corrupt_storage_error()), + } + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + let connection = + Connection::open_with_flags(path, flags).map_err(|_| corrupt_storage_error())?; + connection + .pragma_update(None, "trusted_schema", "OFF") + .map_err(|_| corrupt_storage_error())?; + let integrity: String = connection + .pragma_query_value(None, "quick_check", |row| row.get(0)) + .map_err(|_| corrupt_storage_error())?; + if integrity != "ok" { + return Err(corrupt_storage_error()); + } + let schema_version = schema_version(&connection)?; + if schema_version == 0 || schema_version > CURRENT_SCHEMA_VERSION { + return Err(unsupported_schema_error()); + } + validate_schema_inventory(&connection, schema_version)?; + + let mut issues = Vec::new(); + for &(table, column) in PUBLIC_KEY_COLUMNS { + if column_exists(&connection, table, column)? { + scan_public_key_column(&connection, table, column, &mut issues)?; + } + } + scan_display_identities(&connection, "accounts", "pubkey", "npub", &mut issues)?; + scan_display_identities( + &connection, + "account_identities", + "public_key", + "npub", + &mut issues, + )?; + issues.sort_by_key(|issue| (issue.table, issue.column, issue.row_id)); + if issues.is_empty() { + Ok(DatabasePreflight::Ready { schema_version }) + } else { + Ok(DatabasePreflight::Quarantined { + schema_version, + issues, + }) + } +} + +fn schema_version(connection: &Connection) -> Result<u32, SafeError> { + if !table_exists(connection, "refinery_schema_history")? { + return Err(unsupported_schema_error()); + } + connection + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .map_err(|_| corrupt_storage_error()) +} + +fn validate_schema_inventory(connection: &Connection, version: u32) -> Result<(), SafeError> { + for &(table, introduced) in KNOWN_TABLES { + let present = table_exists(connection, table)?; + if present != (version >= introduced) { + return Err(corrupt_storage_error()); + } + } + let mut statement = connection + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> 'refinery_schema_history'", + ) + .map_err(|_| corrupt_storage_error())?; + let names = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|_| corrupt_storage_error())?; + for name in names { + let name = name.map_err(|_| corrupt_storage_error())?; + if !KNOWN_TABLES.iter().any(|(known, _)| *known == name) { + return Err(corrupt_storage_error()); + } + } + Ok(()) +} + +fn scan_public_key_column( + connection: &Connection, + table: &'static str, + column: &'static str, + issues: &mut Vec<PersistedIdentityIssue>, +) -> Result<(), SafeError> { + let sql = format!("SELECT rowid, {column} FROM {table} WHERE {column} IS NOT NULL"); + let mut statement = connection + .prepare(&sql) + .map_err(|_| corrupt_storage_error())?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|_| corrupt_storage_error())?; + for row in rows { + let (row_id, value) = row.map_err(|_| corrupt_storage_error())?; + let kind = match classify_persisted_public_key(&value) { + PersistedPublicKeyClassification::Canonical(_) => continue, + PersistedPublicKeyClassification::MalformedEncoding => { + PersistedIdentityIssueKind::MalformedEncoding + } + PersistedPublicKeyClassification::NonCanonicalEncoding => { + PersistedIdentityIssueKind::NonCanonicalEncoding + } + PersistedPublicKeyClassification::InvalidCurvePoint => { + PersistedIdentityIssueKind::InvalidCurvePoint + } + }; + issues.push(issue(table, column, row_id, kind, &value)); + } + Ok(()) +} + +fn scan_display_identities( + connection: &Connection, + table: &'static str, + key_column: &'static str, + npub_column: &'static str, + issues: &mut Vec<PersistedIdentityIssue>, +) -> Result<(), SafeError> { + if !column_exists(connection, table, key_column)? + || !column_exists(connection, table, npub_column)? + { + return Ok(()); + } + let sql = format!("SELECT rowid, {key_column}, {npub_column} FROM {table}"); + let mut statement = connection + .prepare(&sql) + .map_err(|_| corrupt_storage_error())?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|_| corrupt_storage_error())?; + for row in rows { + let (row_id, key, npub) = row.map_err(|_| corrupt_storage_error())?; + let PersistedPublicKeyClassification::Canonical(public_key) = + classify_persisted_public_key(&key) + else { + continue; + }; + if AccountIdentity::verify(public_key, npub.clone()).is_err() { + issues.push(issue( + table, + npub_column, + row_id, + PersistedIdentityIssueKind::DisplayIdentityMismatch, + &npub, + )); + } + } + Ok(()) +} + +fn issue( + table: &'static str, + column: &'static str, + row_id: i64, + kind: PersistedIdentityIssueKind, + value: &str, +) -> PersistedIdentityIssue { + PersistedIdentityIssue { + table, + column, + row_id, + kind, + fingerprint: Sha256::digest(value.as_bytes()).into(), + } +} + +fn table_exists(connection: &Connection, table: &str) -> Result<bool, SafeError> { + connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [table], + |row| row.get(0), + ) + .map_err(|_| corrupt_storage_error()) +} + +fn column_exists(connection: &Connection, table: &str, column: &str) -> Result<bool, SafeError> { + if !table_exists(connection, table)? { + return Ok(false); + } + let sql = format!("SELECT EXISTS(SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1)"); + connection + .query_row(&sql, [column], |row| row.get(0)) + .map_err(|_| corrupt_storage_error()) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The application database could not be read."), + ) +} + +const fn unsupported_schema_error() -> SafeError { + SafeError::new( + SafeErrorCode::UnsupportedSchemaVersion, + SafeMessage::new("The application database schema is not supported."), + ) +} + +pub(crate) const fn quarantined_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageQuarantined, + SafeMessage::new("The application database requires authenticated repair."), + ) +} diff --git a/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs @@ -5,10 +5,17 @@ use std::sync::{Mutex, MutexGuard}; use std::time::Duration; use fs2::FileExt; -use radroots_studio_domain::{AccountIdentity, PublicKey, SafeError, SafeErrorCode, SafeMessage}; +use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; use refinery::embed_migrations; use rusqlite::{Connection, OpenFlags}; +use crate::compatibility::{DatabasePreflight, preflight, quarantined_storage_error}; +use crate::recovery::MigrationRecovery; +use crate::repair::{ + QuarantineExportReceipt, RepairAuthorization, RepairCandidate, authenticate_candidate, + export_quarantined, install_candidate, +}; + pub const CURRENT_SCHEMA_VERSION: u32 = 10; mod migrations { @@ -40,21 +47,67 @@ impl Database { /// Returns a safe storage error when the file, connection configuration, /// permission update, or migration cannot complete. pub fn open(path: &Path) -> Result<Self, SafeError> { + let preflight = preflight(path)?; + if matches!(&preflight, DatabasePreflight::Quarantined { .. }) { + return Err(quarantined_storage_error()); + } let parent = path.parent().ok_or_else(storage_error)?; create_secure_directory(parent)?; + restrict_sqlite_sidecars(path)?; let ownership = WritableOwnership::acquire(path)?; + let recovery_source_schema = match &preflight { + DatabasePreflight::Ready { schema_version } + if *schema_version < CURRENT_SCHEMA_VERSION => + { + Some(*schema_version) + } + _ => None, + }; + let recovery = match preflight { + DatabasePreflight::Ready { schema_version } + if schema_version < CURRENT_SCHEMA_VERSION => + { + Some(MigrationRecovery::prepare( + path, + schema_version, + CURRENT_SCHEMA_VERSION, + )?) + } + DatabasePreflight::Fresh | DatabasePreflight::Ready { .. } => None, + DatabasePreflight::Quarantined { .. } => unreachable!("handled above"), + }; let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE - | OpenFlags::SQLITE_OPEN_NO_MUTEX; + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; let mut connection = Connection::open_with_flags(path, flags).map_err(|_| storage_error())?; configure(&connection).map_err(|_| corrupt_storage_error())?; - validate_legacy_account_identities(&connection)?; - migrations::migrations::runner() + if migrations::migrations::runner() .run(&mut connection) + .is_err() + { + drop(connection); + if let Some(source_schema) = recovery_source_schema { + MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION)?; + } + return Err(corrupt_storage_error()); + } + let schema_version = connection + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history", + [], + |row| row.get::<_, u32>(0), + ) .map_err(|_| corrupt_storage_error())?; + if schema_version != CURRENT_SCHEMA_VERSION { + return Err(corrupt_storage_error()); + } restrict_file_permissions(path)?; restrict_sqlite_sidecars(path)?; + if let Some(recovery) = recovery { + recovery.finish(schema_version)?; + } Ok(Self { connection: Mutex::new(connection), path: Some(path.to_path_buf()), @@ -80,6 +133,80 @@ impl Database { }) } + /// Inspects schema and persisted identities without mutating the database. + /// + /// # Errors + /// + /// Returns a safe corrupt or unsupported-schema error when the database + /// cannot be classified. + pub fn preflight(path: &Path) -> Result<DatabasePreflight, SafeError> { + preflight(path) + } + + /// Verifies the authenticated, immutable backup retained for a migration. + /// + /// # Errors + /// + /// Returns a safe backup error when any manifest, digest, authentication + /// tag, schema identity, or SQLite integrity check fails. + pub fn verify_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> { + MigrationRecovery::verify_evidence(path, source_schema, CURRENT_SCHEMA_VERSION) + } + + /// Restores an authenticated pre-migration backup while retaining the + /// displaced database as recovery evidence. + /// + /// # Errors + /// + /// Returns a safe storage or backup error without replacing the database + /// when authentication or the atomic replacement fails. + pub fn restore_migration_backup(path: &Path, source_schema: u32) -> Result<(), SafeError> { + let _ownership = WritableOwnership::acquire(path)?; + MigrationRecovery::restore(path, source_schema, CURRENT_SCHEMA_VERSION) + } + + /// Exports a quarantined database without mutating it and authenticates + /// the resulting SQLite artifact with a caller-owned repair capability. + /// + /// # Errors + /// + /// Returns a safe state, authorization, or storage error. + pub fn export_quarantined( + path: &Path, + destination: &Path, + authorization: &RepairAuthorization, + ) -> Result<QuarantineExportReceipt, SafeError> { + export_quarantined(path, destination, authorization) + } + + /// Validates and authenticates a canonical repaired database candidate. + /// + /// # Errors + /// + /// Returns a safe compatibility or storage error for an invalid candidate. + pub fn authenticate_repair_candidate( + path: &Path, + authorization: &RepairAuthorization, + ) -> Result<RepairCandidate, SafeError> { + authenticate_candidate(path, authorization) + } + + /// Atomically installs an authenticated candidate over a quarantined + /// database while retaining the original as immutable evidence. + /// + /// # Errors + /// + /// Returns a safe authorization, ownership, or storage error without + /// replacing the target when any gate fails. + pub fn install_repair_candidate( + path: &Path, + candidate: &RepairCandidate, + authorization: &RepairAuthorization, + ) -> Result<(), SafeError> { + let _ownership = WritableOwnership::acquire(path)?; + install_candidate(path, candidate, authorization) + } + /// Returns the highest successfully applied migration version. /// /// # Errors @@ -131,13 +258,16 @@ impl Drop for DatabaseConnection<'_> { impl WritableOwnership { fn acquire(database_path: &Path) -> Result<Self, SafeError> { let lock_path = database_path.with_extension("sqlite3.lock"); - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&lock_path) - .map_err(|_| storage_error())?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags( + (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32, + ); + } + let file = options.open(&lock_path).map_err(|_| storage_error())?; restrict_file_permissions(&lock_path)?; file.try_lock_exclusive().map_err(|_| ownership_error())?; Ok(Self { _file: file }) @@ -145,7 +275,26 @@ impl WritableOwnership { } fn create_secure_directory(path: &Path) -> Result<(), SafeError> { + let mut existing = path; + loop { + match fs::symlink_metadata(existing) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(storage_error()); + } + break; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + existing = existing.parent().ok_or_else(storage_error)?; + } + Err(_) => return Err(storage_error()), + } + } fs::create_dir_all(path).map_err(|_| storage_error())?; + let metadata = fs::symlink_metadata(path).map_err(|_| storage_error())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(storage_error()); + } restrict_directory_permissions(path) } @@ -161,79 +310,42 @@ fn configure(connection: &Connection) -> Result<(), SafeError> { .map_err(|_| storage_error()) } -fn validate_legacy_account_identities(connection: &Connection) -> Result<(), SafeError> { - let has_accounts = connection - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'accounts')", - [], - |row| row.get::<_, bool>(0), - ) - .map_err(|_| corrupt_storage_error())?; - if !has_accounts { - return Ok(()); - } - - let mut statement = connection - .prepare("SELECT pubkey, npub, signer_kind, key_availability FROM accounts") - .map_err(|_| corrupt_storage_error())?; - let rows = statement - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }) - .map_err(|_| corrupt_storage_error())?; - for row in rows { - let (public_key, npub, signer_kind, availability) = - row.map_err(|_| corrupt_storage_error())?; - let public_key = PublicKey::from_hex(&public_key).map_err(|_| corrupt_storage_error())?; - AccountIdentity::verify(public_key, npub).map_err(|_| corrupt_storage_error())?; - if signer_kind != "local_secret" - || !matches!( - availability.as_str(), - "available" | "credential_missing" | "store_unavailable" - ) - { - return Err(corrupt_storage_error()); - } - } - Ok(()) -} - fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> { for suffix in ["-wal", "-shm"] { let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); - if sidecar.exists() { - restrict_file_permissions(&sidecar)?; + match fs::symlink_metadata(&sidecar) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(storage_error()); + } + Ok(_) => restrict_file_permissions(&sidecar)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(storage_error()), } } Ok(()) } #[cfg(unix)] -fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> { +pub(crate) fn restrict_file_permissions(path: &Path) -> Result<(), SafeError> { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| storage_error()) } #[cfg(unix)] -fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> { +pub(crate) fn restrict_directory_permissions(path: &Path) -> Result<(), SafeError> { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| storage_error()) } #[cfg(not(unix))] -fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> { +pub(crate) fn restrict_file_permissions(_path: &Path) -> Result<(), SafeError> { Ok(()) } #[cfg(not(unix))] -fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> { +pub(crate) fn restrict_directory_permissions(_path: &Path) -> Result<(), SafeError> { Ok(()) } @@ -261,17 +373,19 @@ const fn ownership_error() -> SafeError { #[cfg(test)] mod tests { use std::fs; + use std::io::Write; use std::path::Path; use std::process::Command; use tempfile::tempdir; use radroots_studio_application::{AccountRepository, AppStateRepository}; - use radroots_studio_domain::PublicKey; + use radroots_studio_domain::{PublicKey, SafeErrorCode}; use refinery::Target; use rusqlite::Connection; use super::{CURRENT_SCHEMA_VERSION, Database, configure, migrations}; + use crate::{DatabasePreflight, PersistedIdentityIssueKind, RepairAuthorization}; #[test] fn migration_opens_fresh_memory_database_once() { @@ -387,7 +501,7 @@ mod tests { assert_eq!(database.list_accounts().expect("accounts").len(), 1); assert_eq!( database.load_selected_account().expect("selection"), - Some(PublicKey::from_bytes([7; 32])) + Some(PublicKey::from_bytes([7; 32]).expect("valid public key")) ); let connection = database.connection(); let migrated: (i64, i64, i64) = connection @@ -398,6 +512,31 @@ mod tests { ) .expect("migrated inventory"); assert_eq!(migrated, (1, 1, 1)); + drop(connection); + drop(database); + + Database::verify_migration_backup(&path, 5).expect("authenticated backup"); + Database::restore_migration_backup(&path, 5).expect("authenticated restore"); + assert_eq!( + Database::preflight(&path).expect("restored preflight"), + DatabasePreflight::Ready { schema_version: 5 } + ); + let retried = Database::open(&path).expect("idempotent migration retry"); + assert_eq!(retried.schema_version().expect("retried version"), 10); + drop(retried); + + let backup = directory + .path() + .join("studio.sqlite3.recovery/migration-v5-to-v10.sqlite3"); + fs::OpenOptions::new() + .append(true) + .open(backup) + .expect("open backup") + .write_all(b"tamper") + .expect("tamper backup"); + let error = + Database::verify_migration_backup(&path, 5).expect_err("tampered backup must fail"); + assert_eq!(error.code(), SafeErrorCode::StorageBackupInvalid); } #[test] @@ -435,6 +574,123 @@ mod tests { } #[test] + fn invalid_curve_identity_is_quarantined_without_mutation() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + ["00".repeat(32), "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned()], + ) + .expect("invalid-curve fixture"); + connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE)") + .expect("checkpoint"); + } + let before = fs::read(&path).expect("before bytes"); + + let DatabasePreflight::Quarantined { + schema_version, + issues, + } = Database::preflight(&path).expect("classified preflight") + else { + panic!("invalid identity was not quarantined"); + }; + assert_eq!(schema_version, 5); + assert!(issues.iter().any(|issue| { + issue.table() == "accounts" + && issue.column() == "pubkey" + && issue.kind() == PersistedIdentityIssueKind::InvalidCurvePoint + })); + let error = Database::open(&path) + .err() + .expect("quarantined open must fail"); + assert_eq!(error.code(), SafeErrorCode::StorageQuarantined); + assert_eq!(fs::read(&path).expect("after bytes"), before); + assert!(!path.with_extension("sqlite3.lock").exists()); + + let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]) + .unwrap_or_else(|_| panic!("repair authorization")); + let export_path = directory.path().join("quarantine-export.sqlite3"); + let export = Database::export_quarantined(&path, &export_path, &authorization) + .expect("authenticated quarantine export"); + assert_eq!(export.path(), export_path); + assert_eq!(export.sha256().len(), 64); + assert_eq!(export.authentication_tag().len(), 64); + assert_eq!(fs::read(&path).expect("post-export bytes"), before); + + let candidate_path = directory.path().join("repaired.sqlite3"); + drop(Database::open(&candidate_path).expect("canonical repair candidate")); + let candidate = Database::authenticate_repair_candidate(&candidate_path, &authorization) + .expect("authenticate candidate"); + let wrong_authorization = RepairAuthorization::from_bytes(vec![0x42; 32]) + .unwrap_or_else(|_| panic!("wrong authorization shape")); + let error = Database::install_repair_candidate(&path, &candidate, &wrong_authorization) + .expect_err("wrong repair authorization"); + assert_eq!(error.code(), SafeErrorCode::RepairUnauthorized); + assert_eq!(fs::read(&path).expect("unauthorized bytes"), before); + + Database::install_repair_candidate(&path, &candidate, &authorization) + .expect("authenticated repair install"); + assert!(matches!( + Database::preflight(&path).expect("repaired preflight"), + DatabasePreflight::Ready { + schema_version: CURRENT_SCHEMA_VERSION + } + )); + assert!( + directory + .path() + .join("studio.sqlite3.quarantined-evidence") + .is_file() + ); + } + + #[test] + fn newer_and_mixed_schema_inventory_fail_before_mutation() { + let directory = tempdir().expect("temporary directory"); + let newer_path = directory.path().join("newer.sqlite3"); + { + let database = Database::open(&newer_path).expect("current database"); + database + .connection() + .execute( + "UPDATE refinery_schema_history SET version = ?1 WHERE version = ?2", + [CURRENT_SCHEMA_VERSION + 1, CURRENT_SCHEMA_VERSION], + ) + .expect("future schema row"); + } + let newer_before = fs::read(&newer_path).expect("newer bytes"); + let error = Database::preflight(&newer_path).expect_err("newer schema"); + assert_eq!(error.code(), SafeErrorCode::UnsupportedSchemaVersion); + assert_eq!(fs::read(&newer_path).expect("newer after"), newer_before); + + let mixed_path = directory.path().join("mixed.sqlite3"); + { + let mut connection = Connection::open(&mixed_path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute("CREATE TABLE installation_identity (singleton INTEGER)", []) + .expect("mixed table"); + } + let mixed_before = fs::read(&mixed_path).expect("mixed bytes"); + let error = Database::preflight(&mixed_path).expect_err("mixed schema"); + assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); + assert_eq!(fs::read(&mixed_path).expect("mixed after"), mixed_before); + } + + #[test] fn failed_v5_copy_rolls_back_the_active_migration() { let directory = tempdir().expect("temporary directory"); let path = directory.path().join("studio.sqlite3"); @@ -469,12 +725,14 @@ mod tests { |row| row.get(0), ) .expect("migration version"); - let copied: i64 = connection - .query_row("SELECT COUNT(*) FROM account_identities", [], |row| { - row.get(0) - }) - .expect("normalized accounts"); - assert_eq!((version, copied), (6, 0)); + assert_eq!(version, 5); + assert!(!connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'account_identities')", + [], + |row| row.get::<_, bool>(0), + ) + .expect("normalized table inventory")); } #[test] @@ -587,4 +845,46 @@ mod tests { assert_eq!(sidecar_mode, 0o600); } } + + #[cfg(unix)] + #[test] + fn database_lock_sidecar_and_recovery_symlinks_fail_closed() { + use std::os::unix::fs::symlink; + + let directory = tempdir().expect("temporary directory"); + let victim = directory.path().join("victim"); + fs::write(&victim, b"unchanged").expect("victim"); + + let database_link = directory.path().join("database-link.sqlite3"); + symlink(&victim, &database_link).expect("database symlink"); + assert!(Database::open(&database_link).is_err()); + assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); + + let lock_path = directory.path().join("locked.sqlite3"); + symlink(&victim, lock_path.with_extension("sqlite3.lock")).expect("lock symlink"); + assert!(Database::open(&lock_path).is_err()); + assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); + + let sidecar_path = directory.path().join("sidecar.sqlite3"); + let wal = std::path::PathBuf::from(format!("{}-wal", sidecar_path.display())); + symlink(&victim, wal).expect("WAL symlink"); + assert!(Database::open(&sidecar_path).is_err()); + assert_eq!(fs::read(&victim).expect("victim bytes"), b"unchanged"); + + let legacy_path = directory.path().join("legacy.sqlite3"); + { + let mut connection = Connection::open(&legacy_path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + } + symlink( + directory.path().join("not-present"), + directory.path().join("legacy.sqlite3.recovery"), + ) + .expect("recovery symlink"); + assert!(Database::open(&legacy_path).is_err()); + } } diff --git a/crates/studio_storage/src/journal.rs b/crates/studio_storage/src/journal.rs @@ -513,10 +513,19 @@ mod tests { use crate::Database; + fn public_key(discriminator: u8) -> PublicKey { + let value = match discriminator { + 7 => "0707070707070707070707070707070707070707070707070707070707070707", + 8 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + _ => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + }; + PublicKey::from_hex(value).expect("valid public key") + } + #[test] fn journal_creates_advances_loads_and_finalizes_pending_operations() { let database = Database::in_memory().expect("database"); - let subject = PublicKey::from_bytes([7; 32]); + let subject = public_key(7); let id = database .begin_operation( AccountOperationKind::Import, @@ -561,7 +570,7 @@ mod tests { database .begin_operation( AccountOperationKind::Remove, - PublicKey::from_bytes([8; 32]), + public_key(8), UnixTimestamp::from_seconds(12).expect("time"), ) .expect("begin"); @@ -581,9 +590,9 @@ mod tests { fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() { let database = Database::in_memory().expect("database"); let request = DurableRequestId::parse("import:test:1").expect("request"); - let account = PublicKey::from_bytes([9; 32]); + let account = public_key(9); let prior = OperationPriorState::new( - Some(PublicKey::from_bytes([8; 32])), + Some(public_key(8)), Some(BindingAvailability::CredentialMissing), ); let started = database diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs @@ -2,11 +2,16 @@ pub mod account_namespace; pub mod accounts; +mod compatibility; pub mod db; mod installation; pub mod journal; pub mod os_keyring; pub mod profiles; +mod recovery; +mod repair; +pub use compatibility::{DatabasePreflight, PersistedIdentityIssue, PersistedIdentityIssueKind}; pub use db::{CURRENT_SCHEMA_VERSION, Database}; pub use os_keyring::{CREDENTIAL_SERVICE, OsKeyringSecretStore}; +pub use repair::{QuarantineExportReceipt, RepairAuthorization, RepairCandidate}; diff --git a/crates/studio_storage/src/os_keyring.rs b/crates/studio_storage/src/os_keyring.rs @@ -106,16 +106,23 @@ mod tests { #[test] fn keyring_coordinates_are_stable_and_public() { - let public_key = PublicKey::from_bytes([0xab; 32]); + let public_key = + PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") + .expect("valid public key"); assert_eq!(CREDENTIAL_SERVICE, "org.radroots.studio.nostr"); - assert_eq!(public_key.to_hex(), "ab".repeat(32)); + assert_eq!( + public_key.to_hex(), + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + ); } #[test] #[ignore = "mutates the current user's operating-system credential store"] fn real_keyring_smoke_round_trips_and_deletes() { let store = OsKeyringSecretStore::default(); - let public_key = PublicKey::from_bytes([0xcd; 32]); + let public_key = + PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") + .expect("valid public key"); let _ = store.delete(public_key); store .put( diff --git a/crates/studio_storage/src/profiles.rs b/crates/studio_storage/src/profiles.rs @@ -156,6 +156,10 @@ mod tests { use crate::Database; + fn public_key() -> PublicKey { + PublicKey::from_bytes([7; 32]).expect("valid public key") + } + fn account(public_key: PublicKey) -> AccountSummary { AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), @@ -184,7 +188,7 @@ mod tests { #[test] fn profile_cache_round_trips_and_records_refresh_status() { let database = Database::in_memory().expect("database"); - let public_key = PublicKey::from_bytes([1; 32]); + let public_key = public_key(); database .insert_account(&account(public_key)) .expect("account"); @@ -211,7 +215,7 @@ mod tests { #[test] fn profile_cache_keeps_newest_then_lowest_event_id() { let database = Database::in_memory().expect("database"); - let public_key = PublicKey::from_bytes([2; 32]); + let public_key = public_key(); database .insert_account(&account(public_key)) .expect("account"); @@ -236,7 +240,7 @@ mod tests { #[test] fn profile_cache_cascades_with_account_removal() { let database = Database::in_memory().expect("database"); - let public_key = PublicKey::from_bytes([3; 32]); + let public_key = public_key(); database .insert_account(&account(public_key)) .expect("account"); diff --git a/crates/studio_storage/src/recovery.rs b/crates/studio_storage/src/recovery.rs @@ -0,0 +1,513 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use hmac::{Hmac, Mac}; +use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; +use rusqlite::{Connection, MAIN_DB, OpenFlags}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::db::{restrict_directory_permissions, restrict_file_permissions}; + +type HmacSha256 = Hmac<Sha256>; + +const RECOVERY_DIRECTORY_SUFFIX: &str = "recovery"; +const AUTHENTICATION_KEY_FILENAME: &str = "authentication-key-v1"; +const MANIFEST_FORMAT: &str = "radroots-studio-migration-recovery-v1"; + +pub(crate) struct MigrationRecovery { + directory: PathBuf, + backup: PathBuf, + marker: PathBuf, + source_schema: u32, + target_schema: u32, + digest: String, + tag: String, + state: String, +} + +impl MigrationRecovery { + pub(crate) fn prepare( + database_path: &Path, + source_schema: u32, + target_schema: u32, + ) -> Result<Self, SafeError> { + let directory = recovery_directory(database_path)?; + create_recovery_directory(&directory)?; + let key = load_or_create_authentication_key(&directory)?; + let stem = format!("migration-v{source_schema}-to-v{target_schema}"); + let backup = directory.join(format!("{stem}.sqlite3")); + let marker = directory.join(format!("{stem}.marker")); + + if marker.try_exists().map_err(|_| storage_error())? { + let mut recovery = Self::load_existing( + directory, + backup, + marker, + source_schema, + target_schema, + &key, + )?; + if recovery.state == "complete" { + recovery.tag = authentication_tag( + &key, + source_schema, + target_schema, + &recovery.digest, + "prepared", + )?; + recovery.state = "prepared".to_owned(); + recovery.write_marker("prepared", &key)?; + } + return Ok(recovery); + } + if backup.try_exists().map_err(|_| storage_error())? { + return Err(backup_invalid()); + } + + create_verified_backup(database_path, &backup)?; + let digest = file_digest(&backup)?; + let tag = authentication_tag(&key, source_schema, target_schema, &digest, "prepared")?; + let recovery = Self { + directory, + backup, + marker, + source_schema, + target_schema, + digest, + tag, + state: "prepared".to_owned(), + }; + recovery.write_marker("prepared", &key)?; + recovery.verify_backup(&key, "prepared")?; + Ok(recovery) + } + + pub(crate) fn finish(self, current_schema: u32) -> Result<(), SafeError> { + if current_schema != self.target_schema { + return Err(backup_invalid()); + } + let key = load_authentication_key(&self.directory)?; + self.verify_backup(&key, "prepared")?; + self.write_marker("complete", &key) + } + + pub(crate) fn verify_evidence( + database_path: &Path, + source_schema: u32, + target_schema: u32, + ) -> Result<(), SafeError> { + let directory = recovery_directory(database_path)?; + let key = load_authentication_key(&directory)?; + let stem = format!("migration-v{source_schema}-to-v{target_schema}"); + Self::load_existing( + directory.clone(), + directory.join(format!("{stem}.sqlite3")), + directory.join(format!("{stem}.marker")), + source_schema, + target_schema, + &key, + ) + .map(|_| ()) + } + + pub(crate) fn restore( + database_path: &Path, + source_schema: u32, + target_schema: u32, + ) -> Result<(), SafeError> { + let directory = recovery_directory(database_path)?; + let key = load_authentication_key(&directory)?; + let stem = format!("migration-v{source_schema}-to-v{target_schema}"); + let recovery = Self::load_existing( + directory.clone(), + directory.join(format!("{stem}.sqlite3")), + directory.join(format!("{stem}.marker")), + source_schema, + target_schema, + &key, + )?; + recovery.verify_backup(&key, &recovery.state)?; + replace_with_backup(database_path, &recovery.backup) + } + + fn load_existing( + directory: PathBuf, + backup: PathBuf, + marker: PathBuf, + source_schema: u32, + target_schema: u32, + key: &[u8], + ) -> Result<Self, SafeError> { + let manifest = read_bounded_file(&marker, 4_096)?; + let manifest = std::str::from_utf8(&manifest).map_err(|_| backup_invalid())?; + let mut lines = manifest.lines(); + if lines.next() != Some(MANIFEST_FORMAT) + || parse_field(&mut lines, "source_schema")? != source_schema.to_string() + || parse_field(&mut lines, "target_schema")? != target_schema.to_string() + || parse_field(&mut lines, "backup")? + != backup + .file_name() + .ok_or_else(backup_invalid)? + .to_string_lossy() + || lines.clone().count() != 3 + { + return Err(backup_invalid()); + } + let digest = parse_field(&mut lines, "sha256")?; + let state = parse_field(&mut lines, "state")?; + let tag = parse_field(&mut lines, "hmac_sha256")?; + if !matches!(state.as_str(), "prepared" | "complete") { + return Err(backup_invalid()); + } + let recovery = Self { + directory, + backup, + marker, + source_schema, + target_schema, + digest, + tag, + state, + }; + recovery.verify_backup(key, &recovery.state)?; + Ok(recovery) + } + + fn verify_backup(&self, key: &[u8], state: &str) -> Result<(), SafeError> { + if file_digest(&self.backup)? != self.digest { + return Err(backup_invalid()); + } + let expected = authentication_tag( + key, + self.source_schema, + self.target_schema, + &self.digest, + state, + )?; + let expected = decode_hex_32(&expected)?; + let actual = decode_hex_32(&self.tag)?; + if !constant_time_eq(&expected, &actual) { + return Err(backup_invalid()); + } + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + let connection = + Connection::open_with_flags(&self.backup, flags).map_err(|_| backup_invalid())?; + let integrity: String = connection + .pragma_query_value(None, "quick_check", |row| row.get(0)) + .map_err(|_| backup_invalid())?; + if integrity != "ok" { + return Err(backup_invalid()); + } + Ok(()) + } + + fn write_marker(&self, state: &str, key: &[u8]) -> Result<(), SafeError> { + let tag = authentication_tag( + key, + self.source_schema, + self.target_schema, + &self.digest, + state, + )?; + let content = format!( + "{MANIFEST_FORMAT}\nsource_schema={}\ntarget_schema={}\nbackup={}\nsha256={}\nstate={state}\nhmac_sha256={tag}\n", + self.source_schema, + self.target_schema, + self.backup + .file_name() + .ok_or_else(backup_invalid)? + .to_string_lossy(), + self.digest, + ); + atomic_secure_write(&self.marker, content.as_bytes()) + } +} + +fn recovery_directory(database_path: &Path) -> Result<PathBuf, SafeError> { + let filename = database_path + .file_name() + .ok_or_else(storage_error)? + .to_string_lossy(); + Ok(database_path.with_file_name(format!("{filename}.{RECOVERY_DIRECTORY_SUFFIX}"))) +} + +fn create_recovery_directory(directory: &Path) -> Result<(), SafeError> { + match fs::symlink_metadata(directory) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(storage_error()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(directory).map_err(|_| storage_error())?; + } + Err(_) => return Err(storage_error()), + } + restrict_directory_permissions(directory) +} + +fn load_or_create_authentication_key(directory: &Path) -> Result<Zeroizing<Vec<u8>>, SafeError> { + let path = directory.join(AUTHENTICATION_KEY_FILENAME); + if path.try_exists().map_err(|_| storage_error())? { + return load_authentication_key(directory); + } + let mut key = Zeroizing::new(vec![0_u8; 32]); + getrandom::getrandom(&mut key).map_err(|_| storage_error())?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags( + i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) + .map_err(|_| storage_error())?, + ); + } + match options.open(&path) { + Ok(mut file) => { + file.write_all(&key).map_err(|_| storage_error())?; + file.sync_all().map_err(|_| storage_error())?; + restrict_file_permissions(&path)?; + Ok(key) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + load_authentication_key(directory) + } + Err(_) => Err(storage_error()), + } +} + +fn load_authentication_key(directory: &Path) -> Result<Zeroizing<Vec<u8>>, SafeError> { + let path = directory.join(AUTHENTICATION_KEY_FILENAME); + let key = read_bounded_file(&path, 32)?; + if key.len() != 32 { + return Err(backup_invalid()); + } + Ok(Zeroizing::new(key)) +} + +fn create_verified_backup(source: &Path, destination: &Path) -> Result<(), SafeError> { + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + let connection = Connection::open_with_flags(source, flags).map_err(|_| backup_invalid())?; + connection + .backup(MAIN_DB, destination, None) + .map_err(|_| backup_invalid())?; + restrict_file_permissions(destination)?; + File::open(destination) + .and_then(|file| file.sync_all()) + .map_err(|_| backup_invalid()) +} + +fn replace_with_backup(database_path: &Path, backup: &Path) -> Result<(), SafeError> { + let parent = database_path.parent().ok_or_else(storage_error)?; + let mut suffix = [0_u8; 8]; + getrandom::getrandom(&mut suffix).map_err(|_| storage_error())?; + let replacement = parent.join(format!(".database-restore-{}.tmp", hex(&suffix))); + let displaced = parent.join(format!(".database-displaced-{}.sqlite3", hex(&suffix))); + let mut source = secure_read(backup)?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags( + i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) + .map_err(|_| storage_error())?, + ); + } + let result = (|| { + let mut destination = options.open(&replacement).map_err(|_| storage_error())?; + std::io::copy(&mut source, &mut destination).map_err(|_| storage_error())?; + destination.sync_all().map_err(|_| storage_error())?; + restrict_file_permissions(&replacement)?; + fs::rename(database_path, &displaced).map_err(|_| storage_error())?; + if fs::rename(&replacement, database_path).is_err() { + let _ = fs::rename(&displaced, database_path); + return Err(storage_error()); + } + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| storage_error()) + })(); + if result.is_err() { + let _ = fs::remove_file(&replacement); + } + result +} + +fn secure_read(path: &Path) -> Result<File, SafeError> { + let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(backup_invalid()); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags( + i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) + .map_err(|_| backup_invalid())?, + ); + } + options.open(path).map_err(|_| backup_invalid()) +} + +fn file_digest(path: &Path) -> Result<String, SafeError> { + let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(backup_invalid()); + } + let mut file = secure_read(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|_| backup_invalid())?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(hex(&digest.finalize())) +} + +fn read_bounded_file(path: &Path, limit: usize) -> Result<Vec<u8>, SafeError> { + let metadata = fs::symlink_metadata(path).map_err(|_| backup_invalid())?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || usize::try_from(metadata.len()).map_err(|_| backup_invalid())? > limit + { + return Err(backup_invalid()); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags( + i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) + .map_err(|_| backup_invalid())?, + ); + } + let file = options.open(path).map_err(|_| backup_invalid())?; + let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(0)); + file.take(u64::try_from(limit).map_err(|_| backup_invalid())? + 1) + .read_to_end(&mut bytes) + .map_err(|_| backup_invalid())?; + if bytes.len() > limit { + return Err(backup_invalid()); + } + Ok(bytes) +} + +fn atomic_secure_write(path: &Path, bytes: &[u8]) -> Result<(), SafeError> { + let parent = path.parent().ok_or_else(storage_error)?; + let mut suffix = [0_u8; 8]; + getrandom::getrandom(&mut suffix).map_err(|_| storage_error())?; + let temporary = parent.join(format!(".marker-{}.tmp", hex(&suffix))); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags( + i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) + .map_err(|_| storage_error())?, + ); + } + let result = (|| { + let mut file = options.open(&temporary).map_err(|_| storage_error())?; + file.write_all(bytes).map_err(|_| storage_error())?; + file.sync_all().map_err(|_| storage_error())?; + restrict_file_permissions(&temporary)?; + fs::rename(&temporary, path).map_err(|_| storage_error())?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| storage_error()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn authentication_tag( + key: &[u8], + source_schema: u32, + target_schema: u32, + digest: &str, + state: &str, +) -> Result<String, SafeError> { + let mut mac = HmacSha256::new_from_slice(key).map_err(|_| backup_invalid())?; + mac.update(MANIFEST_FORMAT.as_bytes()); + mac.update(&source_schema.to_be_bytes()); + mac.update(&target_schema.to_be_bytes()); + mac.update(digest.as_bytes()); + mac.update(state.as_bytes()); + Ok(hex(&mac.finalize().into_bytes())) +} + +fn parse_field<'a>( + lines: &mut impl Iterator<Item = &'a str>, + name: &str, +) -> Result<String, SafeError> { + lines + .next() + .and_then(|line| line.strip_prefix(name)) + .and_then(|value| value.strip_prefix('=')) + .map(str::to_owned) + .ok_or_else(backup_invalid) +} + +fn decode_hex_32(value: &str) -> Result<[u8; 32], SafeError> { + if value.len() != 64 { + return Err(backup_invalid()); + } + let mut bytes = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = hex_nibble(pair[0]).ok_or_else(backup_invalid)?; + let low = hex_nibble(pair[1]).ok_or_else(backup_invalid)?; + bytes[index] = (high << 4) | low; + } + Ok(bytes) +} + +const fn hex_nibble(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool { + left.iter() + .zip(right) + .fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) + == 0 +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database recovery path is unavailable."), + ) +} + +const fn backup_invalid() -> SafeError { + SafeError::new( + SafeErrorCode::StorageBackupInvalid, + SafeMessage::new("The application database recovery backup is invalid."), + ) +} diff --git a/crates/studio_storage/src/repair.rs b/crates/studio_storage/src/repair.rs @@ -0,0 +1,260 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use hmac::{Hmac, Mac}; +use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; +use rusqlite::{Connection, MAIN_DB, OpenFlags}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::compatibility::{DatabasePreflight, preflight}; +use crate::db::{CURRENT_SCHEMA_VERSION, restrict_file_permissions}; + +type HmacSha256 = Hmac<Sha256>; +const EXPORT_DOMAIN: &[u8] = b"radroots-studio-quarantine-export-v1"; +const REPAIR_DOMAIN: &[u8] = b"radroots-studio-repair-candidate-v1"; + +pub struct RepairAuthorization(Zeroizing<[u8; 32]>); + +impl RepairAuthorization { + /// Moves an exact 256-bit caller authorization secret into zeroizing storage. + /// + /// # Errors + /// + /// Returns a safe authorization error for every other input length. + pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, SafeError> { + let bytes = Zeroizing::new(bytes); + let value = <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| unauthorized())?; + Ok(Self(Zeroizing::new(value))) + } + + fn expose(&self) -> &[u8; 32] { + &self.0 + } +} + +pub struct QuarantineExportReceipt { + path: PathBuf, + sha256: String, + authentication_tag: String, +} + +impl QuarantineExportReceipt { + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + #[must_use] + pub fn sha256(&self) -> &str { + &self.sha256 + } + + #[must_use] + pub fn authentication_tag(&self) -> &str { + &self.authentication_tag + } +} + +pub struct RepairCandidate { + path: PathBuf, + sha256: String, + authentication_tag: String, +} + +impl RepairCandidate { + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } +} + +pub(crate) fn export_quarantined( + source: &Path, + destination: &Path, + authorization: &RepairAuthorization, +) -> Result<QuarantineExportReceipt, SafeError> { + if !matches!(preflight(source)?, DatabasePreflight::Quarantined { .. }) { + return Err(not_quarantined()); + } + ensure_new_destination(destination)?; + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + let connection = Connection::open_with_flags(source, flags).map_err(|_| storage_error())?; + if connection.backup(MAIN_DB, destination, None).is_err() { + let _ = fs::remove_file(destination); + return Err(storage_error()); + } + restrict_file_permissions(destination)?; + File::open(destination) + .and_then(|file| file.sync_all()) + .map_err(|_| storage_error())?; + let sha256 = digest_file(destination)?; + let authentication_tag = authenticate(authorization, EXPORT_DOMAIN, &sha256)?; + Ok(QuarantineExportReceipt { + path: destination.to_path_buf(), + sha256, + authentication_tag, + }) +} + +pub(crate) fn authenticate_candidate( + path: &Path, + authorization: &RepairAuthorization, +) -> Result<RepairCandidate, SafeError> { + if !matches!( + preflight(path)?, + DatabasePreflight::Ready { schema_version } if schema_version <= CURRENT_SCHEMA_VERSION + ) { + return Err(storage_error()); + } + let sha256 = digest_file(path)?; + let authentication_tag = authenticate(authorization, REPAIR_DOMAIN, &sha256)?; + Ok(RepairCandidate { + path: path.to_path_buf(), + sha256, + authentication_tag, + }) +} + +pub(crate) fn install_candidate( + target: &Path, + candidate: &RepairCandidate, + authorization: &RepairAuthorization, +) -> Result<(), SafeError> { + if !matches!(preflight(target)?, DatabasePreflight::Quarantined { .. }) { + return Err(not_quarantined()); + } + let digest = digest_file(&candidate.path)?; + if digest != candidate.sha256 + || authenticate(authorization, REPAIR_DOMAIN, &digest)? != candidate.authentication_tag + { + return Err(unauthorized()); + } + if !matches!(preflight(&candidate.path)?, DatabasePreflight::Ready { .. }) { + return Err(storage_error()); + } + let parent = target.parent().ok_or_else(storage_error)?; + let replacement = parent.join(".authenticated-repair.tmp"); + if replacement.try_exists().map_err(|_| storage_error())? { + return Err(storage_error()); + } + copy_secure(&candidate.path, &replacement)?; + let retained = parent.join("studio.sqlite3.quarantined-evidence"); + if retained.try_exists().map_err(|_| storage_error())? { + let _ = fs::remove_file(&replacement); + return Err(storage_error()); + } + fs::rename(target, &retained).map_err(|_| storage_error())?; + if fs::rename(&replacement, target).is_err() { + let _ = fs::rename(&retained, target); + let _ = fs::remove_file(&replacement); + return Err(storage_error()); + } + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| storage_error()) +} + +fn ensure_new_destination(path: &Path) -> Result<(), SafeError> { + if path.try_exists().map_err(|_| storage_error())? { + return Err(storage_error()); + } + let parent = path.parent().ok_or_else(storage_error)?; + let metadata = fs::symlink_metadata(parent).map_err(|_| storage_error())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(storage_error()); + } + Ok(()) +} + +fn copy_secure(source: &Path, destination_path: &Path) -> Result<(), SafeError> { + let mut source = secure_read(source)?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags( + i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) + .map_err(|_| storage_error())?, + ); + } + let mut destination = options + .open(destination_path) + .map_err(|_| storage_error())?; + std::io::copy(&mut source, &mut destination).map_err(|_| storage_error())?; + destination.sync_all().map_err(|_| storage_error())?; + restrict_file_permissions(destination_path) +} + +fn secure_read(path: &Path) -> Result<File, SafeError> { + let metadata = fs::symlink_metadata(path).map_err(|_| storage_error())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(storage_error()); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags( + i32::try_from((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits()) + .map_err(|_| storage_error())?, + ); + } + options.open(path).map_err(|_| storage_error()) +} + +fn digest_file(path: &Path) -> Result<String, SafeError> { + let mut file = secure_read(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|_| storage_error())?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(hex(&digest.finalize())) +} + +fn authenticate( + authorization: &RepairAuthorization, + domain: &[u8], + digest: &str, +) -> Result<String, SafeError> { + let mut hmac = + HmacSha256::new_from_slice(authorization.expose()).map_err(|_| unauthorized())?; + hmac.update(domain); + hmac.update(digest.as_bytes()); + Ok(hex(&hmac.finalize().into_bytes())) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +const fn unauthorized() -> SafeError { + SafeError::new( + SafeErrorCode::RepairUnauthorized, + SafeMessage::new("The database repair authorization is invalid."), + ) +} + +const fn not_quarantined() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The database is not in quarantine."), + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The database repair operation could not be completed."), + ) +} diff --git a/crates/studio_storage/tests/redaction.rs b/crates/studio_storage/tests/redaction.rs @@ -30,7 +30,7 @@ fn redaction_guards_sqlite_schema_and_non_secret_records() { let path = directory.path().join("studio.sqlite3"); { let database = Database::open(&path).expect("database"); - let public_key = PublicKey::from_bytes([2; 32]); + let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); let account = AccountSummary::new( AccountIdentity::derive(public_key).expect("identity"), LocalSignerBinding::new(public_key, BindingAvailability::Available),