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

@@ -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(