Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
nilsding committed May 3, 2020
0 parents commit cf9f546
Show file tree
Hide file tree
Showing 5 changed files with 381 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/docs/
/lib/
/bin/
/.shards/
*.dwarf
*.xml
*.kate-swp
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2020 Georg Gadinger

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# enpass2keepassxc

A quick and dirty Crystal script to convert an Enpass JSON export to a KeePassXC
XML file, which can be then imported via `keepassxc-cli`.

## Features

This script can migrate the following:

- Folders to Groups
- TOTP fields
- Any additional non-empty fields
- It even migrates duplicate field names!
- Sensitivity values of fields
- Adds icons for some entries
- Attempts to guess the created and updated at values of an entry

And it does not use CSV anywhere!

## Installation

You only need Crystal 0.34.0 or newer.

## Usage

First of all, export your existing Enpass database to JSON. This can be done via
_Menu_ > _File_ > _Export_ and selecting `.json` as the file format.

Then convert your exported Enpass database to XML by running this script:

```sh
crystal run enpass2keepassxc.cr -- ~/path/to/exported_passwords.json > ./imported_from_enpass.xml
```

Finally, create a new KeePass database using KeePassXC's CLI:

```
keepassxc-cli import ./imported_from_enpass.xml ~/Documents/MyPasswords.kdbx
```

You're done!

## Contributing

