Add additive KDBX importer
This commit is contained in:
@@ -18,6 +18,7 @@ gix-config.workspace = true
|
||||
hmac.workspace = true
|
||||
image.workspace = true
|
||||
keyring-core.workspace = true
|
||||
keepass.workspace = true
|
||||
pgp.workspace = true
|
||||
qrcode.workspace = true
|
||||
rand.workspace = true
|
||||
@@ -49,6 +50,7 @@ arboard.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
hex = "0.4"
|
||||
keepass = { workspace = true, features = ["save_kdbx4"] }
|
||||
nix = { version = "0.31", features = ["fs"] }
|
||||
rand_chacha = "0.3"
|
||||
smallvec = "1.15"
|
||||
|
||||
@@ -6,6 +6,7 @@ use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
|
||||
use clap_complete::{Shell, generate};
|
||||
|
||||
use crate::PRODUCT_NAME;
|
||||
use crate::kdbx::{KdbxImportMode, KdbxImportRequest};
|
||||
|
||||
pub const EXIT_SUCCESS: u8 = 0;
|
||||
pub const EXIT_FAILURE: u8 = 1;
|
||||
@@ -52,6 +53,7 @@ pub enum CommandRequest {
|
||||
Remove(RemoveRequest),
|
||||
Move(MoveRequest),
|
||||
Copy(CopyRequest),
|
||||
ImportKdbx(KdbxImportRequest),
|
||||
Git(GitRequest),
|
||||
Otp(OtpRequest),
|
||||
Completion { shell: CompletionShell },
|
||||
@@ -329,6 +331,7 @@ pub enum HelpTopic {
|
||||
Remove,
|
||||
Move,
|
||||
Copy,
|
||||
ImportKdbx,
|
||||
Git,
|
||||
Otp,
|
||||
}
|
||||
@@ -347,6 +350,7 @@ impl HelpTopic {
|
||||
"rm" | "remove" | "delete" => Some(Self::Remove),
|
||||
"mv" | "rename" => Some(Self::Move),
|
||||
"cp" | "copy" => Some(Self::Copy),
|
||||
"import-kdbx" => Some(Self::ImportKdbx),
|
||||
"git" => Some(Self::Git),
|
||||
"otp" => Some(Self::Otp),
|
||||
_ => None,
|
||||
@@ -366,6 +370,7 @@ impl HelpTopic {
|
||||
Self::Remove => "rm",
|
||||
Self::Move => "mv",
|
||||
Self::Copy => "cp",
|
||||
Self::ImportKdbx => "import-kdbx",
|
||||
Self::Git => "git",
|
||||
Self::Otp => "otp",
|
||||
}
|
||||
@@ -480,6 +485,8 @@ enum CommandArguments {
|
||||
Move(MoveArguments),
|
||||
#[command(name = "cp", visible_alias = "copy")]
|
||||
Copy(CopyArguments),
|
||||
#[command(name = "import-kdbx")]
|
||||
ImportKdbx(ImportKdbxArguments),
|
||||
Git(GitArguments),
|
||||
Otp(OtpArguments),
|
||||
Completion(CompletionArguments),
|
||||
@@ -616,6 +623,15 @@ struct CopyArguments {
|
||||
destination: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct ImportKdbxArguments {
|
||||
source: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
key_file: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
quick_add: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct GitArguments {
|
||||
#[command(subcommand)]
|
||||
@@ -824,6 +840,17 @@ fn convert_arguments(arguments: CliArguments) -> Result<CliInvocation, CliParseE
|
||||
destination: arguments.destination,
|
||||
force: arguments.force,
|
||||
}),
|
||||
Some(CommandArguments::ImportKdbx(arguments)) => {
|
||||
CommandRequest::ImportKdbx(KdbxImportRequest::new(
|
||||
arguments.source,
|
||||
arguments.key_file,
|
||||
if arguments.quick_add {
|
||||
KdbxImportMode::QuickAdd
|
||||
} else {
|
||||
KdbxImportMode::AddAndUpdate
|
||||
},
|
||||
))
|
||||
}
|
||||
Some(CommandArguments::Git(arguments)) => {
|
||||
CommandRequest::Git(convert_git(arguments.command)?)
|
||||
}
|
||||
@@ -987,6 +1014,7 @@ fn normalize_dispatch(mut arguments: Vec<OsString>) -> Vec<OsString> {
|
||||
"rename",
|
||||
"cp",
|
||||
"copy",
|
||||
"import-kdbx",
|
||||
"git",
|
||||
"otp",
|
||||
"help",
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::{
|
||||
GitOperationControl, GitProgressPhase, GitRepository, GitSnapshot, PullOutcome,
|
||||
PushOutcome, ReqwestGitTransport,
|
||||
},
|
||||
kdbx::{KdbxImportOutcome, KdbxImportRequest, KdbxImporter},
|
||||
mutation::{MutationOutcome, TreeMutator},
|
||||
otp::{OtpAlgorithm, OtpCodeValidity, OtpInput, OtpKind, OtpService, OtpUri},
|
||||
presentation::{ClipboardTimeout, QrMatrix},
|
||||
@@ -55,6 +56,7 @@ pub enum DesktopErrorKind {
|
||||
EntryExists,
|
||||
Mutation,
|
||||
Otp,
|
||||
Import,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -545,6 +547,27 @@ impl DesktopStorage {
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
||||
}
|
||||
|
||||
pub fn import_kdbx_active(
|
||||
&self,
|
||||
handle: &NativeAuthenticationHandle,
|
||||
request: &KdbxImportRequest,
|
||||
password: SecretBytes,
|
||||
) -> Result<(KdbxImportOutcome, TreeModel), DesktopError> {
|
||||
handle
|
||||
.ensure_active()
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
|
||||
let repository = self.repository()?;
|
||||
let keys = self.keys()?;
|
||||
let mut provider = handle.clone();
|
||||
let outcome = KdbxImporter::new(&repository, keys)
|
||||
.import(request, password, &mut provider, GitIdentity::ironstorage())
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Import, error))?;
|
||||
let tree = VaultReader::new(&repository, keys)
|
||||
.list(&DirectoryPath::root())
|
||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))?;
|
||||
Ok((outcome, tree))
|
||||
}
|
||||
|
||||
pub fn otp_code_active(
|
||||
&self,
|
||||
handle: &NativeAuthenticationHandle,
|
||||
|
||||
560
crates/storage/src/kdbx.rs
Normal file
560
crates/storage/src/kdbx.rs
Normal file
@@ -0,0 +1,560 @@
|
||||
//! Direct, additive KeePass KDBX imports into a pass-compatible repository.
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
error::Error,
|
||||
fmt, fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use keepass::{
|
||||
Database, DatabaseKey,
|
||||
db::{Entry, EntryRef, GroupRef, Icon, fields},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
command::{EditRequest, InsertInput, InsertRequest},
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
git::{AutomaticEntryCommitter, GitError, GitIdentity},
|
||||
repository::{EntryPath, Repository, RepositoryError, SecretBytes},
|
||||
write::{InsertContent, OverwriteDecision, VaultWriter, WriteError},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum KdbxImportMode {
|
||||
AddAndUpdate,
|
||||
QuickAdd,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct KdbxImportRequest {
|
||||
source: PathBuf,
|
||||
key_file: Option<PathBuf>,
|
||||
mode: KdbxImportMode,
|
||||
}
|
||||
|
||||
impl KdbxImportRequest {
|
||||
pub fn new(
|
||||
source: impl Into<PathBuf>,
|
||||
key_file: Option<PathBuf>,
|
||||
mode: KdbxImportMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
source: source.into(),
|
||||
key_file,
|
||||
mode,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source(&self) -> &Path {
|
||||
&self.source
|
||||
}
|
||||
|
||||
pub fn key_file(&self) -> Option<&Path> {
|
||||
self.key_file.as_deref()
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> KdbxImportMode {
|
||||
self.mode
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct KdbxImportOutcome {
|
||||
added: usize,
|
||||
updated: usize,
|
||||
unchanged: usize,
|
||||
skipped: usize,
|
||||
changed_paths: Vec<EntryPath>,
|
||||
}
|
||||
|
||||
impl KdbxImportOutcome {
|
||||
pub fn added(&self) -> usize {
|
||||
self.added
|
||||
}
|
||||
|
||||
pub fn updated(&self) -> usize {
|
||||
self.updated
|
||||
}
|
||||
|
||||
pub fn unchanged(&self) -> usize {
|
||||
self.unchanged
|
||||
}
|
||||
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.skipped
|
||||
}
|
||||
|
||||
pub fn changed_paths(&self) -> &[EntryPath] {
|
||||
&self.changed_paths
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KdbxImporter<'a> {
|
||||
repository: &'a Repository,
|
||||
keys: &'a KeyStore,
|
||||
}
|
||||
|
||||
impl<'a> KdbxImporter<'a> {
|
||||
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self {
|
||||
Self { repository, keys }
|
||||
}
|
||||
|
||||
pub fn import(
|
||||
&self,
|
||||
request: &KdbxImportRequest,
|
||||
password: SecretBytes,
|
||||
secrets: &mut impl SecretProvider,
|
||||
identity: GitIdentity,
|
||||
) -> Result<KdbxImportOutcome, KdbxImportError> {
|
||||
let mut source =
|
||||
fs::File::open(request.source()).map_err(|source| KdbxImportError::Io {
|
||||
operation: "open KDBX database",
|
||||
path: request.source.clone(),
|
||||
source,
|
||||
})?;
|
||||
let password = std::str::from_utf8(password.expose())
|
||||
.map_err(|_| KdbxImportError::PasswordEncoding)?;
|
||||
let mut key = DatabaseKey::new().with_password(password);
|
||||
if let Some(path) = request.key_file() {
|
||||
let mut file = fs::File::open(path).map_err(|source| KdbxImportError::Io {
|
||||
operation: "open KDBX key file",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
key = key
|
||||
.with_keyfile(&mut file)
|
||||
.map_err(|source| KdbxImportError::Io {
|
||||
operation: "read KDBX key file",
|
||||
path: path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let database = Database::open(&mut source, key).map_err(KdbxImportError::Open)?;
|
||||
let items = collect_items(&database)?;
|
||||
let writer = VaultWriter::new(self.repository, self.keys);
|
||||
let mut outcome = KdbxImportOutcome::default();
|
||||
|
||||
for item in items {
|
||||
let path = item.path.to_string();
|
||||
let exists = writer.entry_exists(&path)?;
|
||||
if exists && request.mode() == KdbxImportMode::QuickAdd {
|
||||
outcome.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let mut committer =
|
||||
AutomaticEntryCommitter::for_entry(self.repository, &path, identity.clone())?;
|
||||
if exists {
|
||||
let session = writer.begin_edit(&EditRequest { entry: path }, secrets)?;
|
||||
if session.plaintext().expose() == item.contents.expose() {
|
||||
outcome.unchanged += 1;
|
||||
continue;
|
||||
}
|
||||
writer.finish_edit(
|
||||
session,
|
||||
item.contents,
|
||||
"IronStorage KDBX importer",
|
||||
None,
|
||||
&mut committer,
|
||||
)?;
|
||||
outcome.updated += 1;
|
||||
} else {
|
||||
writer.insert(
|
||||
&InsertRequest {
|
||||
entry: path,
|
||||
input: InsertInput::Multiline,
|
||||
force: false,
|
||||
},
|
||||
InsertContent::multiline(item.contents.expose().to_vec()),
|
||||
OverwriteDecision::Decline,
|
||||
None,
|
||||
&mut committer,
|
||||
)?;
|
||||
outcome.added += 1;
|
||||
}
|
||||
outcome.changed_paths.push(item.path);
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportItem {
|
||||
path: EntryPath,
|
||||
contents: SecretBytes,
|
||||
}
|
||||
|
||||
fn collect_items(database: &Database) -> Result<Vec<ImportItem>, KdbxImportError> {
|
||||
let mut items = Vec::new();
|
||||
let mut used = BTreeSet::new();
|
||||
let recycle_bin = database.recycle_bin().map(|group| group.id());
|
||||
collect_group(database.root(), &[], recycle_bin, &mut used, &mut items)?;
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn collect_group(
|
||||
group: GroupRef<'_>,
|
||||
components: &[String],
|
||||
recycle_bin: Option<keepass::db::GroupId>,
|
||||
used: &mut BTreeSet<PathBuf>,
|
||||
items: &mut Vec<ImportItem>,
|
||||
) -> Result<(), KdbxImportError> {
|
||||
for entry in group.entries() {
|
||||
collect_entry(entry, components, used, items)?;
|
||||
}
|
||||
for child in group.groups() {
|
||||
if Some(child.id()) == recycle_bin {
|
||||
continue;
|
||||
}
|
||||
let mut child_components = components.to_vec();
|
||||
let name = sanitize_component(&child.name);
|
||||
if !name.is_empty() {
|
||||
child_components.push(name);
|
||||
}
|
||||
collect_group(child, &child_components, recycle_bin, used, items)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_entry(
|
||||
entry: EntryRef<'_>,
|
||||
components: &[String],
|
||||
used: &mut BTreeSet<PathBuf>,
|
||||
items: &mut Vec<ImportItem>,
|
||||
) -> Result<(), KdbxImportError> {
|
||||
let title = entry_title(&entry);
|
||||
let attachments = entry
|
||||
.attachments_named()
|
||||
.map(|(name, attachment)| (name.to_owned(), attachment.data.get().to_vec()))
|
||||
.collect::<Vec<_>>();
|
||||
let attachment_names = attachments
|
||||
.iter()
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
items.push(ImportItem {
|
||||
path: reserve_path(components, &title, used)?,
|
||||
contents: render_entry(&entry, &attachment_names),
|
||||
});
|
||||
|
||||
for (name, data) in attachments {
|
||||
items.push(ImportItem {
|
||||
path: reserve_path(components, &sanitize_component(&name), used)?,
|
||||
contents: SecretBytes::new(data),
|
||||
});
|
||||
}
|
||||
|
||||
let history_len = entry
|
||||
.history
|
||||
.as_ref()
|
||||
.map_or(0, |history| history.get_entries().len());
|
||||
if history_len != 0 {
|
||||
let mut history_components = vec!["History".to_owned()];
|
||||
history_components.extend_from_slice(components);
|
||||
for index in 0..history_len {
|
||||
if let Some(historical) = entry.historical(index) {
|
||||
items.push(ImportItem {
|
||||
path: reserve_path(&history_components, &entry_title(&historical), used)?,
|
||||
contents: render_entry(&historical, &[]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reserve_path(
|
||||
components: &[String],
|
||||
title: &str,
|
||||
used: &mut BTreeSet<PathBuf>,
|
||||
) -> Result<EntryPath, RepositoryError> {
|
||||
let mut path = components.iter().collect::<PathBuf>();
|
||||
let title = if title.is_empty() { "notitle" } else { title };
|
||||
path.push(title);
|
||||
let original = path.clone();
|
||||
let mut suffix = 1_u64;
|
||||
while !used.insert(path.clone()) {
|
||||
path = original.clone();
|
||||
path.set_file_name(format!("{title}-{suffix}"));
|
||||
suffix = suffix.saturating_add(1);
|
||||
}
|
||||
EntryPath::parse(path)
|
||||
}
|
||||
|
||||
fn entry_title(entry: &Entry) -> String {
|
||||
let title = entry.get_title().filter(|title| !title.trim().is_empty());
|
||||
let candidate = title
|
||||
.or_else(|| entry.get_url().filter(|url| !url.trim().is_empty()))
|
||||
.or_else(|| {
|
||||
entry
|
||||
.get_username()
|
||||
.filter(|login| !login.trim().is_empty())
|
||||
})
|
||||
.unwrap_or("notitle");
|
||||
let candidate = if title.is_none() {
|
||||
url::Url::parse(candidate)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| candidate.to_owned())
|
||||
} else {
|
||||
candidate.to_owned()
|
||||
};
|
||||
sanitize_component(&candidate)
|
||||
}
|
||||
|
||||
fn sanitize_component(value: &str) -> String {
|
||||
let cleaned = value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_control() || "<>:\"/\\|?*".contains(character) {
|
||||
'-'
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
match cleaned.trim() {
|
||||
"." | ".." => "-".to_owned(),
|
||||
cleaned => cleaned.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_entry(entry: &Entry, attachment_names: &[&str]) -> SecretBytes {
|
||||
let mut output = Vec::new();
|
||||
let password = normalize_newlines(entry.get_password().unwrap_or_default());
|
||||
let first_line = password
|
||||
.split_once('\n')
|
||||
.map_or(password.as_str(), |(first, _)| first);
|
||||
output.extend_from_slice(first_line.as_bytes());
|
||||
output.push(b'\n');
|
||||
|
||||
if password.contains('\n') {
|
||||
append_field(&mut output, "password_multiline", &password);
|
||||
}
|
||||
append_field(
|
||||
&mut output,
|
||||
"login",
|
||||
entry.get_username().unwrap_or_default(),
|
||||
);
|
||||
append_field(&mut output, "url", entry.get_url().unwrap_or_default());
|
||||
|
||||
let otp = otp_uri(entry);
|
||||
if let Some(otp) = &otp {
|
||||
output.extend_from_slice(otp.as_bytes());
|
||||
output.push(b'\n');
|
||||
}
|
||||
append_field(
|
||||
&mut output,
|
||||
"comments",
|
||||
entry.get(fields::NOTES).unwrap_or_default(),
|
||||
);
|
||||
if !attachment_names.is_empty() {
|
||||
append_field(&mut output, "attachments", &attachment_names.join(", "));
|
||||
}
|
||||
if !entry.tags.is_empty() {
|
||||
append_field(&mut output, "tags", &entry.tags.join(", "));
|
||||
}
|
||||
if let Some(autotype) = &entry.autotype {
|
||||
append_field(
|
||||
&mut output,
|
||||
"autotype_enabled",
|
||||
if autotype.enabled { "true" } else { "false" },
|
||||
);
|
||||
append_field(
|
||||
&mut output,
|
||||
"autotype_sequence",
|
||||
autotype.default_sequence.as_deref().unwrap_or_default(),
|
||||
);
|
||||
if !autotype.associations.is_empty() {
|
||||
let associations = autotype
|
||||
.associations
|
||||
.iter()
|
||||
.map(|association| format!("{} => {}", association.window, association.sequence))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
append_field(&mut output, "autotype_associations", &associations);
|
||||
}
|
||||
}
|
||||
if let Some(icon) = entry.icon() {
|
||||
let icon = match icon {
|
||||
Icon::BuiltIn(index) => index.to_string(),
|
||||
Icon::Custom(id) => id.to_string(),
|
||||
};
|
||||
append_field(&mut output, "icon", &icon);
|
||||
}
|
||||
|
||||
let mut custom = entry
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|(name, value)| {
|
||||
!fields::KNOWN_FIELDS.contains(&name.as_str())
|
||||
&& !(name.eq_ignore_ascii_case(fields::OTP) && otp.is_some())
|
||||
&& !value.get().is_empty()
|
||||
})
|
||||
.map(|(name, value)| (field_name(name), value.get().as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
custom.sort_by_cached_key(|(name, _)| name.to_lowercase());
|
||||
let mut names = BTreeSet::from([
|
||||
"attachments".to_owned(),
|
||||
"autotype_associations".to_owned(),
|
||||
"autotype_enabled".to_owned(),
|
||||
"autotype_sequence".to_owned(),
|
||||
"comments".to_owned(),
|
||||
"icon".to_owned(),
|
||||
"login".to_owned(),
|
||||
"password_multiline".to_owned(),
|
||||
"tags".to_owned(),
|
||||
"url".to_owned(),
|
||||
]);
|
||||
for (mut name, value) in custom {
|
||||
let original = name.clone();
|
||||
let mut suffix = 2_u64;
|
||||
while !names.insert(name.to_lowercase()) {
|
||||
name = format!("{original}_{suffix}");
|
||||
suffix = suffix.saturating_add(1);
|
||||
}
|
||||
append_field(&mut output, &name, value);
|
||||
}
|
||||
SecretBytes::new(output)
|
||||
}
|
||||
|
||||
fn field_name(value: &str) -> String {
|
||||
let name = value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character == ':' || character.is_whitespace() || character.is_control() {
|
||||
'_'
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
if name.is_empty() {
|
||||
"field".to_owned()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
fn append_field(output: &mut Vec<u8>, name: &str, value: &str) {
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
output.extend_from_slice(name.as_bytes());
|
||||
output.extend_from_slice(b": ");
|
||||
let value = normalize_newlines(value);
|
||||
for byte in value.bytes() {
|
||||
output.push(byte);
|
||||
if byte == b'\n' {
|
||||
output.push(b' ');
|
||||
}
|
||||
}
|
||||
output.push(b'\n');
|
||||
}
|
||||
|
||||
fn normalize_newlines(value: &str) -> String {
|
||||
value.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
|
||||
fn otp_uri(entry: &Entry) -> Option<String> {
|
||||
let raw = entry
|
||||
.get(fields::OTP)
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
if let Some(raw) = raw
|
||||
&& raw.starts_with("otpauth://")
|
||||
&& crate::otp::OtpUri::parse_str(raw).is_ok()
|
||||
{
|
||||
return Some(raw.to_owned());
|
||||
}
|
||||
let secret = raw
|
||||
.filter(|value| !value.starts_with("otpauth://"))
|
||||
.or_else(|| entry.get("TimeOtp-Secret-Base32"))
|
||||
.or_else(|| entry.get("TOTP Seed"))?;
|
||||
let secret = secret
|
||||
.chars()
|
||||
.filter(|character| !character.is_whitespace())
|
||||
.collect::<String>();
|
||||
let title = entry
|
||||
.get_title()
|
||||
.filter(|title| !title.is_empty())
|
||||
.unwrap_or("Imported");
|
||||
let mut uri = url::Url::parse("otpauth://totp/Imported").expect("static OTP URI is valid");
|
||||
uri.set_path(title);
|
||||
{
|
||||
let mut query = uri.query_pairs_mut();
|
||||
query.append_pair("secret", &secret);
|
||||
query.append_pair("issuer", "Imported");
|
||||
query.append_pair(
|
||||
"digits",
|
||||
entry
|
||||
.get("TimeOtp-Length")
|
||||
.or_else(|| (entry.get("TOTP Settings") == Some("30;S")).then_some("6"))
|
||||
.unwrap_or("6"),
|
||||
);
|
||||
query.append_pair("period", entry.get("TimeOtp-Period").unwrap_or("30"));
|
||||
if let Some(algorithm) = entry.get("TimeOtp-Algorithm") {
|
||||
let algorithm = algorithm.replace("HMAC-", "").replace('-', "");
|
||||
query.append_pair("algorithm", &algorithm);
|
||||
}
|
||||
}
|
||||
let uri = uri.to_string();
|
||||
crate::otp::OtpUri::parse_str(&uri).ok().map(|_| uri)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum KdbxImportError {
|
||||
Io {
|
||||
operation: &'static str,
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
PasswordEncoding,
|
||||
Open(keepass::db::DatabaseOpenError),
|
||||
Repository(RepositoryError),
|
||||
Write(WriteError),
|
||||
Git(GitError),
|
||||
}
|
||||
|
||||
impl fmt::Display for KdbxImportError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io {
|
||||
operation, path, ..
|
||||
} => write!(formatter, "cannot {operation}: {}", path.display()),
|
||||
Self::PasswordEncoding => formatter.write_str("KDBX password is not valid UTF-8"),
|
||||
Self::Open(_) => formatter.write_str("cannot decrypt or parse the KDBX database"),
|
||||
Self::Repository(error) => error.fmt(formatter),
|
||||
Self::Write(error) => error.fmt(formatter),
|
||||
Self::Git(error) => error.fmt(formatter),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for KdbxImportError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Io { source, .. } => Some(source),
|
||||
Self::Open(error) => Some(error),
|
||||
Self::Repository(error) => Some(error),
|
||||
Self::Write(error) => Some(error),
|
||||
Self::Git(error) => Some(error),
|
||||
Self::PasswordEncoding => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RepositoryError> for KdbxImportError {
|
||||
fn from(error: RepositoryError) -> Self {
|
||||
Self::Repository(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WriteError> for KdbxImportError {
|
||||
fn from(error: WriteError) -> Self {
|
||||
Self::Write(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GitError> for KdbxImportError {
|
||||
fn from(error: GitError) -> Self {
|
||||
Self::Git(error)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod desktop;
|
||||
pub mod document;
|
||||
pub mod generate;
|
||||
pub mod git;
|
||||
pub mod kdbx;
|
||||
pub mod mutation;
|
||||
pub mod otp;
|
||||
pub mod presentation;
|
||||
|
||||
@@ -8,6 +8,7 @@ use ironstorage::command::{
|
||||
OtpInputSource, OtpRequest, OtpUriPresentation, Presentation, completion_script, help_text,
|
||||
otp_version_text, parse_from, version_text,
|
||||
};
|
||||
use ironstorage::kdbx::{KdbxImportMode, KdbxImportRequest};
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
@@ -360,3 +361,31 @@ fn configuration_option_and_meta_commands_have_stable_contracts() -> TestResult
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kdbx_import_contract_supports_update_and_quick_add_modes() -> TestResult {
|
||||
assert_eq!(
|
||||
request(&["import-kdbx", "vault.kdbx"])?,
|
||||
CommandRequest::ImportKdbx(KdbxImportRequest::new(
|
||||
"vault.kdbx",
|
||||
None,
|
||||
KdbxImportMode::AddAndUpdate,
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
request(&[
|
||||
"import-kdbx",
|
||||
"vault.kdbx",
|
||||
"--key-file",
|
||||
"vault.key",
|
||||
"--quick-add",
|
||||
])?,
|
||||
CommandRequest::ImportKdbx(KdbxImportRequest::new(
|
||||
"vault.kdbx",
|
||||
Some("vault.key".into()),
|
||||
KdbxImportMode::QuickAdd,
|
||||
))
|
||||
);
|
||||
assert!(help_text(Some(HelpTopic::ImportKdbx)).contains("--quick-add"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
246
crates/storage/tests/kdbx_import.rs
Normal file
246
crates/storage/tests/kdbx_import.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::{collections::BTreeMap, fs, io::Cursor};
|
||||
|
||||
use ironstorage::{
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
git::GitIdentity,
|
||||
kdbx::{KdbxImportMode, KdbxImportRequest, KdbxImporter},
|
||||
repository::{Repository, SecretBytes},
|
||||
write::VaultWriter,
|
||||
};
|
||||
use keepass::{
|
||||
Database, DatabaseKey,
|
||||
db::{AutoType, Value, fields},
|
||||
};
|
||||
use support::compatibility::{FixtureSet, TestResult};
|
||||
|
||||
struct FixtureSecrets(BTreeMap<String, Vec<u8>>);
|
||||
|
||||
impl FixtureSecrets {
|
||||
fn all(fixture: &FixtureSet) -> Self {
|
||||
Self(
|
||||
fixture
|
||||
.generated
|
||||
.keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
(
|
||||
key.primary_fingerprint.clone(),
|
||||
key.passphrase.as_bytes().to_vec(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretProvider for FixtureSecrets {
|
||||
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
|
||||
self.0
|
||||
.get(key.fingerprint().as_str())
|
||||
.cloned()
|
||||
.map(SecretBytes::new)
|
||||
.ok_or(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_adds_updates_and_quick_adds_without_deleting() -> 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);
|
||||
let source = store.path().join("source.kdbx");
|
||||
let unrelated = repository.read_entry(&ironstorage::repository::EntryPath::parse(
|
||||
"email/personal",
|
||||
)?)?;
|
||||
save_database(
|
||||
&source,
|
||||
"database password",
|
||||
"first password",
|
||||
"line one\nwww.bahn.de Login:\n\nend",
|
||||
b"first attachment",
|
||||
None,
|
||||
)?;
|
||||
let request = KdbxImportRequest::new(&source, None, KdbxImportMode::AddAndUpdate);
|
||||
let importer = KdbxImporter::new(&repository, &keys);
|
||||
|
||||
let first = importer.import(
|
||||
&request,
|
||||
SecretBytes::new(b"database password".to_vec()),
|
||||
&mut secrets,
|
||||
GitIdentity::ironstorage(),
|
||||
)?;
|
||||
|
||||
assert_eq!((first.added(), first.updated()), (2, 0));
|
||||
assert_eq!(
|
||||
plaintext(&repository, &keys, &mut secrets, "Work/Example")?,
|
||||
b"first password\nlogin: alice\nurl: https://example.test\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\ncomments: line one\n www.bahn.de Login:\n \n end\nattachments: manual.txt\nautotype_enabled: true\nautotype_sequence: {USERNAME}{TAB}{PASSWORD}{ENTER}\nCustom_Label: alpha\n beta: still one field\n"
|
||||
);
|
||||
assert_eq!(
|
||||
plaintext(&repository, &keys, &mut secrets, "Work/manual.txt")?,
|
||||
b"first attachment"
|
||||
);
|
||||
|
||||
let second = importer.import(
|
||||
&request,
|
||||
SecretBytes::new(b"database password".to_vec()),
|
||||
&mut secrets,
|
||||
GitIdentity::ironstorage(),
|
||||
)?;
|
||||
assert_eq!(
|
||||
(second.unchanged(), second.added(), second.updated()),
|
||||
(2, 0, 0)
|
||||
);
|
||||
|
||||
save_database(
|
||||
&source,
|
||||
"database password",
|
||||
"updated password",
|
||||
"updated\ncomments",
|
||||
b"updated attachment",
|
||||
None,
|
||||
)?;
|
||||
let updated = importer.import(
|
||||
&request,
|
||||
SecretBytes::new(b"database password".to_vec()),
|
||||
&mut secrets,
|
||||
GitIdentity::ironstorage(),
|
||||
)?;
|
||||
assert_eq!((updated.updated(), updated.added()), (2, 0));
|
||||
assert!(
|
||||
plaintext(&repository, &keys, &mut secrets, "Work/Example")?
|
||||
.starts_with(b"updated password\n")
|
||||
);
|
||||
|
||||
save_database(
|
||||
&source,
|
||||
"database password",
|
||||
"ignored by quick add",
|
||||
"ignored",
|
||||
b"ignored",
|
||||
None,
|
||||
)?;
|
||||
let quick = importer.import(
|
||||
&KdbxImportRequest::new(&source, None, KdbxImportMode::QuickAdd),
|
||||
SecretBytes::new(b"database password".to_vec()),
|
||||
&mut secrets,
|
||||
GitIdentity::ironstorage(),
|
||||
)?;
|
||||
assert_eq!((quick.skipped(), quick.added(), quick.updated()), (2, 0, 0));
|
||||
assert!(
|
||||
plaintext(&repository, &keys, &mut secrets, "Work/Example")?
|
||||
.starts_with(b"updated password\n")
|
||||
);
|
||||
assert_eq!(
|
||||
repository.read_entry(&ironstorage::repository::EntryPath::parse(
|
||||
"email/personal"
|
||||
)?)?,
|
||||
unrelated
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_and_key_file_are_both_required_before_any_store_change() -> 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);
|
||||
let source = store.path().join("protected.kdbx");
|
||||
let key_file = store.path().join("database.key");
|
||||
fs::write(&key_file, b"key file material")?;
|
||||
save_database(
|
||||
&source,
|
||||
"password",
|
||||
"secret",
|
||||
"notes",
|
||||
b"attachment",
|
||||
Some(b"key file material"),
|
||||
)?;
|
||||
let request = KdbxImportRequest::new(&source, Some(key_file), KdbxImportMode::AddAndUpdate);
|
||||
let importer = KdbxImporter::new(&repository, &keys);
|
||||
|
||||
assert!(
|
||||
importer
|
||||
.import(
|
||||
&request,
|
||||
SecretBytes::new(b"wrong".to_vec()),
|
||||
&mut secrets,
|
||||
GitIdentity::ironstorage(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!VaultWriter::new(&repository, &keys).entry_exists("Work/Example")?);
|
||||
|
||||
let outcome = importer.import(
|
||||
&request,
|
||||
SecretBytes::new(b"password".to_vec()),
|
||||
&mut secrets,
|
||||
GitIdentity::ironstorage(),
|
||||
)?;
|
||||
assert_eq!(outcome.added(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_database(
|
||||
path: &std::path::Path,
|
||||
database_password: &str,
|
||||
entry_password: &str,
|
||||
notes: &str,
|
||||
attachment: &[u8],
|
||||
key_file: Option<&[u8]>,
|
||||
) -> TestResult {
|
||||
let mut database = Database::new();
|
||||
{
|
||||
let mut root = database.root_mut();
|
||||
let mut group = root.add_group();
|
||||
group.name = "Work".to_owned();
|
||||
let mut entry = group.add_entry();
|
||||
entry.set_unprotected(fields::TITLE, "Example");
|
||||
entry.set_unprotected(fields::USERNAME, "alice");
|
||||
entry.set_protected(fields::PASSWORD, entry_password);
|
||||
entry.set_unprotected(fields::URL, "https://example.test");
|
||||
entry.set_unprotected(fields::NOTES, notes);
|
||||
entry.set_protected(
|
||||
fields::OTP,
|
||||
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||
);
|
||||
entry.set_unprotected("Custom Label", "alpha\nbeta: still one field");
|
||||
entry.autotype = Some(AutoType {
|
||||
enabled: true,
|
||||
default_sequence: Some("{USERNAME}{TAB}{PASSWORD}{ENTER}".to_owned()),
|
||||
..AutoType::default()
|
||||
});
|
||||
entry.add_attachment("manual.txt", Value::protected(attachment.to_vec()));
|
||||
}
|
||||
let mut key = DatabaseKey::new().with_password(database_password);
|
||||
if let Some(key_file) = key_file {
|
||||
key = key.with_keyfile(&mut Cursor::new(key_file))?;
|
||||
}
|
||||
database.save(&mut fs::File::create(path)?, key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plaintext(
|
||||
repository: &Repository,
|
||||
keys: &KeyStore,
|
||||
secrets: &mut impl SecretProvider,
|
||||
entry: &str,
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
Ok(VaultWriter::new(repository, keys)
|
||||
.begin_edit(
|
||||
&ironstorage::command::EditRequest {
|
||||
entry: entry.to_owned(),
|
||||
},
|
||||
secrets,
|
||||
)?
|
||||
.plaintext()
|
||||
.expose()
|
||||
.to_vec())
|
||||
}
|
||||
Reference in New Issue
Block a user