Implement CameraX GPG QR transfer on Android (#106)
Android ARM verification / rust (push) Failing after 47m42s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-12 09:49:38 +02:00
1 parent 6669b77f4e
commit d466b170ab
33 files changed
+1616 -162

No files matched your search

+178 -25
View File
@@ -1,6 +1,9 @@
//! Storage-owned OpenPGP key transfer for native mobile QR adapters.
use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path};
use std::{
collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path, sync::Mutex,
time::Instant,
};
use crate::atomic_file::TempFile;
use cap_std::{ambient_authority, fs::Dir};
@@ -17,6 +20,9 @@ use crate::{
const FRAME_PREFIX: &str = "ISKT1";
const FRAME_CHUNK_BYTES: usize = 1_600;
const MAX_TRANSFER_FRAMES: usize = 11_000;
const MAX_TRANSFER_BYTES: usize = MAX_TRANSFER_FRAMES * FRAME_CHUNK_BYTES;
// Bound eager QR matrices to under 8 MiB on lower-memory mobile clients.
const MAX_EXPORT_FRAMES: usize = 256;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileKeyTransferKind {
@@ -140,13 +146,18 @@ impl MobileKeyTransferOutcome {
pub struct MobileKeyTransferService {
config: Config,
keys: KeyStore,
export_deadline: Mutex<Option<Instant>>,
}
impl MobileKeyTransferService {
pub fn load() -> Result<Self, MobileKeyTransferError> {
let config = Config::load(None)?;
let keys = load_keys_or_empty(config.key_material())?;
Ok(Self { config, keys })
Ok(Self {
config,
keys,
export_deadline: Mutex::new(None),
})
}
pub fn keys(&self) -> Vec<MobileKeyTransferKey> {
@@ -169,6 +180,11 @@ impl MobileKeyTransferService {
.find(|key| key.fingerprint().as_str() == fingerprint)
.ok_or(MobileKeyTransferError::KeyUnavailable)?;
let frames = encode_frames(armor)?;
*self
.export_deadline
.lock()
.map_err(|_| MobileKeyTransferError::KeyUnavailable)? =
Some(Instant::now() + self.config.authentication_timeout().duration());
Ok(MobileKeyTransferExport {
key: transfer_key_with_kind(&info, kind),
frames,
@@ -178,13 +194,20 @@ impl MobileKeyTransferService {
pub fn importer(&self) -> MobileKeyTransferImport {
MobileKeyTransferImport::new(self.config.clone())
}
pub fn export_visible(&self) -> bool {
self.export_deadline
.lock()
.ok()
.is_some_and(|deadline| deadline.is_some_and(|deadline| Instant::now() < deadline))
}
}
pub struct MobileKeyTransferImport {
destination: MobileKeyTransferDestination,
digest: Option<String>,
total: Option<usize>,
chunks: BTreeMap<usize, Vec<u8>>,
chunks: BTreeMap<usize, SecretBytes>,
complete: Option<SecretBytes>,
key: Option<MobileKeyTransferKey>,
}
@@ -195,6 +218,31 @@ enum MobileKeyTransferDestination {
}
impl MobileKeyTransferImport {
pub fn add_camera(
&mut self,
frame: crate::mobile_platform::MobileCameraFrame,
rotation_degrees: u32,
) -> Result<Option<MobileKeyTransferProgress>, MobileKeyTransferError> {
if frame.width > 2048
|| frame.height > 2048
|| frame.luminance.expose().len() > 4 * 1024 * 1024
{
return Err(MobileKeyTransferError::InvalidFrame);
}
match frame.decode_rotated(rotation_degrees) {
Ok(payload) => self.add_frame(payload).map(Some),
Err(QrError::NotFound) => Ok(None),
Err(_) => Err(MobileKeyTransferError::InvalidFrame),
}
}
pub(crate) fn completed_armor(&self) -> Result<SecretBytes, MobileKeyTransferError> {
self.complete
.as_ref()
.map(|armor| SecretBytes::new(armor.expose().to_vec()))
.ok_or(MobileKeyTransferError::IncompleteTransfer)
}
fn new(config: Config) -> Self {
Self {
destination: MobileKeyTransferDestination::Configured(Box::new(config)),
@@ -230,6 +278,9 @@ impl MobileKeyTransferImport {
&mut self,
payload: SecretBytes,
) -> Result<MobileKeyTransferProgress, MobileKeyTransferError> {
if payload.expose().len() > MAX_TRANSFER_BYTES {
return Err(MobileKeyTransferError::InvalidFrame);
}
if self.complete.is_some() {
return Err(MobileKeyTransferError::AlreadyComplete);
}
@@ -251,52 +302,58 @@ impl MobileKeyTransferImport {
if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(MobileKeyTransferError::InvalidFrame);
}
let digest = digest.to_ascii_lowercase();
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 {
let encoded = fields.next().ok_or(MobileKeyTransferError::InvalidFrame)?;
if encoded.len() > FRAME_CHUNK_BYTES.div_ceil(3) * 4 {
return Err(MobileKeyTransferError::InvalidFrame);
}
let chunk = SecretBytes::new(
BASE64URL_NOPAD
.decode(encoded.as_bytes())
.map_err(|_| MobileKeyTransferError::InvalidFrame)?,
);
if chunk.expose().is_empty() || chunk.expose().len() > FRAME_CHUNK_BYTES {
return Err(MobileKeyTransferError::InvalidFrame);
}
if self.digest.as_deref().is_some_and(|value| value != digest)
if self
.digest
.as_deref()
.is_some_and(|value| value != digest.as_str())
|| self.total.is_some_and(|value| value != total)
{
return Err(MobileKeyTransferError::MixedTransfers);
}
self.digest
.get_or_insert_with(|| digest.to_ascii_lowercase());
self.digest.get_or_insert(digest);
self.total.get_or_insert(total);
if let Some(existing) = self.chunks.get(&sequence) {
if existing == &chunk {
if existing.expose() == chunk.expose() {
return Ok(self.progress(true));
}
return Err(MobileKeyTransferError::ConflictingFrame);
}
self.chunks.insert(sequence, chunk);
if self.chunks.len() == total {
let mut armor = Vec::new();
let mut armor = zeroize::Zeroizing::new(Vec::new());
for sequence in 1..=total {
armor.extend(
armor.extend_from_slice(
self.chunks
.get(&sequence)
.ok_or(MobileKeyTransferError::IncompleteTransfer)?,
.ok_or(MobileKeyTransferError::IncompleteTransfer)?
.expose(),
);
}
let actual = HEXLOWER.encode(&Sha256::digest(&armor));
let actual = HEXLOWER.encode(&Sha256::digest(armor.as_slice()));
if self.digest.as_deref() != Some(actual.as_str()) {
return Err(MobileKeyTransferError::ChecksumMismatch);
}
self.finish(SecretBytes::new(armor))?;
self.finish(SecretBytes::new(std::mem::take(&mut *armor)))?;
self.chunks.clear();
}
Ok(self.progress(false))
}
@@ -435,7 +492,11 @@ impl MobileKeyTransferImport {
fn progress(&self, duplicate: bool) -> MobileKeyTransferProgress {
MobileKeyTransferProgress {
received: self.chunks.len().max(usize::from(self.complete.is_some())),
received: if self.complete.is_some() {
self.total.unwrap_or(1)
} else {
self.chunks.len()
},
total: self.total.unwrap_or(1),
duplicate,
key: self.key.clone(),
@@ -576,7 +637,7 @@ fn encode_multipart(
}
let digest = HEXLOWER.encode(&Sha256::digest(armor.expose()));
let total = armor.expose().len().div_ceil(chunk_bytes);
if total == 0 || total > MAX_TRANSFER_FRAMES {
if total == 0 || total > MAX_EXPORT_FRAMES {
return Err(MobileKeyTransferError::QrPayload);
}
let mut frames = Vec::with_capacity(total);
@@ -706,6 +767,7 @@ mod tests {
MobileKeyTransferService, encode_multipart, load_keys_or_empty,
};
use crate::{config::Config, crypto::KeyStore, repository::SecretBytes};
use std::{sync::Mutex, time::Instant};
use tempfile::tempdir;
const ALICE_PUBLIC: &[u8] =
@@ -714,6 +776,89 @@ mod tests {
include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc");
const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
#[test]
fn export_rejects_oversized_matrix_sets_before_rendering() {
assert!(matches!(
encode_multipart(SecretBytes::new(vec![0; 257]), 1),
Err(MobileKeyTransferError::QrPayload)
));
}
#[test]
fn export_visibility_is_bounded_and_does_not_renew_while_reading() {
let fixture = configured_secret_key().unwrap();
let service = MobileKeyTransferService {
keys: load_keys_or_empty(fixture.config.key_material()).unwrap(),
config: fixture.config,
export_deadline: Mutex::new(None),
};
assert!(!service.export_visible());
service
.export(ALICE_FINGERPRINT, MobileKeyTransferKind::Public, None)
.unwrap();
assert!(service.export_visible());
*service.export_deadline.lock().unwrap() = Some(Instant::now());
assert!(!service.export_visible());
assert!(!service.export_visible());
}
#[test]
fn camera_multipart_is_exact_duplicate_safe_and_transactional()
-> Result<(), Box<dyn std::error::Error>> {
let fixture = configured_public_key()?;
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
let mut frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?;
frames.reverse();
for (index, frame) in frames.iter().enumerate() {
let matrix = frame.matrix();
let width = (matrix.width() + 8) * 4;
let height = width + 8;
let row = width * 2 + 13;
let mut pixels = vec![255; height * row];
for y in 0..height {
for x in 0..width {
let dark = x / 4 >= 4
&& y / 4 >= 4
&& matrix.is_dark(x / 4 - 4, y / 4 - 4).unwrap_or(false);
pixels[y * row + x * 2] = if dark { 0 } else { 255 };
}
}
let camera = || crate::mobile_platform::MobileCameraFrame {
width: width as u32,
height: height as u32,
row_stride: row as u32,
pixel_stride: 2,
luminance: SecretBytes::new(pixels.clone()),
};
let progress = importer
.add_camera(camera(), [0, 90, 180, 270][index % 4])?
.unwrap();
assert_eq!(progress.received(), index + 1);
if index + 1 < frames.len() {
assert!(importer.add_camera(camera(), 0)?.unwrap().duplicate());
assert_eq!(
importer.import(None, true, false),
Err(MobileKeyTransferError::IncompleteTransfer)
);
}
}
assert_eq!(importer.completed_armor()?.expose(), ALICE_SECRET);
assert_eq!(
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true, false),
Err(MobileKeyTransferError::IncorrectPassphrase)
);
let before = std::fs::read(fixture.key_path.join("alice-public.asc"))?;
importer.import(
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
true,
false,
)?;
assert_eq!(before, ALICE_PUBLIC);
let keys = KeyStore::load(fixture.config.key_material())?;
assert!(keys.infos().any(|key| key.has_secret()));
Ok(())
}
#[test]
fn qr_images_reconstruct_the_same_key_and_reject_invalid_images()
-> Result<(), Box<dyn std::error::Error>> {
@@ -750,8 +895,14 @@ mod tests {
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()))?;
for (index, frame) in frames.into_iter().enumerate() {
let mut payload = frame.payload().to_vec();
if index % 2 == 0 {
for byte in &mut payload[6..70] {
byte.make_ascii_uppercase();
}
}
importer.add_frame(SecretBytes::new(payload))?;
}
assert_eq!(
importer.complete.as_ref().map(SecretBytes::expose),
@@ -769,6 +920,7 @@ mod tests {
-> Result<(), Box<dyn std::error::Error>> {
let fixture = configured_secret_key()?;
let service = MobileKeyTransferService {
export_deadline: Mutex::new(None),
config: fixture.config.clone(),
keys: KeyStore::load(&fixture.key_path)?,
};
@@ -953,6 +1105,7 @@ mod tests {
let fixture = configured_key("placeholder", b"")?;
std::fs::remove_file(fixture.key_path.join("placeholder"))?;
let service = MobileKeyTransferService {
export_deadline: Mutex::new(None),
keys: load_keys_or_empty(&fixture.key_path)?,
config: fixture.config.clone(),
};
@@ -163,6 +163,15 @@ impl AndroidOnboarding {
self.status_unlocked()
}
pub fn import_scanned(
&self,
transfer: &crate::mobile_key_transfer::MobileKeyTransferImport,
passphrase: Option<SecretBytes>,
) -> Result<AndroidOnboardingStatus, crate::mobile_key_transfer::MobileKeyTransferError> {
self.import(transfer.completed_armor()?, passphrase)
.map_err(crate::mobile_key_transfer::MobileKeyTransferError::LocalSetup)
}
fn stage_key(
&self,
fingerprint: &str,
@@ -1052,6 +1061,40 @@ pub(crate) mod tests {
Ok(true)
}
}
#[test]
fn scanned_import_stages_exact_key_without_creating_a_store() {
use crate::mobile_key_transfer::{MobileKeyTransferError, MobileKeyTransferImport};
let temp = tempfile::tempdir().unwrap();
let private = storage(temp.path());
let backend = Arc::new(Backend::default());
let setup = AndroidOnboarding::new(&private, backend).unwrap();
let mut transfer = MobileKeyTransferImport::local_setup();
assert!(matches!(
setup.import_scanned(&transfer, password()),
Err(MobileKeyTransferError::IncompleteTransfer)
));
transfer
.add_frame(SecretBytes::new(
include_bytes!("../../tests/fixtures/compatibility/keys/alice-secret.asc").to_vec(),
))
.unwrap();
assert!(
setup
.import_scanned(&transfer, Some(SecretBytes::new(b"wrong".to_vec())))
.is_err()
);
assert!(setup.status().unwrap().keys.is_empty());
let status = setup.import_scanned(&transfer, password()).unwrap();
assert_eq!(status.keys[0].fingerprint().as_str(), ALICE);
assert!(!status.configured);
assert!(setup.configuration().unwrap().is_none());
assert!(matches!(
setup.import_scanned(&transfer, password()),
Err(MobileKeyTransferError::LocalSetup(_))
));
assert_eq!(setup.status().unwrap().keys.len(), 1);
}
#[test]
fn imported_key_restarts_then_creates_a_pass_compatible_local_store() {
let temp = tempfile::tempdir().unwrap();
+74 -1
View File
@@ -90,6 +90,10 @@ pub struct MobileCameraFrame {
impl MobileCameraFrame {
pub fn decode(&self) -> Result<SecretBytes, QrError> {
self.decode_rotated(0)
}
pub fn decode_rotated(&self, rotation_degrees: u32) -> Result<SecretBytes, QrError> {
let width = self.width as usize;
let height = self.height as usize;
let row = self.row_stride as usize;
@@ -99,6 +103,7 @@ impl MobileCameraFrame {
|| !(1..=4096).contains(&height)
|| !(1..=4).contains(&pixel)
|| bytes.len() > 32 * 1024 * 1024
|| !matches!(rotation_degrees, 0 | 90 | 180 | 270)
{
return Err(QrError::InvalidImage);
}
@@ -112,7 +117,20 @@ impl MobileCameraFrame {
if row <= last_column || last >= bytes.len() {
return Err(QrError::InvalidImage);
}
QrMatrix::decode_luminance(width, height, |x, y| bytes[y * row + x * pixel])
let (output_width, output_height) = if matches!(rotation_degrees, 90 | 270) {
(height, width)
} else {
(width, height)
};
QrMatrix::decode_luminance(output_width, output_height, |x, y| {
let (x, y) = match rotation_degrees {
90 => (y, height - 1 - x),
180 => (width - 1 - x, height - 1 - y),
270 => (width - 1 - y, x),
_ => (x, y),
};
bytes[y * row + x * pixel]
})
}
}
@@ -223,6 +241,61 @@ impl fmt::Debug for MobileCredentialRequest {
mod tests {
use super::*;
#[test]
fn camera_planes_decode_padded_pixels_at_every_orientation() {
let payload = SecretBytes::new(b"camera-qr-byte-exact".to_vec());
let matrix = QrMatrix::encode(&payload).unwrap();
let size = (matrix.width() + 8) * 5;
let row = size * 2 + 7;
let mut pixels = vec![255; size * row];
for y in 0..size {
for x in 0..size {
let dark = x / 5 >= 4
&& y / 5 >= 4
&& matrix.is_dark(x / 5 - 4, y / 5 - 4).unwrap_or(false);
pixels[y * row + x * 2] = if dark { 0 } else { 255 };
}
}
for rotation in [0, 90, 180, 270] {
let decoded = MobileCameraFrame {
luminance: SecretBytes::new(pixels.clone()),
width: size as u32,
height: size as u32,
row_stride: row as u32,
pixel_stride: 2,
}
.decode_rotated(rotation)
.unwrap();
assert_eq!(decoded.expose(), payload.expose());
}
}
#[test]
fn camera_rejects_truncated_oversized_and_invalid_planes() {
for (width, height, row, step, rotation, length) in [
(0, 1, 1, 1, 0, 1),
(4097, 1, 4097, 1, 0, 4097),
(2, 2, 2, 1, 0, 3),
(2, 2, 1, 1, 0, 4),
(2, 2, 2, 0, 0, 4),
(2, 2, 2, 1, 45, 4),
(2, 2, u32::MAX, 1, 0, 4),
(1, 1, 1, 1, 0, 32 * 1024 * 1024 + 1),
] {
assert!(matches!(
MobileCameraFrame {
luminance: SecretBytes::new(vec![255; length]),
width,
height,
row_stride: row,
pixel_stride: step,
}
.decode_rotated(rotation),
Err(QrError::InvalidImage)
));
}
}
#[test]
fn roots_and_caller_facts_are_validated_without_creating_data_or_grants() {
let paths =