561 lines
17 KiB
Rust
561 lines
17 KiB
Rust
//! 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)
|
|
}
|
|
}
|