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

137
Cargo.lock generated
View File

@@ -806,6 +806,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -1194,6 +1200,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -2172,6 +2184,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float_next_after"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2556,6 +2574,16 @@ dependencies = [
"polyval",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -3971,6 +3999,7 @@ dependencies = [
"iced_core",
"iced_futures",
"log",
"lyon_path",
"raw-window-handle",
"rustc-hash 2.1.3",
"thiserror 2.0.19",
@@ -4045,6 +4074,7 @@ dependencies = [
"iced_debug",
"iced_graphics",
"log",
"lyon",
"rustc-hash 2.1.3",
"thiserror 2.0.19",
"wgpu",
@@ -4201,6 +4231,23 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -4289,6 +4336,7 @@ dependencies = [
"gix-config",
"hex",
"hmac",
"image",
"keyring-core",
"nix 0.31.3",
"pgp",
@@ -4745,6 +4793,58 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lyon"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7"
dependencies = [
"lyon_algorithms",
"lyon_tessellation",
]
[[package]]
name = "lyon_algorithms"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31"
dependencies = [
"lyon_path",
"num-traits",
]
[[package]]
name = "lyon_geom"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
dependencies = [
"arrayvec",
"euclid",
"num-traits",
]
[[package]]
name = "lyon_path"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
dependencies = [
"lyon_geom",
"num-traits",
]
[[package]]
name = "lyon_tessellation"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552"
dependencies = [
"float_next_after",
"lyon_path",
"num-traits",
]
[[package]]
name = "mac_address"
version = "1.1.8"
@@ -4859,6 +4959,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.19.3"
@@ -6169,6 +6279,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "qrcode"
version = "0.14.1"
@@ -8641,6 +8757,12 @@ dependencies = [
"nom 7.1.3",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wezterm-bidi"
version = "0.2.3"
@@ -9610,6 +9732,21 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.13.1"

View File

@@ -28,7 +28,8 @@ flate2 = "1.1"
gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] }
gix-config = "0.59"
hmac = "0.12"
iced = { version = "0.14", features = ["tokio"] }
iced = { version = "0.14", features = ["canvas", "tokio"] }
image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] }
ironstorage = { path = "crates/storage" }
keyring-core = "1.0"
muda = "0.19"

View File