1. Fork it (<https://github.com/nilsding/enpass2keepassxc/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request

## Contributors

- [Georg Gadinger](https://github.com/nilsding) - creator and maintainer
291 changes: 291 additions & 0 deletions enpass2keepassxc.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
require "base64"
require "http/params"
require "json"
require "uri"
require "xml"

module Enpass
class Export
JSON.mapping(
folders: Array(Folder),
items: Array(Item)
)

class Folder
JSON.mapping(
uuid: String,
parent_uuid: String,
title: String,
icon: String,
updated_at: Int64
)
end

class Item
JSON.mapping(
uuid: String,
title: String,
auto_submit: Int32,
category: String,
favorite: Int32,
folders: Array(String)?,
fields: Array(Item::Field)?,
note: String,
subtitle: String,
template_type: String,
updated_at: Int64
)

class Field
JSON.mapping(
label: String,
type: String,
history: Array(Field::History)?,
order: Int32,
sensitive: Int32,
uid: Int32,
updated_at: Int64,
value: String,
value_updated_at: Int64
)

class History
JSON.mapping(
updated_at: Int64,
value: String,
)
end
end
end
end
end

def uuid_str_to_b64(uuid : String)
raise "not a uuid" unless uuid.size == 36

bytes = uuid.gsub("-", "").scan(/../).map(&.[](0).to_u8(16))
io = IO::Memory.new(bytes.size)
bytes.each { |b| io.write_byte b }
Base64.encode(io).strip
end

def unix_to_iso8601(time)
Time::Format::ISO_8601_DATE_TIME.format(Time.unix(time))
end

def icon_for_category(category)
{
"license" => "17",
"note" => "44",
"creditcard" => "66",
"finance" => "66",
}.fetch(category, "0")
end

def build_entry(xml, item)
# creation time seems to be just the oldest updated_at value of any field
created_at = item.updated_at
if item.fields
created_at = item.fields.not_nil!.map(&.updated_at).min
end

xml.element("Entry") do
xml.element("UUID") { xml.text(uuid_str_to_b64(item.uuid)) }

xml.element("IconID") { xml.text(icon_for_category(item.category)) }
%w[ForegroundColor BackgroundColor OverrideURL Tags].each do |t|
xml.element(t)
end

xml.element("Times") do
xml.element("LastModificationTime") { xml.text(unix_to_iso8601(item.updated_at)) }
xml.element("CreationTime") { xml.text(unix_to_iso8601(created_at)) }
xml.element("LastAccessTime") { xml.text(unix_to_iso8601(item.updated_at)) }
xml.element("ExpiryTime") { xml.text(unix_to_iso8601(Int32::MAX)) }
xml.element("Expires") { xml.text("False") }
xml.element("UsageCount") { xml.text("0") }
xml.element("LocationChanged") { xml.text(unix_to_iso8601(item.updated_at)) }
end

xml.element("String") do
xml.element("Key") { xml.text("Title") }
xml.element("Value") { xml.text(item.title) }
end

xml.element("String") do
xml.element("Key") { xml.text("Notes") }
xml.element("Value") { xml.text(item.note) unless item.note.empty? }
end

username : String? = nil
email : String? = nil

custom_attributes_count = {} of String => Int32

if item.fields
item.fields.not_nil!.each do |field|
key = field.label
value_args = {} of String => String
value_args["ProtectInMemory"] = "True" unless field.sensitive.zero?

case field.type
when "username"
username = field.value unless field.value.empty?
next # handled below
when "email"
email = field.value unless field.value.empty?
next # handled below
when "password"
key = "Password"
when "url"
key = "URL"
when "totp"
next # handled below
else
# don't care if we don't have a value for non-mandatory fields
next if field.value.empty?
end

custom_attributes_count[key] ||= 0
custom_attributes_count[key] += 1
if custom_attributes_count[key] > 1
key = "#{key} (#{custom_attributes_count[key]})"
end

xml.element("String") do
xml.element("Key") { xml.text(key) }
xml.element("Value", value_args) { xml.text(field.value) unless field.value.empty? }
end
end

# set the UserName to the email if the username is not set
if !email.nil? && !email.not_nil!.empty?
key = "UserName"

if !username.nil? && !username.not_nil!.empty?
xml.element("String") do
xml.element("Key") { xml.text("UserName") }
xml.element("Value") { xml.text(username) }
end

key = "E-Mail"
end

xml.element("String") do
xml.element("Key") { xml.text(key) }
xml.element("Value") { xml.text(email) }
end
else
xml.element("String") do
xml.element("Key") { xml.text("UserName") }
xml.element("Value") { xml.text(username) if !username.nil? && !username.not_nil!.empty? }
end
end

totp_fields = item.fields.not_nil!.select { |f| f.type == "totp" && !f.value.empty? }
unless totp_fields.size.zero?
# get the most recent TOTP value
totp_value = totp_fields.sort_by(&.value_updated_at).last.value
# normalise it
totp_value = totp_value.gsub(" ", "").upcase

totp_params = HTTP::Params.encode({"secret" => totp_value,
"period" => "30",
"digits" => "6",
"issuer" => item.title})
totp_uri = URI.new(
scheme: "otpauth",
host: "totp",
path: URI.encode([item.title, "someone"].join(":")), # here's hoping keepassxc doesn't care about the username in here
query: totp_params
).to_s

xml.element("String") do
xml.element("Key") { xml.text("otp") }
xml.element("Value", {"ProtectInMemory" => "True"}) { xml.text(totp_uri) }
end
end
end

xml.element("AutoType") do
xml.element("Enabled") { xml.text("True") }
xml.element("DataTransferObfuscation") { xml.text("0") }
xml.element("DefaultSequence")
end

xml.element("History")
end
end

def convert_enpass_export(enpass)
entries_by_group = enpass.items.group_by(&.folders)

root_uuid = "5ca246df-de73-47fe-b4a9-28ae68817f78"
watch_uuid = "d275de30-d63b-4a07-a3ca-1be78047ba14"

XML.build(indent: " ") do |xml|
xml.element("KeePassFile") do
xml.element("Meta") do
xml.element("Generator") { xml.text("Enpass2KeepassXC") }
xml.element("MemoryProtection") do
xml.element("ProtectTitle") { xml.text("False") }
xml.element("ProtectUserName") { xml.text("False") }
xml.element("ProtectPassword") { xml.text("True") }
xml.element("ProtectURL") { xml.text("False") }
xml.element("ProtectNotes") { xml.text("False") }
end
end

xml.element("Root") do
xml.element("Group") do
xml.element("UUID") { xml.text(uuid_str_to_b64(root_uuid)) }
xml.element("Name") { xml.text("Root") }
xml.element("Notes")
xml.element("IconID") { xml.text("48") }
xml.element("IsExpanded") { xml.text("True") }

entries_by_group[nil].each do |item|
build_entry(xml, item)
end

entries_by_group.each do |group, items|
next unless group
folder_uuid = group.first

folder_metadata = enpass.folders.find { |f| f.uuid == folder_uuid }

next unless folder_metadata
folder_metadata = folder_metadata.not_nil!

folder_uuid = watch_uuid if folder_uuid == "watch-folder-uuid"

xml.element("Group") do
xml.element("UUID") { xml.text(uuid_str_to_b64(folder_uuid)) }
xml.element("Name") { xml.text(folder_metadata.title) }
xml.element("Notes")
xml.element("IconID") { xml.text("48") }
xml.element("IsExpanded") { xml.text("True") }

items.each do |item|
build_entry(xml, item)
end
end
end
end
end
end
end
end

def main(argv)
abort "usage: enpass2keepassxc JSON_EXPORT" if argv.size != 1

enpassfile = argv.shift
enpass_export = Enpass::Export.from_json(File.read(enpassfile))

document = convert_enpass_export(enpass_export)

puts document
end

main ARGV.dup

0 comments on commit cf9f546

Please sign in to comment.