Present desktop OTP and sensitive outputs

This commit is contained in:
2026-08-10 20:22:59 +02:00
parent 95bb10b4a1
commit 4a54bf7b6a
13 changed files with 1881 additions and 39 deletions

View File

@@ -16,12 +16,14 @@ flate2.workspace = true
gix.workspace = true
gix-config.workspace = true
hmac.workspace = true
image.workspace = true
keyring-core.workspace = true
pgp.workspace = true
qrcode.workspace = true
rand.workspace = true
regex.workspace = true
reqwest.workspace = true
rqrr.workspace = true
serde.workspace = true
sha1.workspace = true
sha2.workspace = true
@@ -49,6 +51,5 @@ arboard.workspace = true
hex = "0.4"
nix = { version = "0.31", features = ["fs"] }
rand_chacha = "0.3"
rqrr.workspace = true
smallvec = "1.15"
tempfile = "3"

View File

@@ -6,10 +6,16 @@ use crate::{
authentication::{
AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession,
},
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
command::{
CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, OtpInputSource,
OtpInsertRequest, RemoveRequest,
},
config::{Config, ConfigSettings, EditorCommand},
crypto::{KeyInfo, KeyStore, SecretProvider},
document::{DocumentError, EntryDocument, EntryDocumentService},
document::{
DocumentError, EntryDocument, EntryDocumentService, EntryFieldDraft, EntryFieldId,
EntryFieldKind,
},
git::{
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter,
EmbeddedFetchTransport, GitConflict, GitConflictResolution, GitError, GitIdentity,
@@ -17,13 +23,14 @@ use crate::{
PushOutcome, ReqwestGitTransport,
},
mutation::{MutationOutcome, TreeMutator},
presentation::ClipboardTimeout,
otp::{OtpAlgorithm, OtpCodeValidity, OtpInput, OtpKind, OtpService, OtpUri},
presentation::{ClipboardTimeout, QrMatrix},
read::{FindResults, GrepResults, TreeModel, VaultReader},
recipient::{
PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager,
RecipientPolicyOutcome,
},
repository::{DirectoryPath, Repository},
repository::{DirectoryPath, Repository, SecretBytes},
secret_store::SecretProtectionPolicy,
write::{OverwriteDecision, VaultWriter, WriteError, WriteOutcome},
};
@@ -42,6 +49,7 @@ pub enum DesktopErrorKind {
MissingDefaultKey,
EntryExists,
Mutation,
Otp,
}
#[derive(Debug)]
@@ -159,6 +167,112 @@ pub struct DesktopGitResult {
tree: Option<TreeModel>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DesktopOtpMetadata {
kind: OtpKind,
issuer: Option<String>,
account: String,
algorithm: OtpAlgorithm,
digits: u32,
}
impl DesktopOtpMetadata {
pub fn kind(&self) -> OtpKind {
self.kind
}
pub fn issuer(&self) -> Option<&str> {
self.issuer.as_deref()
}
pub fn account(&self) -> &str {
&self.account
}
pub fn algorithm(&self) -> OtpAlgorithm {
self.algorithm
}
pub fn digits(&self) -> u32 {
self.digits
}
}
pub struct DesktopOtpCode {
code: SecretBytes,
validity: OtpCodeValidity,
metadata: DesktopOtpMetadata,
tree: Option<TreeModel>,
document: Option<EntryDocument>,
}
impl DesktopOtpCode {
pub fn code(&self) -> &SecretBytes {
&self.code
}
pub fn validity(&self) -> OtpCodeValidity {
self.validity
}
pub fn metadata(&self) -> &DesktopOtpMetadata {
&self.metadata
}
pub fn into_parts(
self,
) -> (
SecretBytes,
OtpCodeValidity,
DesktopOtpMetadata,
Option<TreeModel>,
Option<EntryDocument>,
) {
(
self.code,
self.validity,
self.metadata,
self.tree,
self.document,
)
}
}
pub struct DesktopOtpUri {
payload: SecretBytes,
matrix: Option<QrMatrix>,
}
impl DesktopOtpUri {
pub fn payload(&self) -> &SecretBytes {
&self.payload
}
pub fn matrix(&self) -> Option<&QrMatrix> {
self.matrix.as_ref()
}
pub fn into_parts(self) -> (SecretBytes, Option<QrMatrix>) {
(self.payload, self.matrix)
}
}
pub struct DesktopOtpMutation {
entry: String,
document: EntryDocument,
tree: TreeModel,
}
impl DesktopOtpMutation {
pub fn entry(&self) -> &str {
&self.entry
}
pub fn into_parts(self) -> (String, EntryDocument, TreeModel) {
(self.entry, self.document, self.tree)
}
}
impl DesktopGitResult {
pub fn outcome(&self) -> &DesktopGitOutcome {
&self.outcome
@@ -420,6 +534,263 @@ impl DesktopStorage {
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
}
pub fn otp_code_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
unix_seconds: u64,
confirm_hotp: bool,
) -> Result<DesktopOtpCode, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.otp_code(entry, unix_seconds, confirm_hotp, &mut provider)
}
pub fn otp_code(
&self,
entry: &str,
unix_seconds: u64,
confirm_hotp: bool,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpCode, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let service = OtpService::new(&repository, &keys);
let uri = service
.uri(entry, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
if uri.kind() == OtpKind::Hotp && !confirm_hotp {
return Err(DesktopError::new(
DesktopErrorKind::Otp,
"HOTP generation requires explicit confirmation because it commits the advanced counter",
));
}
let metadata = DesktopOtpMetadata {
kind: uri.kind(),
issuer: uri.issuer().map(str::to_owned),
account: uri.account().to_owned(),
algorithm: uri.algorithm(),
digits: uri.digits(),
};
let outcome = service
.code_automatic(entry, unix_seconds, None, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let validity = outcome.validity();
let changed = validity.counter().is_some();
let tree = changed.then(|| self.tree()).transpose()?;
let document = changed
.then(|| {
EntryDocumentService::new(&repository, &keys)
.open(entry, provider)
.map_err(DesktopError::document)
})
.transpose()?;
Ok(DesktopOtpCode {
code: SecretBytes::new(outcome.code().expose().to_vec()),
validity,
metadata,
tree,
document,
})
}
pub fn otp_uri_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
qr: bool,
) -> Result<DesktopOtpUri, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.otp_uri(entry, qr, &mut provider)
}
pub fn otp_uri(
&self,
entry: &str,
qr: bool,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpUri, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let uri = OtpService::new(&repository, &keys)
.uri(entry, provider)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let payload = SecretBytes::new(uri.encoded().expose().to_vec());
let matrix = qr
.then(|| QrMatrix::encode(&payload))
.transpose()
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
Ok(DesktopOtpUri { payload, matrix })
}
pub fn import_otp_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
uri: SecretBytes,
replace: OverwriteDecision,
) -> Result<DesktopOtpMutation, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.import_otp(entry, uri, replace, &mut provider)
}
pub fn import_otp_qr_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
image: SecretBytes,
replace: OverwriteDecision,
) -> Result<DesktopOtpMutation, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.import_otp_qr(entry, image, replace, &mut provider)
}
pub fn import_otp_qr(
&self,
entry: &str,
image: SecretBytes,
replace: OverwriteDecision,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpMutation, DesktopError> {
let uri = QrMatrix::decode_image(&image)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
self.import_otp(entry, uri, replace, provider)
}
pub fn import_otp(
&self,
entry: &str,
uri: SecretBytes,
replace: OverwriteDecision,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpMutation, DesktopError> {
let parsed =
OtpUri::parse(uri).map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let encoded = parsed.encoded().expose().to_vec();
let repository = self.repository()?;
let keys = self.keys()?;
let exists = VaultWriter::new(&repository, &keys)
.entry_exists(entry)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
if exists {
let mut document = EntryDocumentService::new(&repository, &keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
if let Some(field) = otp_field(&document)? {
if replace == OverwriteDecision::Decline {
return Err(DesktopError::new(
DesktopErrorKind::EntryExists,
format!("entry already contains an OTP URI: {entry}"),
));
}
document
.replace_field_value(field, encoded.clone())
.map_err(DesktopError::document)?;
} else {
let index = document.fields().len();
document
.add(
index,
EntryFieldDraft::otp_uri(encoded.clone())
.map_err(DesktopError::document)?,
)
.map_err(DesktopError::document)?;
}
self.save_document(&document)?;
let document = EntryDocumentService::new(&repository, &keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
return Ok(DesktopOtpMutation {
entry: entry.to_owned(),
document,
tree: self.tree()?,
});
}
let request = OtpInsertRequest {
entry: Some(entry.to_owned()),
force: false,
echo: true,
source: OtpInputSource::Uri,
};
let input = OtpInput::line(encoded)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let service = OtpService::new(&repository, &keys);
let plan = service
.prepare_insert(&request, input)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let mut committer =
AutomaticEntryCommitter::for_entry(&repository, entry, GitIdentity::ironstorage())
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
service
.finish_insert(
plan,
OverwriteDecision::Allow,
OverwriteDecision::Decline,
None,
&mut committer,
)
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
let document = EntryDocumentService::new(&repository, &keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
Ok(DesktopOtpMutation {
entry: entry.to_owned(),
document,
tree: self.tree()?,
})
}
pub fn remove_otp_active(
&self,
handle: &NativeAuthenticationHandle,
entry: &str,
) -> Result<DesktopOtpMutation, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
let mut provider = handle.clone();
self.remove_otp(entry, &mut provider)
}
pub fn remove_otp(
&self,
entry: &str,
provider: &mut impl SecretProvider,
) -> Result<DesktopOtpMutation, DesktopError> {
let repository = self.repository()?;
let keys = self.keys()?;
let mut document = EntryDocumentService::new(&repository, &keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
let field = otp_field(&document)?.ok_or_else(|| {
DesktopError::new(
DesktopErrorKind::Otp,
format!("entry does not contain an OTP URI: {entry}"),
)
})?;
document.remove(field).map_err(DesktopError::document)?;
self.save_document(&document)?;
let document = EntryDocumentService::new(&repository, &keys)
.open(entry, provider)
.map_err(DesktopError::document)?;
Ok(DesktopOtpMutation {
entry: entry.to_owned(),
document,
tree: self.tree()?,
})
}
pub fn mutate_active(
&self,
handle: &NativeAuthenticationHandle,
@@ -611,6 +982,22 @@ impl DesktopStorage {
}
}
fn otp_field(document: &EntryDocument) -> Result<Option<EntryFieldId>, DesktopError> {
let mut fields = document
.fields()
.iter()
.filter(|field| field.metadata().kind() == EntryFieldKind::OtpUri)
.map(|field| field.id());
let first = fields.next();
if fields.next().is_some() {
return Err(DesktopError::new(
DesktopErrorKind::Otp,
format!("entry contains multiple OTP URIs: {}", document.path()),
));
}
Ok(first)
}
struct ConfigPolicyCommitter<'a> {
git: AutomaticPolicyCommitter,
previous: &'a Config,

View File

@@ -550,20 +550,6 @@ fn classify_all(fields: &mut [EntryField]) {
}
fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
if index == 0 {
return EntryFieldMetadata {
kind: EntryFieldKind::Password,
sensitivity: EntrySensitivity::Sensitive,
name: Some("password".to_owned()),
otp: None,
diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err())
.then_some(EntryFieldDiagnostic::NonUtf8Value),
value: 0..line.len(),
};
}
if line.is_empty() {
return blank_metadata();
}
if line.starts_with(b"otpauth://")
&& let Ok(uri) = OtpUri::parse(SecretBytes::new(line.to_vec()))
{
@@ -585,6 +571,20 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
value: 0..line.len(),
};
}
if index == 0 {
return EntryFieldMetadata {
kind: EntryFieldKind::Password,
sensitivity: EntrySensitivity::Sensitive,
name: Some("password".to_owned()),
otp: None,
diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err())
.then_some(EntryFieldDiagnostic::NonUtf8Value),
value: 0..line.len(),
};
}
if line.is_empty() {
return blank_metadata();
}
if line.starts_with(b"otpauth://") {
return EntryFieldMetadata {
kind: EntryFieldKind::OtpUri,

View File

@@ -198,6 +198,9 @@ impl NativeClipboardManager {
pub enum QrError {
EmptyPayload,
PayloadTooLarge,
InvalidImage,
NotFound,
InvalidPayload,
}
impl fmt::Display for QrError {
@@ -205,6 +208,9 @@ impl fmt::Display for QrError {
match self {
Self::EmptyPayload => formatter.write_str("empty data cannot be encoded as a QR code"),
Self::PayloadTooLarge => formatter.write_str("the QR payload is too large"),
Self::InvalidImage => formatter.write_str("the selected file is not a supported image"),
Self::NotFound => formatter.write_str("the image does not contain a QR code"),
Self::InvalidPayload => formatter.write_str("the QR code contains invalid text"),
}
}
}
@@ -233,6 +239,26 @@ impl QrMatrix {
Ok(Self { width, modules })
}
/// 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())
.map_err(|_| QrError::InvalidImage)?
.into_luma8();
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
grayscale.width() as usize,
grayscale.height() as usize,
|x, y| grayscale.get_pixel(x as u32, y as u32).0[0],
);
let grid = prepared
.detect_grids()
.into_iter()
.next()
.ok_or(QrError::NotFound)?;
let (_, payload) = grid.decode().map_err(|_| QrError::InvalidPayload)?;
Ok(SecretBytes::new(payload.into_bytes()))
}
pub fn width(&self) -> usize {
self.width
}

View File

@@ -148,6 +148,36 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
Ok(())
}
#[test]
fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> TestResult {
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);
write_plaintext(
&repository,
&keys,
"documents/otp-only",
b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\n",
)?;
let document =
EntryDocumentService::new(&repository, &keys).open("documents/otp-only", &mut secrets)?;
assert_eq!(document.fields().len(), 1);
assert_eq!(
document.fields()[0].metadata().kind(),
EntryFieldKind::OtpUri
);
assert_eq!(
document.fields()[0]
.metadata()
.otp()
.and_then(|otp| otp.issuer()),
Some("Example")
);
Ok(())
}
#[test]
fn field_ids_survive_updates_removal_and_reordering() -> TestResult {
let fixture = FixtureSet::load()?;

View File

@@ -8,6 +8,7 @@ use data_encoding::BASE32_NOPAD;
use ironstorage::{
command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest},
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
desktop::{DesktopErrorKind, DesktopStorage},
git::{GitIdentity, GitRepository},
otp::{OtpAlgorithm, OtpCodeValidity, OtpError, OtpInput, OtpKind, OtpService, OtpUri},
recipient::RecipientPolicyManager,
@@ -513,6 +514,147 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
Ok(())
}
#[test]
fn desktop_otp_boundary_uses_rfc_codes_and_commits_import_replace_and_removal() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let recipients = RecipientPolicyManager::new(&repository, &keys)
.resolve_for_entry(&EntryPath::parse("desktop/rfc-totp")?, None)?;
let secret = BASE32_NOPAD.encode(b"12345678901234567890");
for (entry, plaintext) in [
(
"desktop/rfc-totp",
format!(
"password\notpauth://totp/RFC6238?secret={secret}&algorithm=SHA1&digits=8&period=30\n"
),
),
(
"desktop/rfc-hotp",
format!("password\notpauth://hotp/RFC4226?secret={secret}&counter=0\n"),
),
] {
repository.write_entry(
&EntryPath::parse(entry)?,
&keys.encrypt(
SecretBytes::new(plaintext.into_bytes()),
recipients.recipients(),
)?,
)?;
}
GitRepository::init(&repository, GitIdentity::ironstorage())?;
let config_path = store.path().join("desktop-config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n",
store.path(),
fixture.generated.keys[0].primary_fingerprint,
fixture.path("keys"),
),
)?;
let desktop = DesktopStorage::load(Some(&config_path))?;
let mut provider = FixtureSecrets::all(&fixture);
let totp = desktop.otp_code("desktop/rfc-totp", 59, false, &mut provider)?;
assert_eq!(totp.code().expose(), b"94287082");
assert_eq!(
totp.validity(),
OtpCodeValidity::Timed {
valid_until: 60,
period: 30,
}
);
assert_eq!(totp.metadata().kind(), OtpKind::Totp);
assert_eq!(totp.metadata().digits(), 8);
let hotp_path = EntryPath::parse("desktop/rfc-hotp")?;
let before = repository.read_entry(&hotp_path)?;
let denied = match desktop.otp_code("desktop/rfc-hotp", 59, false, &mut provider) {
Ok(_) => panic!("HOTP requires confirmation"),
Err(error) => error,
};
assert_eq!(denied.kind(), DesktopErrorKind::Otp);
assert_eq!(repository.read_entry(&hotp_path)?, before);
let hotp = desktop.otp_code("desktop/rfc-hotp", 59, true, &mut provider)?;
assert_eq!(hotp.code().expose(), b"287082");
assert_eq!(
hotp.validity(),
OtpCodeValidity::CounterBased { counter: 1 }
);
let (_, _, _, tree, document) = hotp.into_parts();
assert!(tree.is_some());
assert_eq!(
document
.expect("HOTP returns refreshed document")
.fields()
.iter()
.find_map(|field| field.metadata().otp())
.and_then(|otp| otp.counter()),
Some(1)
);
let replacement = SecretBytes::new(
b"otpauth://totp/Replaced:alice?secret=JBSWY3DPEHPK3PXP&issuer=Replaced".to_vec(),
);
let before = repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?;
assert!(
desktop
.import_otp(
"desktop/rfc-totp",
SecretBytes::new(replacement.expose().to_vec()),
OverwriteDecision::Decline,
&mut provider,
)
.is_err()
);
assert_eq!(
repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?,
before
);
assert!(
desktop
.import_otp_qr(
"desktop/rfc-totp",
SecretBytes::new(b"not an image".to_vec()),
OverwriteDecision::Allow,
&mut provider,
)
.is_err()
);
assert_eq!(
repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?,
before
);
let replaced = desktop.import_otp(
"desktop/rfc-totp",
replacement,
OverwriteDecision::Allow,
&mut provider,
)?;
assert_eq!(
replaced
.into_parts()
.1
.fields()
.iter()
.find_map(|field| field.metadata().otp())
.and_then(|otp| otp.issuer()),
Some("Replaced")
);
let removed = desktop.remove_otp("desktop/rfc-totp", &mut provider)?;
assert!(
removed
.into_parts()
.1
.fields()
.iter()
.all(|field| field.metadata().kind() != ironstorage::document::EntryFieldKind::OtpUri)
);
Ok(())
}
#[test]
fn read_only_totp_ignores_git_that_mutations_correctly_reject() -> TestResult {
let fixture = FixtureSet::load()?;

View File

@@ -2,6 +2,7 @@
use std::{
error::Error,
io::Cursor,
sync::{Arc, Mutex},
time::Duration,
};
@@ -229,3 +230,39 @@ fn qr_matrices_round_trip_and_render_without_plaintext() -> TestResult {
));
Ok(())
}
#[test]
fn qr_image_import_returns_secret_bytes_and_rejects_invalid_input() -> TestResult {
let payload = SecretBytes::new(
b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example".to_vec(),
);
let matrix = QrMatrix::encode(&payload)?;
let scale = 8_u32;
let quiet = 4_u32;
let size = (matrix.width() as u32 + quiet * 2) * scale;
let mut image = image::GrayImage::from_pixel(size, size, image::Luma([255]));
for y in 0..matrix.width() {
for x in 0..matrix.width() {
if matrix.is_dark(x, y) == Some(true) {
for dy in 0..scale {
for dx in 0..scale {
image.put_pixel(
(x as u32 + quiet) * scale + dx,
(y as u32 + quiet) * scale + dy,
image::Luma([0]),
);
}
}
}
}
}
let mut encoded = Cursor::new(Vec::new());
image.write_to(&mut encoded, image::ImageFormat::Png)?;
let decoded = QrMatrix::decode_image(&SecretBytes::new(encoded.into_inner()))?;
assert_eq!(decoded.expose(), payload.expose());
assert!(matches!(
QrMatrix::decode_image(&SecretBytes::new(b"not an image".to_vec())),
Err(QrError::InvalidImage)
));
Ok(())
}