Present desktop OTP and sensitive outputs
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user