Implement complete Android settings and recovery controls (#104)
Android ARM verification / rust (push) Failing after 46m46s
Android ARM verification / android (push) Failing after 7s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-10 12:07:28 +02:00
1 parent 06575363a2
commit 43f660ace3
34 files changed
+2720 -64

No files matched your search

+136 -4
View File
@@ -217,6 +217,15 @@ impl MobileKeyTransferImport {
}
}
pub fn add_image(
&mut self,
image: SecretBytes,
) -> Result<MobileKeyTransferProgress, MobileKeyTransferError> {
let payload =
QrMatrix::decode_image(&image).map_err(|_| MobileKeyTransferError::InvalidFrame)?;
self.add_frame(payload)
}
pub fn add_frame(
&mut self,
payload: SecretBytes,
@@ -352,6 +361,32 @@ impl MobileKeyTransferImport {
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,
@@ -359,10 +394,21 @@ impl MobileKeyTransferImport {
armor.expose(),
overwrite,
)?;
if make_default {
let mut settings = config.settings();
settings.set_default_key(key.fingerprint.clone());
config.with_settings(settings)?.persist()?;
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"
@@ -421,12 +467,14 @@ pub enum MobileKeyTransferError {
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",
@@ -666,6 +714,35 @@ mod tests {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
@@ -773,11 +850,23 @@ mod tests {
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!(
@@ -815,6 +904,49 @@ mod tests {
Ok(())
}
#[cfg(unix)]
#[test]
fn default_key_import_rolls_back_when_config_cannot_be_replaced()
-> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {