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

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