//! Storage-owned OpenPGP key transfer for native mobile QR adapters. use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path}; use crate::atomic_file::TempFile; use cap_std::{ambient_authority, fs::Dir}; use data_encoding::{BASE64URL_NOPAD, HEXLOWER}; use sha2::{Digest as _, Sha256}; use crate::{ config::{Config, ConfigError}, crypto::{CryptoError, KeyInfo, KeyStore}, presentation::{QrError, QrMatrix}, repository::SecretBytes, }; const FRAME_PREFIX: &str = "ISKT1"; const FRAME_CHUNK_BYTES: usize = 1_600; const MAX_TRANSFER_FRAMES: usize = 11_000; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileKeyTransferKind { Public, Private, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileKeyTransferKey { fingerprint: String, title: String, detail: String, kind: MobileKeyTransferKind, requires_passphrase: bool, } impl MobileKeyTransferKey { pub fn fingerprint(&self) -> &str { &self.fingerprint } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } pub fn kind(&self) -> MobileKeyTransferKind { self.kind } pub fn requires_passphrase(&self) -> bool { self.requires_passphrase } } pub struct MobileKeyTransferFrame { sequence: usize, total: usize, payload: SecretBytes, matrix: QrMatrix, } impl MobileKeyTransferFrame { pub fn sequence(&self) -> usize { self.sequence } pub fn total(&self) -> usize { self.total } pub fn payload(&self) -> &[u8] { self.payload.expose() } pub fn matrix(&self) -> &QrMatrix { &self.matrix } } pub struct MobileKeyTransferExport { key: MobileKeyTransferKey, frames: Vec, } impl MobileKeyTransferExport { pub fn key(&self) -> &MobileKeyTransferKey { &self.key } pub fn frames(&self) -> &[MobileKeyTransferFrame] { &self.frames } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileKeyTransferProgress { received: usize, total: usize, duplicate: bool, key: Option, } impl MobileKeyTransferProgress { pub fn received(&self) -> usize { self.received } pub fn total(&self) -> usize { self.total } pub fn duplicate(&self) -> bool { self.duplicate } pub fn key(&self) -> Option<&MobileKeyTransferKey> { self.key.as_ref() } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileKeyTransferOutcome { title: String, detail: String, } impl MobileKeyTransferOutcome { pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } } pub struct MobileKeyTransferService { config: Config, keys: KeyStore, } impl MobileKeyTransferService { pub fn load() -> Result { let config = Config::load(None)?; let keys = load_keys_or_empty(config.key_material())?; Ok(Self { config, keys }) } pub fn keys(&self) -> Vec { self.keys.infos().map(|key| transfer_key(&key)).collect() } pub fn export( &self, fingerprint: &str, kind: MobileKeyTransferKind, passphrase: Option, ) -> Result { let include_secret = kind == MobileKeyTransferKind::Private; let armor = self .keys .export_armored(fingerprint, include_secret, passphrase.as_ref())?; let info = self .keys .infos() .find(|key| key.fingerprint().as_str() == fingerprint) .ok_or(MobileKeyTransferError::KeyUnavailable)?; let frames = encode_frames(armor)?; Ok(MobileKeyTransferExport { key: transfer_key_with_kind(&info, kind), frames, }) } pub fn importer(&self) -> MobileKeyTransferImport { MobileKeyTransferImport::new(self.config.clone()) } } pub struct MobileKeyTransferImport { destination: MobileKeyTransferDestination, digest: Option, total: Option, chunks: BTreeMap>, complete: Option, key: Option, } enum MobileKeyTransferDestination { Configured(Box), LocalSetup, } impl MobileKeyTransferImport { fn new(config: Config) -> Self { Self { destination: MobileKeyTransferDestination::Configured(Box::new(config)), digest: None, total: None, chunks: BTreeMap::new(), complete: None, key: None, } } pub fn local_setup() -> Self { Self { destination: MobileKeyTransferDestination::LocalSetup, digest: None, total: None, chunks: BTreeMap::new(), complete: None, key: None, } } pub fn add_image( &mut self, image: SecretBytes, ) -> Result { let payload = QrMatrix::decode_image(&image).map_err(|_| MobileKeyTransferError::InvalidFrame)?; self.add_frame(payload) } pub fn add_frame( &mut self, payload: SecretBytes, ) -> Result { if self.complete.is_some() { return Err(MobileKeyTransferError::AlreadyComplete); } if payload.expose().starts_with(b"-----BEGIN PGP ") { if self.total.is_some() { return Err(MobileKeyTransferError::MixedTransfers); } self.finish(payload)?; return Ok(self.progress(false)); } let text = std::str::from_utf8(payload.expose()) .map_err(|_| MobileKeyTransferError::InvalidFrame)?; let mut fields = text.splitn(5, ':'); if fields.next() != Some(FRAME_PREFIX) { return Err(MobileKeyTransferError::InvalidFrame); } let digest = fields.next().ok_or(MobileKeyTransferError::InvalidFrame)?; if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(MobileKeyTransferError::InvalidFrame); } let sequence = parse_positive(fields.next())?; let total = parse_positive(fields.next())?; if sequence > total || total > MAX_TRANSFER_FRAMES { return Err(MobileKeyTransferError::InvalidFrame); } let chunk = BASE64URL_NOPAD .decode( fields .next() .ok_or(MobileKeyTransferError::InvalidFrame)? .as_bytes(), ) .map_err(|_| MobileKeyTransferError::InvalidFrame)?; if chunk.is_empty() || chunk.len() > FRAME_CHUNK_BYTES { return Err(MobileKeyTransferError::InvalidFrame); } if self.digest.as_deref().is_some_and(|value| value != digest) || self.total.is_some_and(|value| value != total) { return Err(MobileKeyTransferError::MixedTransfers); } self.digest .get_or_insert_with(|| digest.to_ascii_lowercase()); self.total.get_or_insert(total); if let Some(existing) = self.chunks.get(&sequence) { if existing == &chunk { return Ok(self.progress(true)); } return Err(MobileKeyTransferError::ConflictingFrame); } self.chunks.insert(sequence, chunk); if self.chunks.len() == total { let mut armor = Vec::new(); for sequence in 1..=total { armor.extend( self.chunks .get(&sequence) .ok_or(MobileKeyTransferError::IncompleteTransfer)?, ); } let actual = HEXLOWER.encode(&Sha256::digest(&armor)); if self.digest.as_deref() != Some(actual.as_str()) { return Err(MobileKeyTransferError::ChecksumMismatch); } self.finish(SecretBytes::new(armor))?; } Ok(self.progress(false)) } pub fn import( &mut self, passphrase: Option, make_default: bool, overwrite: bool, ) -> Result { let armor = self .complete .as_ref() .ok_or(MobileKeyTransferError::IncompleteTransfer)?; let key = self .key .as_ref() .ok_or(MobileKeyTransferError::IncompleteTransfer)?; let mut imported = KeyStore::new(); imported.import(armor.expose())?; if key.kind == MobileKeyTransferKind::Private { imported.validate_secret_passphrase(&key.fingerprint, passphrase.as_ref())?; } if make_default && key.kind != MobileKeyTransferKind::Private { return Err(MobileKeyTransferError::PublicDefault); } if matches!(self.destination, MobileKeyTransferDestination::LocalSetup) { if key.kind != MobileKeyTransferKind::Private { return Err(MobileKeyTransferError::PublicDefault); } let operation = crate::mobile_onboarding::MobileOnboardingOperation::default(); let outcome = if make_default { operation.setup_local_imported( SecretBytes::new(armor.expose().to_vec()), passphrase, overwrite, ) } else { operation.stage_local_imported( SecretBytes::new(armor.expose().to_vec()), passphrase, overwrite, ) } .map_err(MobileKeyTransferError::LocalSetup)?; return Ok(MobileKeyTransferOutcome { title: outcome.title().to_owned(), detail: outcome.detail().to_owned(), }); } let MobileKeyTransferDestination::Configured(config) = &self.destination else { unreachable!("local setup returned above") }; let existing = load_keys_or_empty(config.key_material())?; if let Some(found) = existing .infos() .find(|info| info.fingerprint().as_str() == key.fingerprint) && (found.has_secret() || key.kind == MobileKeyTransferKind::Public) && !overwrite { return Err(MobileKeyTransferError::ExistingKey); } let current = Config::load(Some(config.source()))?; let candidate = if make_default { let mut settings = current.settings(); settings.set_default_key(key.fingerprint.clone()); Some(current.with_settings(settings)?) } else { None }; let target = if config.key_material().is_dir() { let suffix = if key.kind == MobileKeyTransferKind::Private { "secret" } else { "public" }; config.key_material().join(format!( "{}-{suffix}.asc", key.fingerprint.to_ascii_lowercase() )) } else { config.key_material().to_owned() }; let backup = match fs::read(&target) { Ok(value) => Some(SecretBytes::new(value)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(_) => return Err(MobileKeyTransferError::Write), }; persist_armored_key( config.key_material(), &key.fingerprint, key.kind == MobileKeyTransferKind::Private, armor.expose(), overwrite, )?; if let Some(candidate) = candidate && let Err(error) = candidate.persist() { if !matches!(error, ConfigError::DurabilityUncertain { .. }) { let parent = target.parent().ok_or(MobileKeyTransferError::Write)?; let name = Path::new(target.file_name().ok_or(MobileKeyTransferError::Write)?); let restored = match backup { Some(ref value) => atomic_replace(parent, name, value.expose()), None => fs::remove_file(&target) .and_then(|()| fs::File::open(parent)?.sync_all()) .map_err(|_| MobileKeyTransferError::Write), }; restored.map_err(|_| MobileKeyTransferError::RecoveryRequired)?; } return Err(error.into()); } let kind = if key.kind == MobileKeyTransferKind::Private { "private" } else { "public" }; Ok(MobileKeyTransferOutcome { title: "GPG Key Imported".to_owned(), detail: format!("Imported the {kind} key {}.", key.fingerprint), }) } fn finish(&mut self, armor: SecretBytes) -> Result<(), MobileKeyTransferError> { validate_transfer_armor(armor.expose())?; let mut store = KeyStore::new(); let infos = store.import(armor.expose())?; if infos.len() != 1 { return Err(MobileKeyTransferError::MismatchedKeys); } self.key = infos.first().map(transfer_key); self.complete = Some(armor); Ok(()) } fn progress(&self, duplicate: bool) -> MobileKeyTransferProgress { MobileKeyTransferProgress { received: self.chunks.len().max(usize::from(self.complete.is_some())), total: self.total.unwrap_or(1), duplicate, key: self.key.clone(), } } } fn load_keys_or_empty(path: &Path) -> Result { match KeyStore::load(path) { Err(CryptoError::NoKeyMaterial) => Ok(KeyStore::new()), result => result, } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum MobileKeyTransferError { Configuration, KeyUnavailable, InvalidFrame, MixedTransfers, ConflictingFrame, IncompleteTransfer, AlreadyComplete, ChecksumMismatch, MismatchedKeys, ExistingKey, PublicDefault, InvalidKeyMaterial, IncorrectPassphrase, QrPayload, Write, RecoveryRequired, LocalSetup(crate::mobile_onboarding::MobileOnboardingError), } impl fmt::Display for MobileKeyTransferError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::RecoveryRequired => "key import could not be rolled back; retain the key material and recover configuration before retrying", Self::Configuration => "the IronStorage configuration is unavailable", Self::KeyUnavailable => "the selected GPG key is unavailable", Self::InvalidFrame => "this is not a valid IronStorage key-transfer QR code", Self::MixedTransfers => "the scanned QR codes belong to different key transfers", Self::ConflictingFrame => "a scanned QR frame conflicts with an earlier frame", Self::IncompleteTransfer => { "more QR frames are required before this key can be imported" } Self::AlreadyComplete => "this key transfer is already complete", Self::ChecksumMismatch => { "the reconstructed key transfer did not pass its integrity check" } Self::MismatchedKeys => "the transfer contains multiple or mismatched GPG keys", Self::ExistingKey => { "this GPG key is already installed; confirm replacement to continue" } Self::PublicDefault => "a public-only key cannot become the default GPG key", Self::InvalidKeyMaterial => "the GPG key material is invalid", Self::IncorrectPassphrase => "the GPG key passphrase is incorrect", Self::QrPayload => "the GPG key could not be represented as QR codes", Self::Write => "the GPG key could not be stored safely", Self::LocalSetup(error) => return error.fmt(formatter), }) } } impl Error for MobileKeyTransferError {} impl From for MobileKeyTransferError { fn from(_: ConfigError) -> Self { Self::Configuration } } impl From for MobileKeyTransferError { fn from(error: CryptoError) -> Self { match error { CryptoError::InvalidKeyPassphrase => Self::IncorrectPassphrase, CryptoError::MissingIdentity { .. } | CryptoError::MissingSecretKey => { Self::KeyUnavailable } _ => Self::InvalidKeyMaterial, } } } impl From for MobileKeyTransferError { fn from(_: QrError) -> Self { Self::QrPayload } } fn transfer_key(info: &KeyInfo) -> MobileKeyTransferKey { transfer_key_with_kind( info, if info.has_secret() { MobileKeyTransferKind::Private } else { MobileKeyTransferKind::Public }, ) } fn transfer_key_with_kind(info: &KeyInfo, kind: MobileKeyTransferKind) -> MobileKeyTransferKey { let title = info .user_ids() .first() .cloned() .unwrap_or_else(|| format!("GPG key {}", info.key_id())); MobileKeyTransferKey { fingerprint: info.fingerprint().as_str().to_owned(), title, detail: format!("Fingerprint {}", info.fingerprint()), kind, requires_passphrase: info.requires_passphrase(), } } fn encode_frames( armor: SecretBytes, ) -> Result, MobileKeyTransferError> { if let Ok(matrix) = QrMatrix::encode(&armor) { return Ok(vec![MobileKeyTransferFrame { sequence: 1, total: 1, payload: armor, matrix, }]); } encode_multipart(armor, FRAME_CHUNK_BYTES) } fn encode_multipart( armor: SecretBytes, chunk_bytes: usize, ) -> Result, MobileKeyTransferError> { if chunk_bytes == 0 { return Err(MobileKeyTransferError::QrPayload); } let digest = HEXLOWER.encode(&Sha256::digest(armor.expose())); let total = armor.expose().len().div_ceil(chunk_bytes); if total == 0 || total > MAX_TRANSFER_FRAMES { return Err(MobileKeyTransferError::QrPayload); } let mut frames = Vec::with_capacity(total); for (index, chunk) in armor.expose().chunks(chunk_bytes).enumerate() { let payload = SecretBytes::new( format!( "{FRAME_PREFIX}:{digest}:{}:{total}:{}", index + 1, BASE64URL_NOPAD.encode(chunk) ) .into_bytes(), ); let matrix = QrMatrix::encode(&payload)?; frames.push(MobileKeyTransferFrame { sequence: index + 1, total, payload, matrix, }); } Ok(frames) } fn parse_positive(value: Option<&str>) -> Result { value .ok_or(MobileKeyTransferError::InvalidFrame)? .parse::() .ok() .filter(|value| *value > 0) .ok_or(MobileKeyTransferError::InvalidFrame) } pub(crate) fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> { let text = std::str::from_utf8(bytes).map_err(|_| MobileKeyTransferError::InvalidKeyMaterial)?; let end = if text.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") { "-----END PGP PUBLIC KEY BLOCK-----" } else if text.starts_with("-----BEGIN PGP PRIVATE KEY BLOCK-----") { "-----END PGP PRIVATE KEY BLOCK-----" } else { return Err(MobileKeyTransferError::InvalidKeyMaterial); }; if text.matches("-----BEGIN PGP ").count() != 1 { return Err(MobileKeyTransferError::MismatchedKeys); } let end_offset = text .find(end) .map(|offset| offset + end.len()) .ok_or(MobileKeyTransferError::InvalidKeyMaterial)?; if !text[end_offset..].trim().is_empty() { return Err(MobileKeyTransferError::InvalidKeyMaterial); } Ok(()) } pub(crate) fn persist_armored_key( configured: &Path, fingerprint: &str, secret: bool, armor: &[u8], overwrite: bool, ) -> Result<(), MobileKeyTransferError> { if configured.is_dir() { let suffix = if secret { "secret" } else { "public" }; let name = format!("{}-{suffix}.asc", fingerprint.to_ascii_lowercase()); if configured.join(&name).exists() && !overwrite { return Err(MobileKeyTransferError::ExistingKey); } atomic_replace(configured, Path::new(&name), armor) } else { let existing = fs::read(configured).map_err(|_| MobileKeyTransferError::Write)?; let mut combined = Vec::with_capacity(existing.len() + armor.len() + 1); combined.extend_from_slice(&existing); if !combined.ends_with(b"\n") { combined.push(b'\n'); } combined.extend_from_slice(armor); let parent = configured.parent().ok_or(MobileKeyTransferError::Write)?; let name = configured .file_name() .ok_or(MobileKeyTransferError::Write)?; atomic_replace(parent, Path::new(name), &combined) } } fn atomic_replace( parent: &Path, name: &Path, contents: &[u8], ) -> Result<(), MobileKeyTransferError> { let directory = Dir::open_ambient_dir(parent, ambient_authority()) .map_err(|_| MobileKeyTransferError::Write)?; if let Ok(metadata) = directory.symlink_metadata(name) && (metadata.file_type().is_symlink() || !metadata.is_file()) { return Err(MobileKeyTransferError::Write); } let mut temporary = TempFile::new(&directory).map_err(|_| MobileKeyTransferError::Write)?; set_private_permissions(&temporary)?; temporary .write_all(contents) .and_then(|()| temporary.as_file().sync_all()) .and_then(|()| temporary.replace(name)) .and_then(|()| directory.open(".").and_then(|file| file.sync_all())) .map_err(|_| MobileKeyTransferError::Write) } #[cfg(unix)] fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), MobileKeyTransferError> { use cap_std::fs::{Permissions, PermissionsExt as _}; temporary .as_file() .set_permissions(Permissions::from_mode(0o600)) .map_err(|_| MobileKeyTransferError::Write) } #[cfg(not(unix))] fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileKeyTransferError> { Ok(()) } #[cfg(test)] mod tests { use super::{ MobileKeyTransferError, MobileKeyTransferImport, MobileKeyTransferKind, MobileKeyTransferService, encode_multipart, load_keys_or_empty, }; use crate::{config::Config, crypto::KeyStore, repository::SecretBytes}; use tempfile::tempdir; const ALICE_PUBLIC: &[u8] = include_bytes!("../tests/fixtures/compatibility/keys/alice-public.asc"); const ALICE_SECRET: &[u8] = include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc"); const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; #[test] fn qr_images_reconstruct_the_same_key_and_reject_invalid_images() -> Result<(), Box> { let fixture = configured_public_key()?; let mut importer = MobileKeyTransferImport::new(fixture.config); assert_eq!( importer.add_image(SecretBytes::new(b"not an image".to_vec())), Err(MobileKeyTransferError::InvalidFrame) ); for frame in encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)? { let matrix = frame.matrix(); let size = u32::try_from(matrix.width() + 8)? * 4; let image = image::GrayImage::from_fn(size, size, |x, y| { let x = (x / 4) as usize; let y = (y / 4) as usize; let dark = x >= 4 && y >= 4 && matrix.is_dark(x - 4, y - 4).unwrap_or(false); image::Luma([if dark { 0 } else { 255 }]) }); let mut png = std::io::Cursor::new(Vec::new()); image.write_to(&mut png, image::ImageFormat::Png)?; importer.add_image(SecretBytes::new(png.into_inner()))?; } assert_eq!( importer.complete.as_ref().map(SecretBytes::expose), Some(ALICE_SECRET) ); Ok(()) } #[test] fn multipart_frames_reconstruct_exact_armor_out_of_order() -> Result<(), Box> { let fixture = configured_public_key()?; let mut importer = MobileKeyTransferImport::new(fixture.config); let mut frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?; frames.reverse(); for frame in frames { importer.add_frame(SecretBytes::new(frame.payload().to_vec()))?; } assert_eq!( importer.complete.as_ref().map(SecretBytes::expose), Some(ALICE_SECRET) ); assert_eq!( importer.key.as_ref().map(|key| key.kind()), Some(MobileKeyTransferKind::Private) ); Ok(()) } #[test] fn single_frame_export_validates_private_passphrase_and_reconstructs_exactly() -> Result<(), Box> { let fixture = configured_secret_key()?; let service = MobileKeyTransferService { config: fixture.config.clone(), keys: KeyStore::load(&fixture.key_path)?, }; assert!(matches!( service.export( ALICE_FINGERPRINT, MobileKeyTransferKind::Private, Some(SecretBytes::new(b"wrong".to_vec())), ), Err(MobileKeyTransferError::IncorrectPassphrase) )); let exported = service.export( ALICE_FINGERPRINT, MobileKeyTransferKind::Private, Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), )?; assert_eq!(exported.frames().len(), 1); let expected = exported.frames()[0].payload().to_vec(); let mut importer = service.importer(); let progress = importer.add_frame(SecretBytes::new(expected.clone()))?; assert!(progress.key().is_some()); assert_eq!( importer.complete.as_ref().map(SecretBytes::expose), Some(expected.as_slice()) ); Ok(()) } #[test] fn mismatched_public_keys_are_rejected_as_one_transfer() -> Result<(), Box> { let fixture = configured_public_key()?; let mut importer = MobileKeyTransferImport::new(fixture.config); let mut mismatched = ALICE_PUBLIC.to_vec(); mismatched.extend_from_slice(include_bytes!( "../tests/fixtures/compatibility/keys/bob-public.asc" )); assert_eq!( importer.add_frame(SecretBytes::new(mismatched)), Err(MobileKeyTransferError::MismatchedKeys) ); Ok(()) } #[test] fn invalid_duplicate_and_incomplete_frames_do_not_replace_keys() -> Result<(), Box> { let fixture = configured_public_key()?; let before = std::fs::read(fixture.key_path.join("alice-public.asc"))?; let mut importer = MobileKeyTransferImport::new(fixture.config); let frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?; let first = frames[0].payload(); let progress = importer.add_frame(SecretBytes::new(first.to_vec()))?; assert!( importer .add_frame(SecretBytes::new(first.to_vec()))? .duplicate() ); assert!(progress.received() < progress.total()); assert_eq!( importer.import(None, false, false), Err(MobileKeyTransferError::IncompleteTransfer) ); assert_eq!( std::fs::read(fixture.key_path.join("alice-public.asc"))?, before ); Ok(()) } #[test] fn private_import_validates_passphrase_before_atomic_persistence() -> Result<(), Box> { let fixture = configured_public_key()?; let mut importer = MobileKeyTransferImport::new(fixture.config.clone()); importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?; assert_eq!( importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true, false), Err(MobileKeyTransferError::IncorrectPassphrase) ); assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1); fixture .config .update_git_identity(&crate::git::GitIdentity::new( "Changed since QR opened", "changed@example.test", )?)?; importer.import( Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), true, false, )?; assert_eq!( Config::load(Some(fixture.config.source()))? .git_identity() .name(), "Changed since QR opened" ); let keys = KeyStore::load(&fixture.key_path)?; assert!(keys.infos().any(|key| key.has_secret())); assert_eq!( Config::load(Some(fixture.config.source()))? .default_key() .as_str(), ALICE_FINGERPRINT ); let before = std::fs::read(fixture.key_path.join(format!( "{}-secret.asc", ALICE_FINGERPRINT.to_ascii_lowercase() )))?; let mut replacement = MobileKeyTransferImport::new(fixture.config); replacement.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?; assert_eq!( replacement.import( Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), true, false, ), Err(MobileKeyTransferError::ExistingKey) ); assert_eq!( std::fs::read(fixture.key_path.join(format!( "{}-secret.asc", ALICE_FINGERPRINT.to_ascii_lowercase() )))?, before ); replacement.import( Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), true, true, )?; Ok(()) } #[cfg(unix)] #[test] fn default_key_import_rolls_back_when_config_cannot_be_replaced() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; let fixture = configured_public_key()?; let mut importer = MobileKeyTransferImport::new(fixture.config.clone()); importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?; let before = std::fs::read(fixture.config.source())?; let parent = fixture.config.source().parent().unwrap(); let permissions = std::fs::metadata(parent)?.permissions(); std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o500))?; let result = importer.import( Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), true, false, ); std::fs::set_permissions(parent, permissions)?; assert_eq!(result, Err(MobileKeyTransferError::Configuration)); assert_eq!(std::fs::read(fixture.config.source())?, before); assert_eq!(std::fs::read_dir(fixture.key_path)?.count(), 1); Ok(()) } #[test] fn corrupt_configuration_does_not_install_an_imported_key() -> Result<(), Box> { let fixture = configured_public_key()?; let mut importer = MobileKeyTransferImport::new(fixture.config.clone()); importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?; std::fs::write(fixture.config.source(), b"not a TOML document [")?; assert_eq!( importer.import( Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), true, false ), Err(MobileKeyTransferError::Configuration) ); assert_eq!(std::fs::read_dir(fixture.key_path)?.count(), 1); Ok(()) } #[test] fn cloned_store_with_empty_key_directory_can_import_its_private_key() -> Result<(), Box> { let fixture = configured_key("placeholder", b"")?; std::fs::remove_file(fixture.key_path.join("placeholder"))?; let service = MobileKeyTransferService { keys: load_keys_or_empty(&fixture.key_path)?, config: fixture.config.clone(), }; assert!(service.keys().is_empty()); let mut importer = service.importer(); importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?; importer.import( Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), true, false, )?; let keys = KeyStore::load(&fixture.key_path)?; assert!(keys.resolve(ALICE_FINGERPRINT).is_ok()); assert_eq!( Config::load(Some(fixture.config.source()))? .default_key() .as_str(), ALICE_FINGERPRINT ); let config = std::fs::read_to_string(fixture.config.source())?; assert!(config.contains("vault = \"vault\"")); assert!(config.contains("key_material = \"keys\"")); Ok(()) } struct Fixture { _root: tempfile::TempDir, config: Config, key_path: std::path::PathBuf, } fn configured_public_key() -> Result> { configured_key("alice-public.asc", ALICE_PUBLIC) } fn configured_secret_key() -> Result> { configured_key("alice-secret.asc", ALICE_SECRET) } fn configured_key(name: &str, bytes: &[u8]) -> Result> { let root = tempdir()?; let vault = root.path().join("vault"); let keys = root.path().join("keys"); std::fs::create_dir(&vault)?; std::fs::create_dir(&keys)?; std::fs::write(keys.join(name), bytes)?; let config_path = root.path().join("config.toml"); std::fs::write( &config_path, format!( "vault = \"vault\"\ndefault_key = {:?}\nkey_material = \"keys\"\n", ALICE_FINGERPRINT ), )?; let config = Config::load(Some(&config_path))?; Ok(Fixture { _root: root, config, key_path: keys, }) } }