Add OTP QR import from TOTP and entry editors (#120)
Dependency security audit / rustsec (push) Successful in 20s

This commit is contained in:
hugo committed 2026-09-12 12:06:15 +02:00
1 parent 93a08e9c65
commit 3c3574b449
12 files changed
+448 -23

No files matched your search

+11
View File
@@ -1901,6 +1901,17 @@ impl MobileAuthentication {
.map_err(Into::into)
}
pub fn add_entry_editor_otp_image(
&self,
editor: u64,
image: Vec<u8>,
) -> Result<MobileEntryEditorPage, MobileAuthenticationFfiError> {
self.authentication
.add_entry_editor_otp_image(editor, ironstorage::repository::SecretBytes::new(image))
.map(|page| (&page).into())
.map_err(Into::into)
}
pub fn remove_entry_editor_field(
&self,
editor: u64,
@@ -1173,6 +1173,18 @@ impl MobileAuthentication {
Ok(draft.page())
}
pub fn add_entry_editor_otp_image(
&self,
editor: u64,
image: SecretBytes,
) -> Result<MobileEntryEditorPage, MobileAuthenticationError> {
self.ensure_active()?;
let mut status = self.status()?;
let draft = status.editors.get_mut(&editor).ok_or_else(editor_missing)?;
draft.add_otp_image(image).map_err(editor_error)?;
Ok(draft.page())
}
pub fn remove_entry_editor_field(
&self,
editor: u64,
+33
View File
@@ -7,6 +7,8 @@ use crate::document::{
EntryFieldKind, EntrySensitivity,
};
use crate::generate::{GenerateError, GeneratorConfig, MAX_PASSWORD_LENGTH};
use crate::presentation::{QrError, QrMatrix};
use crate::repository::SecretBytes;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileEntrySectionKind {
@@ -330,6 +332,15 @@ impl MobileEntryDraft {
name: Option<String>,
value: String,
) -> Result<(), MobileEntryEditorError> {
if kind == MobileEntryEditorFieldKind::OtpUri
&& self
.document
.fields()
.iter()
.any(|field| field.metadata().kind() == EntryFieldKind::OtpUri)
{
return Err(MobileEntryEditorError::ExistingOtp);
}
let draft = match kind {
MobileEntryEditorFieldKind::Password => {
return Err(MobileEntryEditorError::ProtectedPasswordField);
@@ -343,10 +354,26 @@ impl MobileEntryDraft {
}
MobileEntryEditorFieldKind::OtpUri => EntryFieldDraft::otp_uri(value.into_bytes())?,
};
if kind == MobileEntryEditorFieldKind::OtpUri
&& self.creating
&& let [field] = self.document.fields()
&& field.metadata().kind() == EntryFieldKind::Password
&& field.value().is_empty()
{
self.document.update(field.id(), draft)?;
return Ok(());
}
self.document.add(self.document.fields().len(), draft)?;
Ok(())
}
pub fn add_otp_image(&mut self, image: SecretBytes) -> Result<(), MobileEntryEditorError> {
let payload = QrMatrix::decode_image(&image).map_err(MobileEntryEditorError::Qr)?;
let uri = String::from_utf8(payload.expose().to_vec())
.map_err(|_| MobileEntryEditorError::NonUtf8)?;
self.add(MobileEntryEditorFieldKind::OtpUri, None, uri)
}
pub fn remove(&mut self, id: u64) -> Result<(), MobileEntryEditorError> {
let id = EntryFieldId::from_value(id);
if self
@@ -420,6 +447,8 @@ pub enum MobileEntryEditorError {
MissingFieldName,
ProtectedPasswordField,
NonUtf8,
ExistingOtp,
Qr(QrError),
}
impl fmt::Display for MobileEntryEditorError {
@@ -433,6 +462,10 @@ impl fmt::Display for MobileEntryEditorError {
formatter.write_str("the first password field cannot be removed or moved")
}
Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"),
Self::ExistingOtp => formatter.write_str(
"this entry already has an OTP URI; edit its existing field to replace it",
),
Self::Qr(error) => error.fmt(formatter),
}
}
}
+13 -1
View File
@@ -242,7 +242,19 @@ impl QrMatrix {
/// Decode the first QR symbol from an encoded image without exposing the
/// secret-derived payload to a presentation adapter.
pub fn decode_image(image: &SecretBytes) -> Result<SecretBytes, QrError> {
let grayscale = image::load_from_memory(image.expose())
if image.expose().len() > 32 * 1024 * 1024 {
return Err(QrError::InvalidImage);
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(image.expose()))
.with_guessed_format()
.map_err(|_| QrError::InvalidImage)?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(128 * 1024 * 1024);
reader.limits(limits);
let grayscale = reader
.decode()
.map_err(|_| QrError::InvalidImage)?
.into_luma8();
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
+81
View File
@@ -801,6 +801,87 @@ fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> TestR
Ok(())
}
#[test]
fn mobile_otp_import_creates_or_appends_one_validated_field() -> TestResult {
use ironstorage::presentation::QrMatrix;
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut secrets = FixtureSecrets::all(&fixture);
let service = EntryDocumentService::new(&repository, &keys);
let uri = "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example";
let mut document = service.open("otp/new-camera", &mut secrets)?;
document.add(0, EntryFieldDraft::line(Vec::new())?)?;
let mut draft = MobileEntryDraft::new(document, true);
let original = draft.document().serialize().expose().to_vec();
assert!(
draft
.add(MobileEntryEditorFieldKind::OtpUri, None, "invalid".into())
.is_err()
);
assert_eq!(draft.document().serialize().expose(), original);
assert!(
draft
.add_otp_image(SecretBytes::new(b"not an image".to_vec()))
.is_err()
);
assert_eq!(draft.document().serialize().expose(), original);
let matrix = QrMatrix::encode(&SecretBytes::new(uri.as_bytes().to_vec()))?;
let size = (matrix.width() as u32 + 8) * 8;
let image = image::GrayImage::from_fn(size, size, |x, y| {
let dark = x / 8 >= 4
&& y / 8 >= 4
&& matrix.is_dark((x / 8 - 4) as usize, (y / 8 - 4) as usize) == Some(true);
image::Luma([if dark { 0 } else { 255 }])
});
let mut encoded = std::io::Cursor::new(Vec::new());
image.write_to(&mut encoded, image::ImageFormat::Png)?;
draft.add_otp_image(SecretBytes::new(encoded.into_inner()))?;
assert_eq!(draft.page().fields().len(), 1);
assert_eq!(
draft.page().fields()[0].kind(),
MobileEntryEditorFieldKind::OtpUri
);
assert_eq!(draft.document().serialize().expose(), uri.as_bytes());
assert!(matches!(
draft.add(MobileEntryEditorFieldKind::OtpUri, None, uri.into()),
Err(MobileEntryEditorError::ExistingOtp)
));
service.save_recoverable(draft.document(), None, &mut Committer::default())?;
let reopened = service.open("otp/new-camera", &mut secrets)?;
let otp = reopened.fields()[0].metadata().otp().expect("OTP metadata");
assert_eq!(otp.issuer(), Some("Example"));
assert_eq!(otp.account(), "alice");
write_plaintext(
&repository,
&keys,
"otp/existing",
b"keep-password\nusername: original\nnotes: keep notes\n",
)?;
let mut draft = MobileEntryDraft::new(service.open("otp/existing", &mut secrets)?, false);
let before = draft.document().serialize().expose().to_vec();
draft.add(MobileEntryEditorFieldKind::OtpUri, None, uri.into())?;
assert!(draft.document().serialize().expose().starts_with(&before));
assert_eq!(
draft.page().fields().last().unwrap().kind(),
MobileEntryEditorFieldKind::OtpUri
);
service.save_recoverable(draft.document(), None, &mut Committer::default())?;
assert_eq!(
service
.open("otp/existing", &mut secrets)?
.fields()
.last()
.unwrap()
.value(),
uri.as_bytes()
);
Ok(())
}
#[test]
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
let fixture = FixtureSet::load()?;
+6
View File
@@ -260,6 +260,12 @@ fn qr_image_import_returns_secret_bytes_and_rejects_invalid_input() -> TestResul
image.write_to(&mut encoded, image::ImageFormat::Png)?;
let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?;
assert_eq!(decoded.expose(), payload.expose());
let mut oversized = Cursor::new(Vec::new());
image::GrayImage::new(4097, 1).write_to(&mut oversized, image::ImageFormat::Png)?;
assert!(matches!(
QrMatrix::decode_image(&SecretBytes::new(oversized.into_inner())),
Err(QrError::InvalidImage)
));
assert!(matches!(
QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())),
Err(QrError::InvalidImage)