From 429987785ed2edf935c0ed3c01efbf43103685ea Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 9 Aug 2026 19:34:34 +0200 Subject: [PATCH] feat(core): protect secrets on native platforms --- Cargo.lock | 282 ++++++++++- crates/vnidrop/Cargo.toml | 14 + crates/vnidrop/src/secure_secret.rs | 41 ++ crates/vnidrop/src/secure_secret/android.rs | 430 ++++++++++++++++ .../src/secure_secret/android_native.rs | 431 ++++++++++++++++ crates/vnidrop/src/secure_secret/apple.rs | 372 ++++++++++++++ crates/vnidrop/src/secure_secret/linux.rs | 310 ++++++++++++ crates/vnidrop/src/secure_secret/windows.rs | 472 ++++++++++++++++++ .../src/secure_secret/windows_tests.rs | 187 +++++++ 9 files changed, 2536 insertions(+), 3 deletions(-) create mode 100644 crates/vnidrop/src/secure_secret/android.rs create mode 100644 crates/vnidrop/src/secure_secret/android_native.rs create mode 100644 crates/vnidrop/src/secure_secret/apple.rs create mode 100644 crates/vnidrop/src/secure_secret/linux.rs create mode 100644 crates/vnidrop/src/secure_secret/windows.rs create mode 100644 crates/vnidrop/src/secure_secret/windows_tests.rs diff --git a/Cargo.lock b/Cargo.lock index c2f9206..4eb10ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,6 +219,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -244,6 +256,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -421,6 +444,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -483,6 +515,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.65" @@ -864,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -1049,6 +1090,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-assoc" version = "1.3.0" @@ -1060,6 +1107,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1930,6 +1998,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -2511,6 +2580,15 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2811,6 +2889,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2837,6 +2929,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2863,6 +2964,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3032,6 +3144,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "papaya" version = "0.2.4" @@ -3866,6 +3988,25 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3974,6 +4115,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -4485,6 +4637,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn-mid" version = "0.5.4" @@ -4550,7 +4713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4684,6 +4847,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4977,6 +5141,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5233,9 +5408,13 @@ dependencies = [ "iroh-relay", "irpc", "irpc-iroh", + "jni 0.21.1", "libc", "n0-future", + "ndk-context", "num_cpus", + "secret-service", + "security-framework", "serde", "serde_json", "sqlx", @@ -5248,6 +5427,7 @@ dependencies = [ "uniffi", "uuid", "walkdir", + "windows-sys 0.61.2", ] [[package]] @@ -5443,7 +5623,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5917,6 +6097,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -6016,3 +6252,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml index 20f25bd..37c11be 100644 --- a/crates/vnidrop/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -36,6 +36,20 @@ uniffi = { version = "=0.29.4", features = ["tokio"] } uuid = { version = "1.23.3", features = ["v4", "serde"] } walkdir = "2.5.0" +[target.'cfg(target_os = "linux")'.dependencies] +secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] } + +[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] +security-framework = { version = "3.7.0", features = ["OSX_10_15"] } + +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21.1" +ndk-context = "0.1.1" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_Storage_FileSystem"] } + [dev-dependencies] iroh-relay = { version = "1.0.3", features = ["server"] } +secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] } tempfile = "3.27.0" diff --git a/crates/vnidrop/src/secure_secret.rs b/crates/vnidrop/src/secure_secret.rs index efe3225..985bb24 100644 --- a/crates/vnidrop/src/secure_secret.rs +++ b/crates/vnidrop/src/secure_secret.rs @@ -10,6 +10,15 @@ use uuid::Uuid; use crate::{error::VnidropError, util::now_ms}; +#[cfg(any(test, target_os = "android"))] +mod android; +#[cfg(any(target_os = "macos", target_os = "ios"))] +mod apple; +#[cfg(any(test, target_os = "linux"))] +mod linux; +#[cfg(target_os = "windows")] +mod windows; + const SECRET_BYTES: usize = 32; const HANDLE_NAMESPACE: &str = "vnidrop"; const HANDLE_VERSION: &str = "v1"; @@ -120,6 +129,38 @@ pub(crate) trait SecureSecretStore: Send + Sync { fn list_handles(&self) -> Result, SecureSecretStoreError>; } +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub(crate) fn platform_secret_store( + _app_data_dir: &Path, +) -> Result, VnidropError> { + Ok(Arc::new(apple::AppleKeychainSecretStore::new())) +} + +#[cfg(target_os = "android")] +pub(crate) fn platform_secret_store( + _app_data_dir: &Path, +) -> Result, VnidropError> { + android::native::create_store_from_android_runtime().map_err(map_store_error) +} + +#[cfg(target_os = "windows")] +pub(crate) fn platform_secret_store( + app_data_dir: &Path, +) -> Result, VnidropError> { + windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1")) + .map(|store| Arc::new(store) as Arc) + .map_err(map_store_error) +} + +#[cfg(target_os = "linux")] +pub(crate) fn platform_secret_store( + _app_data_dir: &Path, +) -> Result, VnidropError> { + linux::LinuxSecretServiceStore::connect() + .map(|store| Arc::new(store) as Arc) + .map_err(map_store_error) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SecretMetadataState { Staged, diff --git a/crates/vnidrop/src/secure_secret/android.rs b/crates/vnidrop/src/secure_secret/android.rs new file mode 100644 index 0000000..3fb5099 --- /dev/null +++ b/crates/vnidrop/src/secure_secret/android.rs @@ -0,0 +1,430 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use data_encoding::HEXLOWER; + +use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError}; + +const RECORD_MAGIC: &[u8; 8] = b"VNDASK01"; +const RECORD_STAGED: u8 = 0; +const RECORD_SEALED: u8 = 1; +const RECORD_EXTENSION: &str = "vns"; +const KEY_ALIAS_PREFIX: &str = "vnidrop.secret.v1."; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AndroidSealedValue { + pub(crate) nonce: Vec, + pub(crate) ciphertext: Vec, +} + +/// Performs AES-GCM operations with a non-exportable key held by Android Keystore. +/// +/// Implementations create one key per alias, let Keystore generate the encryption +/// nonce, and never return key material to Rust. +pub(crate) trait AndroidKeystore: Send + Sync { + fn seal( + &self, + alias: &str, + plaintext: &[u8], + ) -> Result; + fn open( + &self, + alias: &str, + sealed: &AndroidSealedValue, + ) -> Result, SecureSecretStoreError>; + fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError>; +} + +/// Android secret-store adapter whose ordinary storage contains authenticated +/// ciphertext only. +/// +/// `no_backup_dir` must be the directory returned by Android +/// `Context.getNoBackupFilesDir()`. The Android host owns acquiring that Context; +/// secret values never cross that initialization boundary. +pub(crate) struct AndroidSecureSecretStore { + records_dir: PathBuf, + keystore: Arc, +} + +impl AndroidSecureSecretStore { + pub(crate) fn new( + no_backup_dir: &Path, + keystore: Arc, + ) -> Result { + if !no_backup_dir.is_absolute() { + return Err(SecureSecretStoreError::Unavailable); + } + let records_dir = no_backup_dir.join("vnidrop-protected-secrets-v1"); + fs::create_dir_all(&records_dir).map_err(map_io_error)?; + set_private_directory_permissions(&records_dir)?; + Ok(Self { + records_dir, + keystore, + }) + } + + fn record_path(&self, handle: &SecretHandle) -> PathBuf { + let encoded = HEXLOWER.encode(handle.as_str().as_bytes()); + self.records_dir + .join(format!("{encoded}.{RECORD_EXTENSION}")) + } + + fn alias(handle: &SecretHandle) -> String { + let digest = blake3::hash(handle.as_str().as_bytes()); + format!("{KEY_ALIAS_PREFIX}{}", HEXLOWER.encode(digest.as_bytes())) + } + + fn write_record( + &self, + handle: &SecretHandle, + state: u8, + sealed: Option<&AndroidSealedValue>, + ) -> Result<(), SecureSecretStoreError> { + let bytes = encode_record(handle, state, sealed)?; + let path = self.record_path(handle); + let temporary = path.with_extension(format!("{RECORD_EXTENSION}.tmp")); + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&temporary) + .map_err(map_io_error)?; + set_private_file_permissions(&temporary)?; + file.write_all(&bytes).map_err(map_io_error)?; + file.sync_all().map_err(map_io_error)?; + fs::rename(&temporary, &path).map_err(map_io_error)?; + sync_directory(&self.records_dir)?; + Ok(()) + } + + fn read_record( + &self, + handle: &SecretHandle, + ) -> Result { + let mut bytes = Vec::new(); + File::open(self.record_path(handle)) + .map_err(map_io_error)? + .read_to_end(&mut bytes) + .map_err(map_io_error)?; + decode_record(&bytes, handle) + } +} + +impl SecureSecretStore for AndroidSecureSecretStore { + fn put( + &self, + handle: &SecretHandle, + material: SecretMaterial, + ) -> Result<(), SecureSecretStoreError> { + self.write_record(handle, RECORD_STAGED, None)?; + let alias = Self::alias(handle); + let sealed = self.keystore.seal(&alias, &material.0)?; + if sealed.nonce.is_empty() || sealed.ciphertext.is_empty() { + return Err(SecureSecretStoreError::Corrupted); + } + self.write_record(handle, RECORD_SEALED, Some(&sealed)) + } + + fn get(&self, handle: &SecretHandle) -> Result { + let sealed = self.read_record(handle)?; + let plaintext = self.keystore.open(&Self::alias(handle), &sealed)?; + SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted) + } + + fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { + match self.keystore.delete(&Self::alias(handle)) { + Ok(()) | Err(SecureSecretStoreError::Missing) => {} + Err(error) => return Err(error), + } + let path = self.record_path(handle); + match fs::remove_file(path) { + Ok(()) => sync_directory(&self.records_dir)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(map_io_error(error)), + } + Ok(()) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + let mut handles = Vec::new(); + for entry in fs::read_dir(&self.records_dir).map_err(map_io_error)? { + let entry = entry.map_err(map_io_error)?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some(RECORD_EXTENSION) { + continue; + } + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or(SecureSecretStoreError::Corrupted)?; + let decoded = HEXLOWER + .decode(stem.as_bytes()) + .map_err(|_| SecureSecretStoreError::Corrupted)?; + let handle = + String::from_utf8(decoded).map_err(|_| SecureSecretStoreError::Corrupted)?; + handles.push(SecretHandle(handle)); + } + handles.sort_by(|left, right| left.as_str().cmp(right.as_str())); + Ok(handles) + } +} + +fn encode_record( + handle: &SecretHandle, + state: u8, + sealed: Option<&AndroidSealedValue>, +) -> Result, SecureSecretStoreError> { + let handle_bytes = handle.as_str().as_bytes(); + let handle_len = + u16::try_from(handle_bytes.len()).map_err(|_| SecureSecretStoreError::Corrupted)?; + let (nonce, ciphertext) = match (state, sealed) { + (RECORD_STAGED, None) => (&[][..], &[][..]), + (RECORD_SEALED, Some(value)) => (value.nonce.as_slice(), value.ciphertext.as_slice()), + _ => return Err(SecureSecretStoreError::Corrupted), + }; + let nonce_len = u16::try_from(nonce.len()).map_err(|_| SecureSecretStoreError::Corrupted)?; + let ciphertext_len = + u32::try_from(ciphertext.len()).map_err(|_| SecureSecretStoreError::Corrupted)?; + let mut record = Vec::with_capacity( + RECORD_MAGIC.len() + 1 + 2 + 2 + 4 + handle_bytes.len() + nonce.len() + ciphertext.len(), + ); + record.extend_from_slice(RECORD_MAGIC); + record.push(state); + record.extend_from_slice(&handle_len.to_be_bytes()); + record.extend_from_slice(&nonce_len.to_be_bytes()); + record.extend_from_slice(&ciphertext_len.to_be_bytes()); + record.extend_from_slice(handle_bytes); + record.extend_from_slice(nonce); + record.extend_from_slice(ciphertext); + Ok(record) +} + +fn decode_record( + bytes: &[u8], + expected_handle: &SecretHandle, +) -> Result { + const HEADER_LEN: usize = 8 + 1 + 2 + 2 + 4; + if bytes.len() < HEADER_LEN || &bytes[..8] != RECORD_MAGIC { + return Err(SecureSecretStoreError::Corrupted); + } + let state = bytes[8]; + let handle_len = usize::from(u16::from_be_bytes([bytes[9], bytes[10]])); + let nonce_len = usize::from(u16::from_be_bytes([bytes[11], bytes[12]])); + let ciphertext_len = usize::try_from(u32::from_be_bytes([ + bytes[13], bytes[14], bytes[15], bytes[16], + ])) + .map_err(|_| SecureSecretStoreError::Corrupted)?; + let expected_len = HEADER_LEN + .checked_add(handle_len) + .and_then(|value| value.checked_add(nonce_len)) + .and_then(|value| value.checked_add(ciphertext_len)) + .ok_or(SecureSecretStoreError::Corrupted)?; + if bytes.len() != expected_len || state != RECORD_SEALED { + return Err(SecureSecretStoreError::Corrupted); + } + let handle_end = HEADER_LEN + handle_len; + let handle = std::str::from_utf8(&bytes[HEADER_LEN..handle_end]) + .map_err(|_| SecureSecretStoreError::Corrupted)?; + if handle != expected_handle.as_str() { + return Err(SecureSecretStoreError::Corrupted); + } + let nonce_end = handle_end + nonce_len; + if nonce_len == 0 || ciphertext_len == 0 { + return Err(SecureSecretStoreError::Corrupted); + } + Ok(AndroidSealedValue { + nonce: bytes[handle_end..nonce_end].to_vec(), + ciphertext: bytes[nonce_end..].to_vec(), + }) +} + +fn map_io_error(error: std::io::Error) -> SecureSecretStoreError { + match error.kind() { + std::io::ErrorKind::NotFound => SecureSecretStoreError::Missing, + std::io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted, + _ => SecureSecretStoreError::Unavailable, + } +} + +fn sync_directory(path: &Path) -> Result<(), SecureSecretStoreError> { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(map_io_error) +} + +#[cfg(unix)] +fn set_private_directory_permissions(path: &Path) -> Result<(), SecureSecretStoreError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(map_io_error) +} + +#[cfg(not(unix))] +fn set_private_directory_permissions(_path: &Path) -> Result<(), SecureSecretStoreError> { + Ok(()) +} + +#[cfg(unix)] +fn set_private_file_permissions(path: &Path) -> Result<(), SecureSecretStoreError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(map_io_error) +} + +#[cfg(not(unix))] +fn set_private_file_permissions(_path: &Path) -> Result<(), SecureSecretStoreError> { + Ok(()) +} + +#[cfg(target_os = "android")] +#[path = "android_native.rs"] +pub(crate) mod native; + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, sync::Mutex}; + + use tempfile::TempDir; + + use super::*; + use crate::secure_secret::SECRET_BYTES; + + #[derive(Default)] + struct FakeKeystore { + keys: Mutex>, + delete_failure: Mutex>, + } + + impl AndroidKeystore for FakeKeystore { + fn seal( + &self, + alias: &str, + plaintext: &[u8], + ) -> Result { + let mask = 0xa7; + self.keys.lock().unwrap().insert(alias.to_string(), mask); + Ok(AndroidSealedValue { + nonce: vec![4; 12], + ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(), + }) + } + + fn open( + &self, + alias: &str, + sealed: &AndroidSealedValue, + ) -> Result, SecureSecretStoreError> { + let mask = *self + .keys + .lock() + .unwrap() + .get(alias) + .ok_or(SecureSecretStoreError::Missing)?; + Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect()) + } + + fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> { + if let Some(error) = self.delete_failure.lock().unwrap().take() { + return Err(error); + } + self.keys.lock().unwrap().remove(alias); + Ok(()) + } + } + + fn fixture() -> (TempDir, AndroidSecureSecretStore, Arc) { + let directory = TempDir::new().unwrap(); + let keystore = Arc::new(FakeKeystore::default()); + let store = AndroidSecureSecretStore::new(directory.path(), keystore.clone()).unwrap(); + (directory, store, keystore) + } + + fn handle() -> SecretHandle { + SecretHandle("vnidrop/v1/endpoint-identity/test".to_string()) + } + + #[test] + fn adapter_round_trips_lists_and_deletes_without_plaintext_persistence() { + let (directory, store, keystore) = fixture(); + let handle = handle(); + let plaintext = vec![0x5a; SECRET_BYTES]; + + store + .put(&handle, SecretMaterial::new(plaintext.clone()).unwrap()) + .unwrap(); + + let persisted = fs::read(store.record_path(&handle)).unwrap(); + assert!(!persisted + .windows(plaintext.len()) + .any(|window| window == plaintext)); + + drop(store); + let restarted = AndroidSecureSecretStore::new(directory.path(), keystore).unwrap(); + assert_eq!(restarted.list_handles().unwrap(), vec![handle.clone()]); + assert_eq!(restarted.get(&handle).unwrap().0, plaintext); + + restarted.delete(&handle).unwrap(); + assert!(restarted.list_handles().unwrap().is_empty()); + assert!(matches!( + restarted.get(&handle), + Err(SecureSecretStoreError::Missing) + )); + } + + #[test] + fn staged_crash_record_remains_discoverable_and_fails_closed() { + let (_directory, store, _keystore) = fixture(); + let handle = handle(); + store.write_record(&handle, RECORD_STAGED, None).unwrap(); + + assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]); + assert!(matches!( + store.get(&handle), + Err(SecureSecretStoreError::Corrupted) + )); + store.delete(&handle).unwrap(); + assert!(store.list_handles().unwrap().is_empty()); + } + + #[test] + fn tampering_and_missing_keystore_keys_are_distinct_failures() { + let (_directory, store, keystore) = fixture(); + let handle = handle(); + store + .put(&handle, SecretMaterial::new(vec![9; SECRET_BYTES]).unwrap()) + .unwrap(); + + keystore.keys.lock().unwrap().clear(); + assert!(matches!( + store.get(&handle), + Err(SecureSecretStoreError::Missing) + )); + + fs::write(store.record_path(&handle), b"tampered").unwrap(); + assert!(matches!( + store.get(&handle), + Err(SecureSecretStoreError::Corrupted) + )); + } + + #[test] + fn failed_key_deletion_retains_the_record_for_safe_retry() { + let (_directory, store, keystore) = fixture(); + let handle = handle(); + store + .put(&handle, SecretMaterial::new(vec![7; SECRET_BYTES]).unwrap()) + .unwrap(); + *keystore.delete_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked); + + assert!(matches!( + store.delete(&handle), + Err(SecureSecretStoreError::Locked) + )); + assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]); + + store.delete(&handle).unwrap(); + assert!(store.list_handles().unwrap().is_empty()); + } +} diff --git a/crates/vnidrop/src/secure_secret/android_native.rs b/crates/vnidrop/src/secure_secret/android_native.rs new file mode 100644 index 0000000..0100e98 --- /dev/null +++ b/crates/vnidrop/src/secure_secret/android_native.rs @@ -0,0 +1,431 @@ +use jni::{ + errors::Error as JniError, + objects::{JByteArray, JObject, JString, JValue}, + JNIEnv, JavaVM, +}; + +use super::*; + +const ANDROID_KEYSTORE: &str = "AndroidKeyStore"; +const AES: &str = "AES"; +const TRANSFORMATION: &str = "AES/GCM/NoPadding"; + +/// JNI-backed Android Keystore engine. The VM pointer comes from the Android +/// runtime; no Context or secret bytes are exposed through UniFFI. +pub(crate) struct AndroidJniKeystore { + vm: JavaVM, +} + +impl AndroidJniKeystore { + /// Constructs the engine after the Android runtime has initialized + /// `ndk-context` with the process Java VM. + pub(crate) fn from_android_runtime() -> Result { + let context = std::panic::catch_unwind(ndk_context::android_context) + .map_err(|_| SecureSecretStoreError::Unavailable)?; + let vm = context.vm(); + if vm.is_null() { + return Err(SecureSecretStoreError::Unavailable); + } + // Android owns the process VM for longer than every core instance. + let vm = unsafe { JavaVM::from_raw(vm.cast()) } + .map_err(|_| SecureSecretStoreError::Unavailable)?; + Ok(Self { vm }) + } + + fn with_env( + &self, + operation: impl FnOnce(&mut JNIEnv<'_>) -> Result, + ) -> Result { + let mut env = self + .vm + .attach_current_thread() + .map_err(|_| SecureSecretStoreError::Unavailable)?; + operation(&mut env) + } + + fn no_backup_files_dir(&self) -> Result { + self.with_env(|env| { + let context = std::panic::catch_unwind(ndk_context::android_context) + .map_err(|_| SecureSecretStoreError::Unavailable)? + .context(); + if context.is_null() { + return Err(SecureSecretStoreError::Unavailable); + } + // ndk-context retains this process Context for the Android runtime lifetime. + let context = unsafe { JObject::from_raw(context.cast()) }; + let directory = env + .call_method(&context, "getNoBackupFilesDir", "()Ljava/io/File;", &[]) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + if directory.is_null() { + return Err(SecureSecretStoreError::Unavailable); + } + let path = env + .call_method(&directory, "getAbsolutePath", "()Ljava/lang/String;", &[]) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + let path = JString::from(path); + let path: String = env + .get_string(&path) + .map_err(|error| map_jni_error(env, error))? + .into(); + Ok(PathBuf::from(path)) + }) + } +} + +pub(crate) fn create_store_from_android_runtime( +) -> Result, SecureSecretStoreError> { + let keystore = Arc::new(AndroidJniKeystore::from_android_runtime()?); + let no_backup_dir = keystore.no_backup_files_dir()?; + Ok(Arc::new(AndroidSecureSecretStore::new( + &no_backup_dir, + keystore, + )?)) +} + +impl AndroidKeystore for AndroidJniKeystore { + fn seal( + &self, + alias: &str, + plaintext: &[u8], + ) -> Result { + self.with_env(|env| { + let key_store = load_key_store(env)?; + let alias_string = env + .new_string(alias) + .map_err(|error| map_jni_error(env, error))?; + let alias_object = JObject::from(alias_string); + let contains = env + .call_method( + &key_store, + "containsAlias", + "(Ljava/lang/String;)Z", + &[JValue::Object(&alias_object)], + ) + .map_err(|error| map_jni_error(env, error))? + .z() + .map_err(|error| map_jni_error(env, error))?; + if !contains { + generate_key(env, alias)?; + } + let key = get_key(env, &key_store, alias)?; + let cipher = cipher_instance(env)?; + env.call_method( + &cipher, + "init", + "(ILjava/security/Key;)V", + &[JValue::Int(1), JValue::Object(&key)], + ) + .map_err(|error| map_jni_error(env, error))?; + let plaintext = env + .byte_array_from_slice(plaintext) + .map_err(|error| map_jni_error(env, error))?; + let plaintext_object = JObject::from(plaintext); + let ciphertext = env + .call_method( + &cipher, + "doFinal", + "([B)[B", + &[JValue::Object(&plaintext_object)], + ) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + let nonce = env + .call_method(&cipher, "getIV", "()[B", &[]) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + Ok(AndroidSealedValue { + nonce: env + .convert_byte_array(JByteArray::from(nonce)) + .map_err(|error| map_jni_error(env, error))?, + ciphertext: env + .convert_byte_array(JByteArray::from(ciphertext)) + .map_err(|error| map_jni_error(env, error))?, + }) + }) + } + + fn open( + &self, + alias: &str, + sealed: &AndroidSealedValue, + ) -> Result, SecureSecretStoreError> { + self.with_env(|env| { + let key_store = load_key_store(env)?; + let key = get_key(env, &key_store, alias)?; + let cipher = cipher_instance(env)?; + let nonce = env + .byte_array_from_slice(&sealed.nonce) + .map_err(|error| map_jni_error(env, error))?; + let nonce_object = JObject::from(nonce); + let parameters = env + .new_object( + "javax/crypto/spec/GCMParameterSpec", + "(I[B)V", + &[JValue::Int(128), JValue::Object(&nonce_object)], + ) + .map_err(|error| map_jni_error(env, error))?; + env.call_method( + &cipher, + "init", + "(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V", + &[ + JValue::Int(2), + JValue::Object(&key), + JValue::Object(¶meters), + ], + ) + .map_err(|error| map_jni_error(env, error))?; + let ciphertext = env + .byte_array_from_slice(&sealed.ciphertext) + .map_err(|error| map_jni_error(env, error))?; + let ciphertext_object = JObject::from(ciphertext); + let plaintext = env + .call_method( + &cipher, + "doFinal", + "([B)[B", + &[JValue::Object(&ciphertext_object)], + ) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + env.convert_byte_array(JByteArray::from(plaintext)) + .map_err(|error| map_jni_error(env, error)) + }) + } + + fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> { + self.with_env(|env| { + let key_store = load_key_store(env)?; + let alias = env + .new_string(alias) + .map_err(|error| map_jni_error(env, error))?; + let alias_object = JObject::from(alias); + env.call_method( + &key_store, + "deleteEntry", + "(Ljava/lang/String;)V", + &[JValue::Object(&alias_object)], + ) + .map_err(|error| map_jni_error(env, error))?; + Ok(()) + }) + } +} + +fn load_key_store<'local>( + env: &mut JNIEnv<'local>, +) -> Result, SecureSecretStoreError> { + let provider = env + .new_string(ANDROID_KEYSTORE) + .map_err(|error| map_jni_error(env, error))?; + let provider_object = JObject::from(provider); + let key_store = env + .call_static_method( + "java/security/KeyStore", + "getInstance", + "(Ljava/lang/String;)Ljava/security/KeyStore;", + &[JValue::Object(&provider_object)], + ) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + env.call_method( + &key_store, + "load", + "(Ljava/security/KeyStore$LoadStoreParameter;)V", + &[JValue::Object(&JObject::null())], + ) + .map_err(|error| map_jni_error(env, error))?; + Ok(key_store) +} + +fn generate_key(env: &mut JNIEnv<'_>, alias: &str) -> Result<(), SecureSecretStoreError> { + let algorithm = env + .new_string(AES) + .map_err(|error| map_jni_error(env, error))?; + let provider = env + .new_string(ANDROID_KEYSTORE) + .map_err(|error| map_jni_error(env, error))?; + let algorithm_object = JObject::from(algorithm); + let provider_object = JObject::from(provider); + let generator = env + .call_static_method( + "javax/crypto/KeyGenerator", + "getInstance", + "(Ljava/lang/String;Ljava/lang/String;)Ljavax/crypto/KeyGenerator;", + &[ + JValue::Object(&algorithm_object), + JValue::Object(&provider_object), + ], + ) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + let alias = env + .new_string(alias) + .map_err(|error| map_jni_error(env, error))?; + let alias_object = JObject::from(alias); + let builder = env + .new_object( + "android/security/keystore/KeyGenParameterSpec$Builder", + "(Ljava/lang/String;I)V", + &[JValue::Object(&alias_object), JValue::Int(3)], + ) + .map_err(|error| map_jni_error(env, error))?; + let modes = java_string_array(env, "GCM")?; + env.call_method( + &builder, + "setBlockModes", + "([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;", + &[JValue::Object(&modes)], + ) + .map_err(|error| map_jni_error(env, error))?; + let paddings = java_string_array(env, "NoPadding")?; + env.call_method( + &builder, + "setEncryptionPaddings", + "([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;", + &[JValue::Object(&paddings)], + ) + .map_err(|error| map_jni_error(env, error))?; + env.call_method( + &builder, + "setKeySize", + "(I)Landroid/security/keystore/KeyGenParameterSpec$Builder;", + &[JValue::Int(256)], + ) + .map_err(|error| map_jni_error(env, error))?; + env.call_method( + &builder, + "setRandomizedEncryptionRequired", + "(Z)Landroid/security/keystore/KeyGenParameterSpec$Builder;", + &[JValue::Bool(1)], + ) + .map_err(|error| map_jni_error(env, error))?; + let parameters = env + .call_method( + &builder, + "build", + "()Landroid/security/keystore/KeyGenParameterSpec;", + &[], + ) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + env.call_method( + &generator, + "init", + "(Ljava/security/spec/AlgorithmParameterSpec;)V", + &[JValue::Object(¶meters)], + ) + .map_err(|error| map_jni_error(env, error))?; + env.call_method(&generator, "generateKey", "()Ljavax/crypto/SecretKey;", &[]) + .map_err(|error| map_jni_error(env, error))?; + Ok(()) +} + +fn get_key<'local>( + env: &mut JNIEnv<'local>, + key_store: &JObject<'local>, + alias: &str, +) -> Result, SecureSecretStoreError> { + let alias = env + .new_string(alias) + .map_err(|error| map_jni_error(env, error))?; + let alias_object = JObject::from(alias); + let key = env + .call_method( + key_store, + "getKey", + "(Ljava/lang/String;[C)Ljava/security/Key;", + &[ + JValue::Object(&alias_object), + JValue::Object(&JObject::null()), + ], + ) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error))?; + if key.is_null() { + return Err(SecureSecretStoreError::Missing); + } + Ok(key) +} + +fn cipher_instance<'local>( + env: &mut JNIEnv<'local>, +) -> Result, SecureSecretStoreError> { + let transformation = env + .new_string(TRANSFORMATION) + .map_err(|error| map_jni_error(env, error))?; + let transformation_object = JObject::from(transformation); + env.call_static_method( + "javax/crypto/Cipher", + "getInstance", + "(Ljava/lang/String;)Ljavax/crypto/Cipher;", + &[JValue::Object(&transformation_object)], + ) + .map_err(|error| map_jni_error(env, error))? + .l() + .map_err(|error| map_jni_error(env, error)) +} + +fn java_string_array<'local>( + env: &mut JNIEnv<'local>, + value: &str, +) -> Result, SecureSecretStoreError> { + let class = env + .find_class("java/lang/String") + .map_err(|error| map_jni_error(env, error))?; + let array = env + .new_object_array(1, class, JObject::null()) + .map_err(|error| map_jni_error(env, error))?; + let value = env + .new_string(value) + .map_err(|error| map_jni_error(env, error))?; + env.set_object_array_element(&array, 0, value) + .map_err(|error| map_jni_error(env, error))?; + Ok(JObject::from(array)) +} + +fn map_jni_error(env: &mut JNIEnv<'_>, _error: JniError) -> SecureSecretStoreError { + let has_exception = env.exception_check().unwrap_or(false); + if !has_exception { + return SecureSecretStoreError::Unavailable; + } + let exception = match env.exception_occurred() { + Ok(exception) => exception, + Err(_) => return SecureSecretStoreError::Unavailable, + }; + let _ = env.exception_clear(); + if is_instance_of( + env, + &exception, + "android/security/keystore/UserNotAuthenticatedException", + ) { + SecureSecretStoreError::Locked + } else if is_instance_of( + env, + &exception, + "android/security/keystore/KeyPermanentlyInvalidatedException", + ) || is_instance_of(env, &exception, "javax/crypto/AEADBadTagException") + || is_instance_of(env, &exception, "javax/crypto/BadPaddingException") + { + SecureSecretStoreError::Corrupted + } else if is_instance_of(env, &exception, "java/security/UnrecoverableKeyException") { + SecureSecretStoreError::Missing + } else { + SecureSecretStoreError::Unavailable + } +} + +fn is_instance_of(env: &mut JNIEnv<'_>, object: &JObject<'_>, class: &str) -> bool { + env.is_instance_of(object, class).unwrap_or(false) +} diff --git a/crates/vnidrop/src/secure_secret/apple.rs b/crates/vnidrop/src/secure_secret/apple.rs new file mode 100644 index 0000000..49cc3f7 --- /dev/null +++ b/crates/vnidrop/src/secure_secret/apple.rs @@ -0,0 +1,372 @@ +use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError}; +use security_framework::{ + access_control::{ProtectionMode, SecAccessControl}, + item::{ItemClass, ItemSearchOptions, Limit, SearchResult}, + passwords::{ + delete_generic_password_options, generic_password, set_generic_password_options, + PasswordOptions, + }, +}; +use std::sync::Arc; + +const SERVICE: &str = "com.vnidrop.secure-secrets.v1"; +const ACCOUNT_ATTRIBUTE: &str = "acct"; +const ERR_SEC_PARAM: i32 = -50; +const ERR_SEC_AUTH_FAILED: i32 = -25_293; +const ERR_SEC_NOT_AVAILABLE: i32 = -25_291; +const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300; +const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308; +const ERR_SEC_DECODE: i32 = -26_275; +const ERR_SEC_MISSING_ENTITLEMENT: i32 = -34_018; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AppleAccessibility { + AfterFirstUnlockThisDeviceOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AppleKeychainPolicy { + accessibility: AppleAccessibility, + synchronizable: bool, + data_protection_keychain: bool, +} + +impl Default for AppleKeychainPolicy { + fn default() -> Self { + Self { + accessibility: AppleAccessibility::AfterFirstUnlockThisDeviceOnly, + synchronizable: false, + data_protection_keychain: true, + } + } +} + +trait AppleKeychainApi: Send + Sync { + fn put( + &self, + service: &str, + account: &str, + material: &[u8], + policy: AppleKeychainPolicy, + ) -> Result<(), i32>; + fn get(&self, service: &str, account: &str) -> Result, i32>; + fn delete(&self, service: &str, account: &str) -> Result<(), i32>; + fn list_accounts(&self, service: &str) -> Result, i32>; +} + +#[derive(Default)] +struct SystemAppleKeychain; + +impl SystemAppleKeychain { + fn options(service: &str, account: &str) -> PasswordOptions { + let mut options = PasswordOptions::new_generic_password(service, account); + options.set_access_synchronized(Some(false)); + options.use_protected_keychain(); + options + } +} + +impl AppleKeychainApi for SystemAppleKeychain { + fn put( + &self, + service: &str, + account: &str, + material: &[u8], + policy: AppleKeychainPolicy, + ) -> Result<(), i32> { + debug_assert_eq!(policy, AppleKeychainPolicy::default()); + let access_control = SecAccessControl::create_with_protection( + Some(ProtectionMode::AccessibleAfterFirstUnlockThisDeviceOnly), + 0, + ) + .map_err(|error| error.code())?; + let mut options = Self::options(service, account); + options.set_access_control(access_control); + set_generic_password_options(material, options).map_err(|error| error.code()) + } + + fn get(&self, service: &str, account: &str) -> Result, i32> { + generic_password(Self::options(service, account)).map_err(|error| error.code()) + } + + fn delete(&self, service: &str, account: &str) -> Result<(), i32> { + delete_generic_password_options(Self::options(service, account)) + .map_err(|error| error.code()) + } + + fn list_accounts(&self, service: &str) -> Result, i32> { + let mut options = ItemSearchOptions::new(); + options + .class(ItemClass::generic_password()) + .service(service) + .cloud_sync(Some(false)) + .load_attributes(true) + .limit(Limit::All); + #[cfg(target_os = "macos")] + options.ignore_legacy_keychains(); + + let results = match options.search() { + Ok(results) => results, + Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => return Ok(Vec::new()), + Err(error) => return Err(error.code()), + }; + results + .into_iter() + .map(|result| match result { + SearchResult::Dict(_) => result + .simplify_dict() + .and_then(|attributes| attributes.get(ACCOUNT_ATTRIBUTE).cloned()) + .ok_or(ERR_SEC_DECODE), + _ => Err(ERR_SEC_DECODE), + }) + .collect() + } +} + +/// Stores VniDrop's protected material in Apple's device-local data-protection Keychain. +pub(crate) struct AppleKeychainSecretStore { + api: Arc, +} + +impl AppleKeychainSecretStore { + pub(crate) fn new() -> Self { + Self { + api: Arc::new(SystemAppleKeychain), + } + } + + #[cfg(test)] + fn with_api(api: impl AppleKeychainApi + 'static) -> Self { + Self { api: Arc::new(api) } + } +} + +impl SecureSecretStore for AppleKeychainSecretStore { + fn put( + &self, + handle: &SecretHandle, + material: SecretMaterial, + ) -> Result<(), SecureSecretStoreError> { + self.api + .put( + SERVICE, + handle.as_str(), + &material.0, + AppleKeychainPolicy::default(), + ) + .map_err(map_status) + } + + fn get(&self, handle: &SecretHandle) -> Result { + let material = self.api.get(SERVICE, handle.as_str()).map_err(map_status)?; + SecretMaterial::new(material).map_err(|_| SecureSecretStoreError::Corrupted) + } + + fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { + self.api + .delete(SERVICE, handle.as_str()) + .map_err(map_status) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + self.api + .list_accounts(SERVICE) + .map(|accounts| accounts.into_iter().map(SecretHandle).collect()) + .map_err(map_status) + } +} + +fn map_status(status: i32) -> SecureSecretStoreError { + match status { + ERR_SEC_ITEM_NOT_FOUND => SecureSecretStoreError::Missing, + ERR_SEC_INTERACTION_NOT_ALLOWED | ERR_SEC_AUTH_FAILED => SecureSecretStoreError::Locked, + ERR_SEC_DECODE | ERR_SEC_PARAM => SecureSecretStoreError::Corrupted, + ERR_SEC_NOT_AVAILABLE | ERR_SEC_MISSING_ENTITLEMENT => SecureSecretStoreError::Unavailable, + _ => SecureSecretStoreError::Unavailable, + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, sync::Mutex}; + + use super::*; + + #[derive(Clone, Default)] + struct RecordingKeychain { + state: Arc>, + } + + #[derive(Default)] + struct RecordingState { + entries: HashMap<(String, String), Vec>, + last_policy: Option, + } + + impl AppleKeychainApi for RecordingKeychain { + fn put( + &self, + service: &str, + account: &str, + material: &[u8], + policy: AppleKeychainPolicy, + ) -> Result<(), i32> { + let mut state = self.state.lock().unwrap(); + state.last_policy = Some(policy); + state.entries.insert( + (service.to_string(), account.to_string()), + material.to_vec(), + ); + Ok(()) + } + + fn get(&self, service: &str, account: &str) -> Result, i32> { + self.state + .lock() + .unwrap() + .entries + .get(&(service.to_string(), account.to_string())) + .cloned() + .ok_or(ERR_SEC_ITEM_NOT_FOUND) + } + + fn delete(&self, service: &str, account: &str) -> Result<(), i32> { + self.state + .lock() + .unwrap() + .entries + .remove(&(service.to_string(), account.to_string())) + .map(|_| ()) + .ok_or(ERR_SEC_ITEM_NOT_FOUND) + } + + fn list_accounts(&self, service: &str) -> Result, i32> { + Ok(self + .state + .lock() + .unwrap() + .entries + .keys() + .filter(|(entry_service, _)| entry_service == service) + .map(|(_, account)| account.clone()) + .collect()) + } + } + + fn handle(value: &str) -> SecretHandle { + SecretHandle(value.to_string()) + } + + #[test] + fn adapter_creates_replaces_reads_lists_and_deletes_only_its_service() { + let api = RecordingKeychain::default(); + api.state.lock().unwrap().entries.insert( + ("com.example.unrelated".to_string(), "leave-me".to_string()), + vec![0x77; 32], + ); + let store = AppleKeychainSecretStore::with_api(api.clone()); + let owned = handle("vnidrop/v1/endpoint-identity/apple-test"); + + store + .put(&owned, SecretMaterial::new(vec![0x31; 32]).unwrap()) + .unwrap(); + drop(store); + + let reopened_store = AppleKeychainSecretStore::with_api(api.clone()); + assert_eq!( + reopened_store.get(&owned).unwrap(), + SecretMaterial::new(vec![0x31; 32]).unwrap() + ); + reopened_store + .put(&owned, SecretMaterial::new(vec![0x42; 32]).unwrap()) + .unwrap(); + + assert_eq!( + reopened_store.get(&owned).unwrap(), + SecretMaterial::new(vec![0x42; 32]).unwrap() + ); + assert_eq!(reopened_store.list_handles().unwrap(), vec![owned.clone()]); + reopened_store.delete(&owned).unwrap(); + assert!(matches!( + reopened_store.get(&owned), + Err(SecureSecretStoreError::Missing) + )); + assert!(api + .state + .lock() + .unwrap() + .entries + .contains_key(&("com.example.unrelated".to_string(), "leave-me".to_string()))); + } + + #[test] + fn adapter_always_requests_device_local_non_synchronizing_protection() { + let api = RecordingKeychain::default(); + let store = AppleKeychainSecretStore::with_api(api.clone()); + + store + .put( + &handle("vnidrop/v1/relationship-grant/apple-policy"), + SecretMaterial::new(vec![0x51; 32]).unwrap(), + ) + .unwrap(); + + assert_eq!( + api.state.lock().unwrap().last_policy, + Some(AppleKeychainPolicy { + accessibility: AppleAccessibility::AfterFirstUnlockThisDeviceOnly, + synchronizable: false, + data_protection_keychain: true, + }) + ); + } + + #[test] + fn apple_statuses_map_to_fail_closed_contract_outcomes() { + assert!(matches!( + map_status(ERR_SEC_INTERACTION_NOT_ALLOWED), + SecureSecretStoreError::Locked + )); + assert!(matches!( + map_status(ERR_SEC_AUTH_FAILED), + SecureSecretStoreError::Locked + )); + assert!(matches!( + map_status(ERR_SEC_ITEM_NOT_FOUND), + SecureSecretStoreError::Missing + )); + assert!(matches!( + map_status(ERR_SEC_DECODE), + SecureSecretStoreError::Corrupted + )); + assert!(matches!( + map_status(ERR_SEC_NOT_AVAILABLE), + SecureSecretStoreError::Unavailable + )); + assert!(matches!( + map_status(-1), + SecureSecretStoreError::Unavailable + )); + } + + #[test] + fn malformed_keychain_values_are_corrupted_without_diagnostic_disclosure() { + let api = RecordingKeychain::default(); + let secret = vec![0x6d; 31]; + api.state.lock().unwrap().entries.insert( + ( + SERVICE.to_string(), + "vnidrop/v1/pairing-eligibility/corrupt".to_string(), + ), + secret.clone(), + ); + let store = AppleKeychainSecretStore::with_api(api); + + let error = store + .get(&handle("vnidrop/v1/pairing-eligibility/corrupt")) + .unwrap_err(); + + assert!(matches!(&error, SecureSecretStoreError::Corrupted)); + assert!(!format!("{error:?}").contains(&data_encoding::HEXLOWER.encode(&secret))); + } +} diff --git a/crates/vnidrop/src/secure_secret/linux.rs b/crates/vnidrop/src/secure_secret/linux.rs new file mode 100644 index 0000000..657221a --- /dev/null +++ b/crates/vnidrop/src/secure_secret/linux.rs @@ -0,0 +1,310 @@ +use std::{collections::HashMap, sync::Arc}; + +use secret_service::{blocking::SecretService, EncryptionType, Error}; + +use super::{ + SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, HANDLE_NAMESPACE, + HANDLE_VERSION, +}; + +const ATTRIBUTE_APPLICATION: &str = "application"; +const ATTRIBUTE_HANDLE: &str = "vnidrop-handle"; +const APPLICATION_ID: &str = "com.vnidrop.VniDrop"; +const ITEM_LABEL: &str = "VniDrop protected secret"; + +trait LinuxSecretServiceApi: Send + Sync { + fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError>; + fn get(&self, handle: &str) -> Result, SecureSecretStoreError>; + fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError>; + fn list_handles(&self) -> Result, SecureSecretStoreError>; +} + +struct SystemLinuxSecretService; + +impl SystemLinuxSecretService { + fn connect() -> Result { + SecretService::connect(EncryptionType::Dh).map_err(map_error)?; + Ok(Self) + } + + fn service(&self) -> Result, SecureSecretStoreError> { + SecretService::connect(EncryptionType::Dh).map_err(map_error) + } +} + +impl LinuxSecretServiceApi for SystemLinuxSecretService { + fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> { + let service = self.service()?; + let collection = service.get_default_collection().map_err(map_error)?; + if collection.is_locked().map_err(map_error)? { + return Err(SecureSecretStoreError::Locked); + } + collection + .create_item( + ITEM_LABEL, + HashMap::from([ + (ATTRIBUTE_APPLICATION, APPLICATION_ID), + (ATTRIBUTE_HANDLE, handle), + ]), + material, + true, + "application/octet-stream", + ) + .map_err(map_error)?; + Ok(()) + } + + fn get(&self, handle: &str) -> Result, SecureSecretStoreError> { + let service = self.service()?; + let result = service + .search_items(HashMap::from([ + (ATTRIBUTE_APPLICATION, APPLICATION_ID), + (ATTRIBUTE_HANDLE, handle), + ])) + .map_err(map_error)?; + if !result.locked.is_empty() { + return Err(SecureSecretStoreError::Locked); + } + let mut items = result.unlocked.into_iter(); + let item = items.next().ok_or(SecureSecretStoreError::Missing)?; + if items.next().is_some() { + return Err(SecureSecretStoreError::Corrupted); + } + item.get_secret().map_err(map_error) + } + + fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> { + let service = self.service()?; + let result = service + .search_items(HashMap::from([ + (ATTRIBUTE_APPLICATION, APPLICATION_ID), + (ATTRIBUTE_HANDLE, handle), + ])) + .map_err(map_error)?; + if !result.locked.is_empty() { + return Err(SecureSecretStoreError::Locked); + } + if result.unlocked.is_empty() { + return Err(SecureSecretStoreError::Missing); + } + for item in result.unlocked { + item.delete().map_err(map_error)?; + } + Ok(()) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + let service = self.service()?; + let result = service + .search_items(HashMap::from([(ATTRIBUTE_APPLICATION, APPLICATION_ID)])) + .map_err(map_error)?; + if !result.locked.is_empty() { + return Err(SecureSecretStoreError::Locked); + } + result + .unlocked + .into_iter() + .map(|item| { + item.get_attributes() + .map_err(map_error)? + .remove(ATTRIBUTE_HANDLE) + .ok_or(SecureSecretStoreError::Corrupted) + }) + .collect() + } +} + +pub(super) struct LinuxSecretServiceStore { + api: Arc, +} + +impl LinuxSecretServiceStore { + pub(super) fn connect() -> Result { + Ok(Self { + api: Arc::new(SystemLinuxSecretService::connect()?), + }) + } + + #[cfg(test)] + fn with_api(api: Arc) -> Self { + Self { api } + } +} + +impl SecureSecretStore for LinuxSecretServiceStore { + fn put( + &self, + handle: &SecretHandle, + material: SecretMaterial, + ) -> Result<(), SecureSecretStoreError> { + self.api.put(handle.as_str(), &material.0) + } + + fn get(&self, handle: &SecretHandle) -> Result { + let bytes = self.api.get(handle.as_str())?; + SecretMaterial::new(bytes).map_err(|_| SecureSecretStoreError::Corrupted) + } + + fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { + self.api.delete(handle.as_str()) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + let expected_prefix = format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/"); + let mut handles = self + .api + .list_handles()? + .into_iter() + .map(|handle| { + if handle.starts_with(&expected_prefix) { + Ok(SecretHandle(handle)) + } else { + Err(SecureSecretStoreError::Corrupted) + } + }) + .collect::, _>>()?; + handles.sort_by(|left, right| left.as_str().cmp(right.as_str())); + Ok(handles) + } +} + +fn map_error(error: Error) -> SecureSecretStoreError { + match error { + Error::Locked | Error::Prompt => SecureSecretStoreError::Locked, + Error::NoResult => SecureSecretStoreError::Missing, + Error::Crypto(_) | Error::Zvariant(_) => SecureSecretStoreError::Corrupted, + Error::Unavailable | Error::Zbus(_) | Error::ZbusFdo(_) => { + SecureSecretStoreError::Unavailable + } + _ => SecureSecretStoreError::Unavailable, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + + #[derive(Default)] + struct RecordingSecretService { + values: Mutex>>, + failure: Mutex>, + } + + impl RecordingSecretService { + fn failure(&self) -> Result<(), SecureSecretStoreError> { + match &*self.failure.lock().unwrap() { + Some(SecureSecretStoreError::Locked) => Err(SecureSecretStoreError::Locked), + Some(SecureSecretStoreError::Missing) => Err(SecureSecretStoreError::Missing), + Some(SecureSecretStoreError::Corrupted) => Err(SecureSecretStoreError::Corrupted), + Some(SecureSecretStoreError::Unavailable) => { + Err(SecureSecretStoreError::Unavailable) + } + None => Ok(()), + } + } + } + + impl LinuxSecretServiceApi for RecordingSecretService { + fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> { + self.failure()?; + self.values + .lock() + .unwrap() + .insert(handle.to_string(), material.to_vec()); + Ok(()) + } + + fn get(&self, handle: &str) -> Result, SecureSecretStoreError> { + self.failure()?; + self.values + .lock() + .unwrap() + .get(handle) + .cloned() + .ok_or(SecureSecretStoreError::Missing) + } + + fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> { + self.failure()?; + self.values + .lock() + .unwrap() + .remove(handle) + .map(|_| ()) + .ok_or(SecureSecretStoreError::Missing) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + self.failure()?; + Ok(self.values.lock().unwrap().keys().cloned().collect()) + } + } + + fn handle(suffix: &str) -> SecretHandle { + SecretHandle(format!("vnidrop/v1/relationship-grant/{suffix}")) + } + + #[test] + fn adapter_survives_restart_and_deletes_only_the_selected_item() { + let api = Arc::new(RecordingSecretService::default()); + let first = handle("first"); + let second = handle("second"); + let material = SecretMaterial::new(vec![0x5a; 32]).unwrap(); + let store = LinuxSecretServiceStore::with_api(api.clone()); + store.put(&first, material.clone()).unwrap(); + store + .put(&second, SecretMaterial::new(vec![0x6b; 32]).unwrap()) + .unwrap(); + + let restarted = LinuxSecretServiceStore::with_api(api); + assert_eq!(restarted.get(&first).unwrap(), material); + assert_eq!( + restarted.list_handles().unwrap(), + vec![first.clone(), second] + ); + restarted.delete(&first).unwrap(); + assert!(matches!( + restarted.get(&first), + Err(SecureSecretStoreError::Missing) + )); + } + + #[test] + fn failures_are_typed_and_secret_material_is_redacted() { + let api = Arc::new(RecordingSecretService::default()); + let store = LinuxSecretServiceStore::with_api(api.clone()); + let secret = SecretMaterial::new(vec![0x7c; 32]).unwrap(); + assert_eq!(format!("{secret:?}"), "SecretMaterial(redacted)"); + + for failure in [ + SecureSecretStoreError::Locked, + SecureSecretStoreError::Unavailable, + SecureSecretStoreError::Corrupted, + ] { + *api.failure.lock().unwrap() = Some(failure); + assert!(store.get(&handle("failure")).is_err()); + } + } + + #[test] + fn secret_service_errors_map_without_exposing_details() { + assert!(matches!( + map_error(Error::Locked), + SecureSecretStoreError::Locked + )); + assert!(matches!( + map_error(Error::NoResult), + SecureSecretStoreError::Missing + )); + assert!(matches!( + map_error(Error::Crypto("distinctive-secret")), + SecureSecretStoreError::Corrupted + )); + assert!(matches!( + map_error(Error::Unavailable), + SecureSecretStoreError::Unavailable + )); + } +} diff --git a/crates/vnidrop/src/secure_secret/windows.rs b/crates/vnidrop/src/secure_secret/windows.rs new file mode 100644 index 0000000..b6f21b9 --- /dev/null +++ b/crates/vnidrop/src/secure_secret/windows.rs @@ -0,0 +1,472 @@ +use std::{ + collections::HashSet, + ffi::OsStr, + fs::{self, OpenOptions}, + io::{self, Write}, + os::windows::ffi::OsStrExt, + path::{Path, PathBuf}, + ptr, + sync::Arc, +}; + +use data_encoding::HEXLOWER; +use windows_sys::Win32::{ + Foundation::{ + GetLastError, LocalFree, ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, + ERROR_CALL_NOT_IMPLEMENTED, ERROR_FILE_EXISTS, ERROR_NOT_SUPPORTED, + ERROR_PASSWORD_RESTRICTION, + }, + Security::Cryptography::{ + CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, + }, + Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}, +}; + +use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError}; + +const ENVELOPE_MAGIC: &[u8; 8] = b"VNIDPAPI"; +const ENVELOPE_VERSION: u8 = 1; +const FILE_EXTENSION: &str = "dpapi"; +const MAX_ENVELOPE_BYTES: usize = 64 * 1024; +const DEFAULT_CONTEXT: &[u8] = b"com.vnidrop.secure-secret.dpapi.v1.current-user"; +const PROTECTED_PAYLOAD_MAGIC: &[u8] = b"VNIDROP-SECRET-V1"; + +/// Current-user DPAPI storage backed by atomically published protected blobs. +pub(crate) struct WindowsDpapiSecretStore { + directory: PathBuf, + protector: Arc, +} + +impl WindowsDpapiSecretStore { + pub(crate) fn new(directory: impl AsRef) -> Result { + Self::with_protector(directory, Arc::new(DpapiProtector::new())) + } + + fn with_protector( + directory: impl AsRef, + protector: Arc, + ) -> Result { + let directory = directory.as_ref().to_path_buf(); + fs::create_dir_all(&directory).map_err(map_io_error)?; + cleanup_interrupted_writes(&directory)?; + Ok(Self { + directory, + protector, + }) + } + + fn path_for(&self, handle: &SecretHandle) -> PathBuf { + let digest = blake3::hash(handle.as_str().as_bytes()); + self.directory.join(format!( + "{}.{}", + HEXLOWER.encode(digest.as_bytes()), + FILE_EXTENSION + )) + } + + #[cfg(test)] + fn with_protector_for_test( + directory: impl AsRef, + protector: Arc, + ) -> Result { + Self::with_protector(directory, protector) + } + + #[cfg(test)] + fn path_for_test(&self, handle: &SecretHandle) -> PathBuf { + self.path_for(handle) + } +} + +impl SecureSecretStore for WindowsDpapiSecretStore { + fn put( + &self, + handle: &SecretHandle, + material: SecretMaterial, + ) -> Result<(), SecureSecretStoreError> { + let destination = self.path_for(handle); + if destination.exists() { + return ensure_same_value(self, handle, &material); + } + + let ciphertext = self.protector.protect(handle, &material.0)?; + let envelope = encode_envelope(handle, &ciphertext)?; + let temporary = self.directory.join(format!( + "{}.tmp-{}", + destination + .file_stem() + .and_then(OsStr::to_str) + .ok_or(SecureSecretStoreError::Unavailable)?, + uuid::Uuid::new_v4() + )); + let mut temporary_guard = TemporaryFile::new(temporary); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(temporary_guard.path()) + .map_err(map_io_error)?; + file.write_all(&envelope).map_err(map_io_error)?; + file.sync_all().map_err(map_io_error)?; + drop(file); + + match move_write_through(temporary_guard.path(), &destination) { + Ok(()) => { + temporary_guard.disarm(); + Ok(()) + } + Err(error) + if matches!( + error.raw_os_error().map(|code| code as u32), + Some(ERROR_ALREADY_EXISTS) | Some(ERROR_FILE_EXISTS) + ) => + { + ensure_same_value(self, handle, &material) + } + Err(error) => Err(map_io_error(error)), + } + } + + fn get(&self, handle: &SecretHandle) -> Result { + let envelope = fs::read(self.path_for(handle)).map_err(map_io_error)?; + let ciphertext = decode_envelope(&envelope, handle)?; + let plaintext = self.protector.unprotect(handle, ciphertext)?; + SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted) + } + + fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> { + fs::remove_file(self.path_for(handle)).map_err(map_io_error) + } + + fn list_handles(&self) -> Result, SecureSecretStoreError> { + let mut handles = Vec::new(); + let mut unique = HashSet::new(); + for entry in fs::read_dir(&self.directory).map_err(map_io_error)? { + let entry = entry.map_err(map_io_error)?; + let path = entry.path(); + if path.extension() != Some(OsStr::new(FILE_EXTENSION)) { + continue; + } + let envelope = fs::read(&path).map_err(map_io_error)?; + let handle = decode_handle(&envelope)?; + if self.path_for(&handle) != path || !unique.insert(handle.clone()) { + return Err(SecureSecretStoreError::Corrupted); + } + handles.push(handle); + } + handles.sort_by(|left, right| left.as_str().cmp(right.as_str())); + Ok(handles) + } +} + +fn ensure_same_value( + store: &WindowsDpapiSecretStore, + handle: &SecretHandle, + expected: &SecretMaterial, +) -> Result<(), SecureSecretStoreError> { + if store.get(handle)? == *expected { + Ok(()) + } else { + Err(SecureSecretStoreError::Corrupted) + } +} + +fn encode_envelope( + handle: &SecretHandle, + ciphertext: &[u8], +) -> Result, SecureSecretStoreError> { + let handle_bytes = handle.as_str().as_bytes(); + let handle_len = + u16::try_from(handle_bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?; + let ciphertext_len = + u32::try_from(ciphertext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?; + let capacity = ENVELOPE_MAGIC.len() + 1 + 2 + 4 + handle_bytes.len() + ciphertext.len(); + if capacity > MAX_ENVELOPE_BYTES { + return Err(SecureSecretStoreError::Unavailable); + } + let mut envelope = Vec::with_capacity(capacity); + envelope.extend_from_slice(ENVELOPE_MAGIC); + envelope.push(ENVELOPE_VERSION); + envelope.extend_from_slice(&handle_len.to_le_bytes()); + envelope.extend_from_slice(&ciphertext_len.to_le_bytes()); + envelope.extend_from_slice(handle_bytes); + envelope.extend_from_slice(ciphertext); + Ok(envelope) +} + +fn decode_handle(envelope: &[u8]) -> Result { + let (handle, _) = decode_envelope_parts(envelope)?; + Ok(handle) +} + +fn decode_envelope<'a>( + envelope: &'a [u8], + expected_handle: &SecretHandle, +) -> Result<&'a [u8], SecureSecretStoreError> { + let (handle, ciphertext) = decode_envelope_parts(envelope)?; + if handle != *expected_handle { + return Err(SecureSecretStoreError::Corrupted); + } + Ok(ciphertext) +} + +fn decode_envelope_parts(envelope: &[u8]) -> Result<(SecretHandle, &[u8]), SecureSecretStoreError> { + const HEADER_BYTES: usize = 8 + 1 + 2 + 4; + if envelope.len() < HEADER_BYTES + || envelope.len() > MAX_ENVELOPE_BYTES + || &envelope[..8] != ENVELOPE_MAGIC + || envelope[8] != ENVELOPE_VERSION + { + return Err(SecureSecretStoreError::Corrupted); + } + let handle_len = usize::from(u16::from_le_bytes([envelope[9], envelope[10]])); + let ciphertext_len = + u32::from_le_bytes([envelope[11], envelope[12], envelope[13], envelope[14]]) as usize; + let handle_end = HEADER_BYTES + .checked_add(handle_len) + .ok_or(SecureSecretStoreError::Corrupted)?; + let envelope_end = handle_end + .checked_add(ciphertext_len) + .ok_or(SecureSecretStoreError::Corrupted)?; + if handle_len == 0 || ciphertext_len == 0 || envelope_end != envelope.len() { + return Err(SecureSecretStoreError::Corrupted); + } + let handle = std::str::from_utf8(&envelope[HEADER_BYTES..handle_end]) + .map_err(|_| SecureSecretStoreError::Corrupted)?; + Ok(( + SecretHandle(handle.to_owned()), + &envelope[handle_end..envelope_end], + )) +} + +fn cleanup_interrupted_writes(directory: &Path) -> Result<(), SecureSecretStoreError> { + for entry in fs::read_dir(directory).map_err(map_io_error)? { + let entry = entry.map_err(map_io_error)?; + let name = entry.file_name(); + if name.to_string_lossy().contains(".tmp-") { + fs::remove_file(entry.path()).map_err(map_io_error)?; + } + } + Ok(()) +} + +struct TemporaryFile { + path: PathBuf, + armed: bool, +} + +impl TemporaryFile { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn path(&self) -> &Path { + &self.path + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TemporaryFile { + fn drop(&mut self) { + if self.armed { + let _ = fs::remove_file(&self.path); + } + } +} + +fn move_write_through(source: &Path, destination: &Path) -> io::Result<()> { + let source = wide_path(source); + let destination = wide_path(destination); + // The files share a directory, so MoveFileEx publishes the fully flushed blob as one rename. + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +fn wide_path(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain(Some(0)).collect() +} + +struct DpapiProtector { + context: Vec, +} + +impl DpapiProtector { + fn new() -> Self { + Self { + context: DEFAULT_CONTEXT.to_vec(), + } + } + + #[cfg(test)] + fn with_context_for_test(context: &[u8]) -> Self { + Self { + context: context.to_vec(), + } + } + + fn entropy(&self, handle: &SecretHandle) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(&self.context); + hasher.update(&[0]); + hasher.update(handle.as_str().as_bytes()); + *hasher.finalize().as_bytes() + } + + fn protect( + &self, + handle: &SecretHandle, + plaintext: &[u8], + ) -> Result, SecureSecretStoreError> { + let mut payload = encode_protected_payload(plaintext)?; + let input = blob(&payload)?; + let entropy_bytes = self.entropy(handle); + let entropy = blob(&entropy_bytes)?; + let mut output = CRYPT_INTEGER_BLOB::default(); + // Omitting CRYPTPROTECT_LOCAL_MACHINE binds the blob to the current Windows user. + let success = unsafe { + CryptProtectData( + &input, + ptr::null(), + &entropy, + ptr::null(), + ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + }; + let result = if success == 0 { + Err(map_dpapi_error(false)) + } else { + copy_and_free(output, false) + }; + payload.fill(0); + result + } + + fn unprotect( + &self, + handle: &SecretHandle, + ciphertext: &[u8], + ) -> Result, SecureSecretStoreError> { + let input = blob(ciphertext)?; + let entropy_bytes = self.entropy(handle); + let entropy = blob(&entropy_bytes)?; + let mut output = CRYPT_INTEGER_BLOB::default(); + let success = unsafe { + CryptUnprotectData( + &input, + ptr::null_mut(), + &entropy, + ptr::null(), + ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + }; + if success == 0 { + return Err(map_dpapi_error(true)); + } + let mut payload = copy_and_free(output, true)?; + let plaintext = decode_protected_payload(&payload).map(<[u8]>::to_vec); + payload.fill(0); + plaintext + } +} + +fn encode_protected_payload(plaintext: &[u8]) -> Result, SecureSecretStoreError> { + let length = u32::try_from(plaintext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?; + let mut payload = Vec::with_capacity(PROTECTED_PAYLOAD_MAGIC.len() + 4 + plaintext.len() + 32); + payload.extend_from_slice(PROTECTED_PAYLOAD_MAGIC); + payload.extend_from_slice(&length.to_le_bytes()); + payload.extend_from_slice(plaintext); + let digest = blake3::hash(&payload); + payload.extend_from_slice(digest.as_bytes()); + Ok(payload) +} + +fn decode_protected_payload(payload: &[u8]) -> Result<&[u8], SecureSecretStoreError> { + let header_end = PROTECTED_PAYLOAD_MAGIC.len() + 4; + if payload.len() < header_end + 32 || !payload.starts_with(PROTECTED_PAYLOAD_MAGIC) { + return Err(SecureSecretStoreError::Corrupted); + } + let length = u32::from_le_bytes( + payload[PROTECTED_PAYLOAD_MAGIC.len()..header_end] + .try_into() + .map_err(|_| SecureSecretStoreError::Corrupted)?, + ) as usize; + let material_end = header_end + .checked_add(length) + .ok_or(SecureSecretStoreError::Corrupted)?; + if material_end + .checked_add(32) + .ok_or(SecureSecretStoreError::Corrupted)? + != payload.len() + { + return Err(SecureSecretStoreError::Corrupted); + } + let expected = blake3::hash(&payload[..material_end]); + if expected.as_bytes() != &payload[material_end..] { + return Err(SecureSecretStoreError::Corrupted); + } + Ok(&payload[header_end..material_end]) +} + +fn blob(bytes: &[u8]) -> Result { + Ok(CRYPT_INTEGER_BLOB { + cbData: u32::try_from(bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?, + pbData: bytes.as_ptr().cast_mut(), + }) +} + +fn copy_and_free( + output: CRYPT_INTEGER_BLOB, + clear_before_free: bool, +) -> Result, SecureSecretStoreError> { + if output.pbData.is_null() || output.cbData == 0 { + return Err(SecureSecretStoreError::Corrupted); + } + let result = unsafe { + let bytes = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec(); + if clear_before_free { + ptr::write_bytes(output.pbData, 0, output.cbData as usize); + } + LocalFree(output.pbData.cast()); + bytes + }; + Ok(result) +} + +fn map_dpapi_error(unprotecting: bool) -> SecureSecretStoreError { + let code = unsafe { GetLastError() }; + match code { + ERROR_ACCESS_DENIED | ERROR_PASSWORD_RESTRICTION => SecureSecretStoreError::Locked, + ERROR_NOT_SUPPORTED | ERROR_CALL_NOT_IMPLEMENTED => SecureSecretStoreError::Unavailable, + _ if unprotecting => SecureSecretStoreError::Corrupted, + _ => SecureSecretStoreError::Unavailable, + } +} + +fn map_io_error(error: io::Error) -> SecureSecretStoreError { + match error.kind() { + io::ErrorKind::NotFound => SecureSecretStoreError::Missing, + io::ErrorKind::PermissionDenied => SecureSecretStoreError::Locked, + io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted, + _ => SecureSecretStoreError::Unavailable, + } +} + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; diff --git a/crates/vnidrop/src/secure_secret/windows_tests.rs b/crates/vnidrop/src/secure_secret/windows_tests.rs new file mode 100644 index 0000000..b6356fc --- /dev/null +++ b/crates/vnidrop/src/secure_secret/windows_tests.rs @@ -0,0 +1,187 @@ +use std::{fs, sync::Arc}; + +use data_encoding::HEXLOWER; +use iroh::SecretKey; + +use super::{DpapiProtector, WindowsDpapiSecretStore}; +use crate::{ + repository::Repository, + secure_secret::{ + CustodyCrashPoint, SecretCustody, SecretHandle, SecretKind, SecretMaterial, + SecureSecretStore, SecureSecretStoreError, + }, + VnidropError, +}; + +fn handle(suffix: &str) -> SecretHandle { + SecretHandle(format!("vnidrop/v1/relationship-grant/{suffix}")) +} + +fn material(seed: u8) -> SecretMaterial { + SecretMaterial::new(vec![seed; 32]).unwrap() +} + +#[test] +fn round_trip_survives_adapter_restart_and_never_persists_plaintext() { + let directory = tempfile::tempdir().unwrap(); + let handle = handle("restart"); + let secret = material(0xa7); + + WindowsDpapiSecretStore::new(directory.path()) + .unwrap() + .put(&handle, secret.clone()) + .unwrap(); + + let restarted = WindowsDpapiSecretStore::new(directory.path()).unwrap(); + assert_eq!(restarted.get(&handle).unwrap(), secret); + assert_eq!(restarted.list_handles().unwrap(), vec![handle]); + + for entry in fs::read_dir(directory.path()).unwrap() { + let bytes = fs::read(entry.unwrap().path()).unwrap(); + assert!(!bytes.windows(32).any(|window| window == [0xa7; 32])); + } +} + +#[test] +fn delete_removes_only_the_selected_protected_value() { + let directory = tempfile::tempdir().unwrap(); + let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); + let retained = handle("retained"); + let removed = handle("removed"); + store.put(&retained, material(1)).unwrap(); + store.put(&removed, material(2)).unwrap(); + + store.delete(&removed).unwrap(); + + assert!(matches!( + store.get(&removed), + Err(SecureSecretStoreError::Missing) + )); + assert_eq!(store.get(&retained).unwrap(), material(1)); + assert_eq!(store.list_handles().unwrap(), vec![retained]); +} + +#[test] +fn repeated_put_is_idempotent_but_cannot_replace_secret_material() { + let directory = tempfile::tempdir().unwrap(); + let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); + let handle = handle("immutable"); + + store.put(&handle, material(6)).unwrap(); + store.put(&handle, material(6)).unwrap(); + + assert!(matches!( + store.put(&handle, material(7)), + Err(SecureSecretStoreError::Corrupted) + )); + assert_eq!(store.get(&handle).unwrap(), material(6)); +} + +#[test] +fn missing_corrupt_and_wrong_context_values_fail_closed() { + let directory = tempfile::tempdir().unwrap(); + let handle = handle("failure-mapping"); + let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); + assert!(matches!( + store.get(&handle), + Err(SecureSecretStoreError::Missing) + )); + + store.put(&handle, material(3)).unwrap(); + fs::write(store.path_for_test(&handle), b"not a protected envelope").unwrap(); + assert!(matches!( + store.get(&handle), + Err(SecureSecretStoreError::Corrupted) + )); + + let isolated = tempfile::tempdir().unwrap(); + let original = WindowsDpapiSecretStore::with_protector_for_test( + isolated.path(), + Arc::new(DpapiProtector::with_context_for_test(b"first-context")), + ) + .unwrap(); + original.put(&handle, material(4)).unwrap(); + let wrong_context = WindowsDpapiSecretStore::with_protector_for_test( + isolated.path(), + Arc::new(DpapiProtector::with_context_for_test(b"second-context")), + ) + .unwrap(); + assert!(matches!( + wrong_context.get(&handle), + Err(SecureSecretStoreError::Corrupted) + )); +} + +#[test] +fn incomplete_temporary_writes_are_removed_on_restart() { + let directory = tempfile::tempdir().unwrap(); + let temporary = directory.path().join("interrupted.tmp-123"); + fs::write(&temporary, material(5).0).unwrap(); + + let store = WindowsDpapiSecretStore::new(directory.path()).unwrap(); + + assert!(!temporary.exists()); + assert!(store.list_handles().unwrap().is_empty()); +} + +#[test] +fn unusable_backing_path_is_reported_as_unavailable() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("not-a-directory"); + fs::write(&file, b"occupied").unwrap(); + + assert!(matches!( + WindowsDpapiSecretStore::new(&file), + Err(SecureSecretStoreError::Unavailable) + )); +} + +#[tokio::test] +async fn endpoint_migration_survives_activation_crash_without_changing_identity() { + let directory = tempfile::tempdir().unwrap(); + let app_data = directory.path().join("app-data"); + fs::create_dir(&app_data).unwrap(); + let legacy = app_data.join("iroh.secret"); + let original = SecretKey::generate(); + fs::write(&legacy, HEXLOWER.encode(&original.to_bytes())).unwrap(); + + let repository = Repository::open(&app_data).await.unwrap(); + let protected_directory = app_data.join("protected-secrets"); + let store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap()); + let custody = SecretCustody::new(repository.protected_secrets(), store); + custody.crash_once_at(CustodyCrashPoint::MetadataActivation); + assert!(custody + .migrate_legacy_endpoint_identity(&legacy) + .await + .is_err()); + assert!(legacy.exists()); + drop(custody); + drop(repository); + + let repository = Repository::open(&app_data).await.unwrap(); + let restarted_store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap()); + let (custody, _) = SecretCustody::start(repository.protected_secrets(), restarted_store) + .await + .unwrap(); + let handle = custody + .migrate_legacy_endpoint_identity(&legacy) + .await + .unwrap(); + assert!(!legacy.exists()); + assert_eq!( + custody.load(&handle).await.unwrap(), + SecretMaterial::new(original.to_bytes().to_vec()).unwrap() + ); + + let replacement = SecretKey::generate(); + fs::write(&legacy, HEXLOWER.encode(&replacement.to_bytes())).unwrap(); + assert!(matches!( + custody.migrate_legacy_endpoint_identity(&legacy).await, + Err(VnidropError::SecureStorageCorrupted { .. }) + )); + assert!(legacy.exists()); + assert_eq!( + custody.load(&handle).await.unwrap(), + SecretMaterial::new(original.to_bytes().to_vec()).unwrap() + ); +}