@@ -32,6 +32,13 @@ pub enum UiAction {
CopyEntry,
DeleteEntry,
ToggleReveal,
GenerateOtp,
CopyOtp,
ImportOtp,
ShowOtpUri,
CopyOtpUri,
ShowOtpQr,
RemoveOtp,
GitStatus,
GitPull,
GitPush,
@@ -72,6 +79,13 @@ impl UiAction {
Self::CopyEntry => "copy-entry",
Self::DeleteEntry => "delete-entry",
Self::ToggleReveal => "toggle-reveal",
Self::GenerateOtp => "generate-otp",
Self::CopyOtp => "copy-otp",
Self::ImportOtp => "import-otp",
Self::ShowOtpUri => "show-otp-uri",
Self::CopyOtpUri => "copy-otp-uri",
Self::ShowOtpQr => "show-otp-qr",
Self::RemoveOtp => "remove-otp",
Self::GitStatus => "git-status",
Self::GitPull => "git-pull",
Self::GitPush => "git-push",
@@ -135,6 +149,7 @@ pub struct ActionContext {
pub focused_field: bool,
pub focused_sensitive: bool,
pub focused_generatable: bool,
pub focused_otp: bool,
pub entry_path: bool,
pub selected_object: bool,
pub git_running: bool,
@@ -237,6 +252,23 @@ pub const ACTIONS: &[ActionSpec] = &[
"Reveal or Hide Field",
None,
),
spec(
UiAction::GenerateOtp,
MenuGroup::Entry,
"Generate OTP Code",
None,
),
spec(UiAction::CopyOtp, MenuGroup::Entry, "Copy OTP Code", None),
spec(
UiAction::ImportOtp,
MenuGroup::Entry,
"Import OTP URI or QR…",
None,
),
spec(UiAction::ShowOtpUri, MenuGroup::Entry, "Show OTP URI", None),
spec(UiAction::CopyOtpUri, MenuGroup::Entry, "Copy OTP URI", None),
spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None),
spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None),
spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", None),
spec(UiAction::GitPull, MenuGroup::Tools, "Pull", None),
spec(UiAction::GitPush, MenuGroup::Tools, "Push", None),
@@ -378,6 +410,28 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
&& !context.switching_vault
&& context.focused_sensitive
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp => {
context.unlocked
&& context.document_open
&& !context.editing
&& context.focused_otp
&& !context.saving
&& !context.switching_vault
&& !context.modal_open
}
UiAction::ImportOtp => {
context.storage_ready
&& context.unlocked
&& !context.dirty
&& !context.saving
&& !context.switching_vault
&& !context.modal_open
}
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => {
context.storage_ready
&& !context.saving
@@ -497,6 +551,58 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
UiAction::ToggleReveal if !context.document_open => "Open an entry first",
UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first",
UiAction::ToggleReveal => "Wait for vault validation",
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
if !context.unlocked =>
{
"Unlock an entry first"
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
if !context.document_open =>
{
"Open an entry first"
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
if !context.focused_otp =>
{
"Select an OTP URI field first"
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
if context.editing =>
{
"Finish editing the entry first"
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp => "Close the current screen first",
UiAction::ImportOtp if !context.storage_ready => "Shared configuration is unavailable",
UiAction::ImportOtp if !context.unlocked => "Unlock the password store first",
UiAction::ImportOtp if context.dirty => "Save or discard the current draft first",
UiAction::ImportOtp if context.saving => "Wait for the active save",
UiAction::ImportOtp if context.switching_vault => "Wait for vault validation",
UiAction::ImportOtp => "Close the current screen first",
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
if !context.storage_ready =>
{
@@ -561,6 +667,13 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] {
UiAction::CopyEntry => &["duplicate entry", "copy folder", "pass cp"],
UiAction::DeleteEntry => &["remove", "rm", "delete folder"],
UiAction::ToggleReveal => &["show password", "hide password", "reveal field"],
UiAction::GenerateOtp => &["totp", "hotp", "one time password"],
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
UiAction::ShowOtpUri => &["show otpauth", "otp secret"],
UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"],
UiAction::ShowOtpQr => &["otp qr", "export otp"],
UiAction::RemoveOtp => &["delete otp", "remove otpauth"],
UiAction::GitStatus => &["repository status", "git history"],
UiAction::GitPull => &["fetch", "download changes"],
UiAction::GitPush => &["upload changes"],
@@ -623,6 +736,7 @@ mod tests {
focused_field: true,
focused_sensitive: true,
focused_generatable: true,
focused_otp: true,
entry_path: true,
selected_object: true,
git_running: false,
@@ -672,6 +786,17 @@ mod tests {
assert!(enabled(UiAction::InitializeStore, viewing));
assert!(enabled(UiAction::NewFolder, viewing));
assert!(enabled(UiAction::NewEntry, viewing));
for action in [
UiAction::GenerateOtp,
UiAction::CopyOtp,
UiAction::ShowOtpUri,
UiAction::CopyOtpUri,
UiAction::ShowOtpQr,
UiAction::RemoveOtp,
] {
assert!(enabled(action, viewing), "{action:?}");
}
assert!(enabled(UiAction::ImportOtp, viewing));
let locked = ActionContext {
unlocked: false,
@@ -683,6 +808,13 @@ mod tests {
UiAction::CopyEditedField,
UiAction::ToggleReveal,
UiAction::GeneratePassword,
UiAction::GenerateOtp,
UiAction::CopyOtp,
UiAction::ImportOtp,
UiAction::ShowOtpUri,
UiAction::CopyOtpUri,
UiAction::ShowOtpQr,
UiAction::RemoveOtp,
UiAction::Lock,
] {
assert!(!enabled(action, locked));

View File

@@ -2,6 +2,8 @@
use std::path::PathBuf;
use ironstorage::repository::SecretBytes;
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
let mut dialog = rfd::AsyncFileDialog::new().set_title("Open Password Store");
@@ -14,6 +16,22 @@ pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, St
.map(|folder| folder.path().to_owned()))
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
let selected = rfd::AsyncFileDialog::new()
.set_title("Import OTP QR Image")
.add_filter("Image", &["png", "jpg", "jpeg", "gif"])
.pick_file()
.await;
selected
.map(|file| {
std::fs::read(file.path())
.map(SecretBytes::new)
.map_err(|error| error.to_string())
})
.transpose()
}
#[cfg(target_os = "linux")]
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
use ashpd::{
@@ -47,6 +65,37 @@ pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, St
.map(Some)
}
#[cfg(target_os = "linux")]
pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
use ashpd::{
PortalError,
desktop::{file_chooser::SelectedFiles, request::ResponseError},
};
let response = SelectedFiles::open_file()
.title("Import OTP QR Image")
.accept_label("Import")
.modal(true)
.multiple(false)
.send()
.await
.and_then(|request| request.response());
let selected = match response {
Ok(selected) => selected,
Err(ashpd::Error::Response(ResponseError::Cancelled))
| Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None),
Err(error) => return Err(error.to_string()),
};
let Some(uri) = selected.uris().first() else {
return Err("file portal returned no selection".to_owned());
};
let path = file_uri_path(uri.as_str())?;
std::fs::read(path)
.map(SecretBytes::new)
.map(Some)
.map_err(|error| error.to_string())
}
#[cfg(target_os = "linux")]
fn file_uri_path(uri: &str) -> Result<PathBuf, String> {
let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?;

File diff suppressed because it is too large Load Diff

View File

@@ -145,6 +145,13 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
| UiAction::CopyEntry
| UiAction::DeleteEntry
| UiAction::ToggleReveal
| UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ImportOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
| UiAction::GitStatus
| UiAction::GitPull
| UiAction::GitPush

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(())
}