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