Rust: Passwords migration from passwordstore to KeePassXC

Car key in a circle clipart.

On Linux machines I keep my passwords in GPG encrypted and Git synchronized format using pass command. Recently I needed to share a large amount of passwords and other secrets. I have exported passwords to CSV and imprted them to KeePassXC database file that is easy to share. To generate the CSV I have written a simple Rust program.

CSV format

KeePassXC expects a CSV file containing a set of columns like: group, title, username, password, notes etc.

With pass one will keep passwords as plain text file in a loose format. But generally the file content will be like that:

  • First line contains the password.
  • Following lines can be key/value paris in format <key>: <value>.
  • Keys like username or login will map to username KeePass value etc.
  • Other lines will be part of notes field.

Note:

There is no way to export binary files via CSV import method.

KeePass also expects a group (directory like path) and a title. For these I will use the directory path and file name of the GPG file containing the password data.

Additionally I will use file creation and modification date for corresponding KeePass metadata.

Denim

The program is written in Rust using denim. You can install it with cargo install denim and then run the script like any executable.

Alternatively you can just crate a cargo project and copy relevant parts of the script to Cargo.toml and main.rs and install it from there.

Running

Note:

Before running this command make sure your GPG key is opened by running pass command and getting a password first.

Warning:

The resulting CSV file will contain plain text passwords. Delete it after importing.

Change directory to your ~/.password-store and run the script like this:

cd ~/.password-store
OUT=`mktemp /tmp/pass.XXXXXXXXXX.csv`
~/scripts/pass_to_csv **/*.gpg > $OUT
echo $OUT
# Once done with the import
rm $OUT

The temporary file can be imported to KeePassXC using Database -> Import -> CSV File... menu action wizard.

Appendix

Here is the code for pass_to_csv script. Remember to make it executable (if using denim) with: chmod +x pass_to_csv.

#!/usr/bin/env -S denim
/* Cargo.toml
[package]
name = "pass_to_csv"
version = "0.1.0"
authors = ["Jakub Pastuszek"]
edition = "2021"

[dependencies]
cotton = "0.1.0"
csv = "1.1"
serde = { version = "1", features = ["derive"] }
*/
use cotton::prelude::*;
use std::time::SystemTime;
use serde::Serialize;
use csv::Writer;

const MAX_NOTES_LEN: usize = 20000;

/// Export passwords from pass command to CSV suitable for KeePassXC import
#[derive(Parser)]
struct Cli {
    #[command(flatten)]
    logging: ArgsLogger,

    /// List of .gpg files to decrypt and export
    #[arg()]
    files: Vec<PathBuf>,
}

#[derive(Debug, Serialize)]
struct Entry<'i> {
    group: String,
    title: String,
    username: Option<&'i str>,
    password: Option<&'i str>,
    url: Option<&'i str>,
    notes: Option<String>,
    icon: Option<u32>,
    totp: Option<String>,
    modified: String,
    created: String,
}

#[derive(Debug, Default)]
struct EntryContent<'i> {
    password: Option<&'i str>,
    username: Option<&'i str>,
    url: Option<&'i str>,
    notes: Option<String>,
}

fn parse_content<'i>(mut lines: impl Iterator<Item = &'i str>) -> EntryContent<'i> {
    let mut entry = EntryContent::default();
    let mut notes = Vec::new();

    entry.password = lines.next();

    for line in lines {
        if let Some((key, value)) = line.splitn(2, ": ").collect_tuple() {
            match key {
                "login" | "username" | "attachments" => entry.username = Some(value),
                "url" => entry.url = Some(value),
                "comments" => notes.push(value),
                "icon" | "autotype_enabled" => warn!("dropping line: {}", line),
                _ => notes.push(line),
            }
        } else {
            notes.push(line);
        }
    }

    entry.notes = if notes.is_empty() {
        None
    } else {
        Some(notes.join("\n"))
    };

    entry
}

fn to_ts(sys: SystemTime) -> PResult<String> {
    let ts = sys.duration_since(SystemTime::UNIX_EPOCH)?;
    let dt = NaiveDateTime::from_timestamp_opt(ts.as_secs().try_into()?, 0)
        .ok_or_problem("making date/time from timestamp")?;
    Ok(DateTime::<Utc>::from_local(dt, Utc).format("%+").to_string())
}

fn main() -> FinalResult {
    let Cli {
        logging,
        files,
    } = Cli::parse();
    setup_logger(logging, vec![module_path!()]);

    let mut out = Writer::from_writer(stdout());

    for file in files {
        info!("Reading {:?}", file);
        let data: Result<StdoutUntrimmed, _> = run_result!(["gpg", "--decrypt", &file.to_str().ok_or_problem("bad path")?]);
        if let Some(StdoutUntrimmed(data)) = data.ok() {
            debug!("{}", data);

            let content = parse_content(data.lines());

            let meta = file.metadata()?;

            let group = file.parent().ok_or_problem("bad parent")?.to_string_lossy().to_string();

            let entry = Entry {
                title: file.file_stem().ok_or_problem("bad stem")?.to_string_lossy().to_string(),
                group: if group.is_empty() { "Root".to_string() } else { group },
                created: to_ts(meta.created()?)?,
                modified: to_ts(meta.modified()?)?,
                password: content.password,
                username: content.username,
                url: content.url,
                notes: content.notes,
                icon: None,
                totp: None,
            };
            debug!("{:#?}", entry);

            if let Some(len) = entry.notes.as_ref().map(|n| n.len()) {
                if len > MAX_NOTES_LEN {
                    error!("Notes are too long: {}! Skipping!", len);
                    continue
                }
            }

            out.serialize(&entry)?;
        } else {
            error!("Error reading {:?} as UTF-8 string", file);
        }
    }

    Ok(())
}

// vim: ft=rust