Skip to content

Instantly share code, notes, and snippets.

@hikerpig
Last active August 15, 2018 02:53
Show Gist options
  • Save hikerpig/45969385fcc792369a6bf3704f1d9be7 to your computer and use it in GitHub Desktop.
Save hikerpig/45969385fcc792369a6bf3704f1d9be7 to your computer and use it in GitHub Desktop.
lastpass2keepass
#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
# thanks https://github.com/lifepillar/csv2keepassxml/blob/master/csv2keepassxml
# @example `csv2keepassxml -U 1 -u 2 -p 3 -n 4 -t 5 -g 6 lp.csv`
# Convert a CSV file into a KeePass 2 XML file.
require 'csv'
require 'date'
require 'htmlentities'
require 'optparse'
require 'securerandom'
VERSION = '0.0.2'
def base64_uuid
SecureRandom.base64(nil)
end
def password_item(record, coder)
item = {}
item['Title'] = coder.encode(record[TITLE]) if TITLE > -1
item['UserName'] = coder.encode(record[USERNAME]) if USERNAME > -1
item['URL'] = coder.encode(record[URL]) if URL > -1
if PASSWORD > -1
pwd = coder.encode(record[PASSWORD])
if pwd.length > 65536
title = (TITLE > - 1) ? "'" + record[TITLE] + "'" : "with no title"
puts "Warning: item #{title} has a password longer than 65536 characters."
puts "Warning: passwords longer than 65536 characters are stored as notes."
item['Notes'] = pwd
else
item['Password'] = pwd
item['Notes'] = coder.encode(record[NOTES]) if NOTES > -1
end
end
# Tags
taglist = []
TAGS.each do |i|
if not (record[i].nil? or record[i].empty?)
taglist += record[i].split(/#{TAGSEP}/)
end
end
item['__TAGS__'] = taglist.join(';')
# Custom fields
CUSTOM.each_pair do |i,name|
item[name] = coder.encode(record[i])
end
return item
end
def xml_entry(record, timestamp, options = {})
s = "<Entry>"
s += "<UUID>#{options.fetch(:uuid, base64_uuid())}</UUID>"
s += "<IconID>0</IconID>"
s += "<ForegroundColor/>"
s += "<BackgroundColor/>"
s += "<OverrideURL/>"
if (record['__TAGS__'].nil? or record['__TAGS__'].empty?)
s += "<Tags />"
else
s += "<Tags>#{record['__TAGS__']}</Tags>"
end
s += "<Times>"
s += "<LastModificationTime>#{timestamp}</LastModificationTime>"
s += "<CreationTime>#{timestamp}</CreationTime>"
s += "<LastAccessTime>#{timestamp}</LastAccessTime>"
s += "<ExpiryTime>4001-01-01T00:00:00Z</ExpiryTime>"
s += "<Expires>False</Expires>"
s += "<UsageCount>0</UsageCount>"
s += "<LocationChanged>#{timestamp}</LocationChanged>"
s += "</Times>"
record.each do |k,v|
next if k == '__TAGS__'
next if v.nil? or v.empty?
s += "<String>"
s += "<Key>#{k}</Key>"
if k == 'Password'
s += "<Value ProtectInMemory=\"True\">#{v}</Value>"
else
s += "<Value>#{v}</Value>"
end
s += "</String>"
end
s += "<AutoType>"
s += "<Enabled>True</Enabled>"
s += "<DataTransferObfuscation>0</DataTransferObfuscation>"
s += "</AutoType>"
s += "<History/>"
s += '</Entry>'
return s
end
# Main
options = {}
OptionParser.new do |opts|
opts.banner = <<-eos
Usage: csv2keepassxml [options] <path>
NOTE: Column indexes are 1-based.
eos
opts.on("-g", "--group NUM", Integer, "Column index for categories") do |o|
options[:group] = o
end
opts.on("-n", "--notes NUM", Integer, "Column index for notes") do |o|
options[:notes] = o
end
opts.on("-p", "--password NUM", Integer, "Column index for passwords") do |o|
options[:password] = o
end
opts.on("-t", "--title NUM", Integer, "Column index for titles") do |o|
options[:title] = o
end
opts.on("-u", "--username NUM", Integer, "Column index for usernames") do |o|
options[:username] = o
end
opts.on("-U", "--url NUM", Integer, "Column index for URLs") do |o|
options[:url] = o
end
opts.on("-T", "--tags NUM,NUM,...", Array, "Column index(es) for tags") do |o|
options[:tags] = o
end
opts.on("--tags-separator SEP", String, "Tag separator (default: ';')") do |o|
options[:tagsep] = o
end
opts.on("-F", "--custom-fields NUM,NUM,...", Array, "Column index(es) for custom fields") do |o|
options[:custom] = o
end
opts.on("-d", "--dbname NAME", "Name of the database") do |o|
options[:dbname] = o
end
opts.on("-H", "--[no-]header", "Parse a CSV with/without header") do |o|
options[:header] = o
end
opts.on("-M", "--macoskeychain", "Parse the output of CSVKeychain") do |o|
options[:macoskeychain] = o
end
opts.on("-h", "--help", "Prints this help") do
puts opts
exit
end
opts.on("-v", "--[no-]verbose", "Be verbose") do |o|
options[:verbose] = o
end
end.parse!
input_file = ARGV.first
if input_file.nil?
puts "Please specify the path of a CSV file."
exit(1)
end
output_file = File.join(File.dirname(input_file), File.basename(input_file, '.csv') + '.xml')
if File.exist?(output_file)
puts "File exists: #{output_file}"
print "Overwrite (y/n)?"
overwrite = $stdin.gets
if overwrite !~ /^[yY]/
puts "Canceled."
exit(0)
end
end
options[:dbname] ||= 'NewDatabase'
URL = options.fetch(:url, options.fetch(:macoskeychain, false) ? 1 : 0) - 1
USERNAME = options.fetch(:username, options.fetch(:macoskeychain, false) ? 2 : 0) - 1
PASSWORD = options.fetch(:password, options.fetch(:macoskeychain, false) ? 3 : 0) - 1
TITLE = options.fetch(:title, options.fetch(:macoskeychain, false) ? 4 : 0) - 1
NOTES = options.fetch(:notes, options.fetch(:macoskeychain, false) ? 5 : 0) - 1
GROUP = options.fetch(:group, options.fetch(:macoskeychain, false) ? 14 : 0) - 1
TAGS = options.fetch(:tags, []).map { |i| i.to_i - 1 }
TAGSEP = options.fetch(:tagsep, ';')
CUSTOM = {}
k = 1
options.fetch(:custom, []).each do |i|
CUSTOM[i.to_i - 1] = "CustomField#{k}"
k += 1
end
HAS_HEADER = options.fetch(:header, true)
coder = HTMLEntities.new
items = { 'General' => [] }
n = 0
CSV.foreach(input_file, :headers => HAS_HEADER, :return_headers => true) do |row|
n += 1
if HAS_HEADER && n == 1 # Get the names of custom fields from the header
options.fetch(:custom, []).each do |i|
puts "INFO: Custom field at column #{i}: #{row[i.to_i - 1]}" if options[:verbose]
CUSTOM[i.to_i - 1] = row[i.to_i - 1]
end
next
end
if options.has_key?(:group) or options.has_key?(:macoskeychain)
row[GROUP] = 'General' if row[GROUP].nil? or row[GROUP].empty?
items[row[GROUP]] ||= []
items[row[GROUP]] << password_item(row, coder)
next
end
# No group info
items['General'] << password_item(row, coder)
end
puts "INFO: Parsed #{n} rows." if options[:verbose]
num_entries = 0
timestamp = DateTime.now.strftime("%Y-%m-%dT%H:%M:%SZ")
general_uuid = base64_uuid()
last_top_visible_entry_uuid = base64_uuid()
File.open(output_file, 'w') do |f|
f.puts '<?xml version="1.0" encoding="UTF-8"?>'
f.puts "<KeePassFile>"
f.puts " <Meta>"
f.puts " <Generator>csv2keepassxml</Generator>"
f.puts " <DatabaseName>#{options[:dbname]}</DatabaseName>"
f.puts " <DatabaseNameChanged>#{timestamp}</DatabaseNameChanged>"
f.puts " <DatabaseDescription/>"
f.puts " <DatabaseDescriptionChanged>#{timestamp}</DatabaseDescriptionChanged>"
f.puts " <DefaultUserName/>"
f.puts " <DefaultUserNameChanged>#{timestamp}</DefaultUserNameChanged>"
f.puts " <MaintenanceHistoryDays>365</MaintenanceHistoryDays>"
f.puts " <Color/>"
f.puts " <MasterKeyChanged/>"
f.puts " <MasterKeyChangeRec>-1</MasterKeyChangeRec>"
f.puts " <MasterKeyChangeForce>-1</MasterKeyChangeForce>"
f.puts " <MemoryProtection>"
f.puts " <ProtectTitle>False</ProtectTitle>"
f.puts " <ProtectUserName>False</ProtectUserName>"
f.puts " <ProtectPassword>True</ProtectPassword>"
f.puts " <ProtectURL>False</ProtectURL>"
f.puts " <ProtectNotes>False</ProtectNotes>"
f.puts " </MemoryProtection>"
f.puts " <RecycleBinEnabled>False</RecycleBinEnabled>"
f.puts " <RecycleBinUUID>AAAAAAAAAAAAAAAAAAAAAA==</RecycleBinUUID>"
f.puts " <RecycleBinChanged>#{timestamp}</RecycleBinChanged>"
f.puts " <EntryTemplatesGroup>AAAAAAAAAAAAAAAAAAAAAA==</EntryTemplatesGroup>"
f.puts " <EntryTemplatesGroupChanged>#{timestamp}</EntryTemplatesGroupChanged>"
f.puts " <HistoryMaxItems>10</HistoryMaxItems>"
f.puts " <HistoryMaxSize>6291456</HistoryMaxSize>"
f.puts " <LastSelectedGroup>AAAAAAAAAAAAAAAAAAAAAA==</LastSelectedGroup>"
f.puts " <LastTopVisibleGroup>AAAAAAAAAAAAAAAAAAAAAA==</LastTopVisibleGroup>"
f.puts " <Binaries/>"
f.puts " <CustomData/>"
f.puts " </Meta>"
f.puts " <Root>"
f.puts " <Group>"
f.puts " <UUID>#{general_uuid}</UUID>"
f.puts " <Name>General</Name>"
f.puts " <Notes/>"
f.puts " <IconID>48</IconID>"
f.puts " <Times>"
f.puts " <LastModificationTime>#{timestamp}</LastModificationTime>"
f.puts " <CreationTime>#{timestamp}</CreationTime>"
f.puts " <LastAccessTime>#{timestamp}</LastAccessTime>"
f.puts " <ExpiryTime>4001-01-01T00:00:00Z</ExpiryTime>"
f.puts " <Expires>False</Expires>"
f.puts " <UsageCount>0</UsageCount>"
f.puts " <LocationChanged>#{timestamp}</LocationChanged>"
f.puts " </Times>"
f.puts " <IsExpanded>True</IsExpanded>"
f.puts " <DefaultAutoTypeSequence/>"
f.puts " <EnableAutoType>null</EnableAutoType>"
f.puts " <EnableSearching>null</EnableSearching>"
f.puts " <LastTopVisibleEntry>AAAAAAAAAAAAAAAAAAAAAA==</LastTopVisibleEntry>"
is_first = true
items['General'].each do |record|
f.puts xml_entry(record, timestamp)
num_entries += 1
end
items.keys.select { |k| k != 'General' }.each do |group|
f.puts ' <Group>'
f.puts " <UUID>#{base64_uuid()}</UUID>"
f.puts " <Name>#{coder.encode(group)}</Name>"
f.puts " <Notes/>"
f.puts " <IconID>0</IconID>"
f.puts " <Times>"
f.puts " <LastModificationTime>#{timestamp}</LastModificationTime>"
f.puts " <CreationTime>#{timestamp}</CreationTime>"
f.puts " <LastAccessTime>#{timestamp}</LastAccessTime>"
f.puts " <ExpiryTime>4001-01-01T00:00:00Z</ExpiryTime>"
f.puts " <Expires>False</Expires>"
f.puts " <UsageCount>0</UsageCount>"
f.puts " <LocationChanged>#{timestamp}</LocationChanged>"
f.puts " </Times>"
f.puts " <IsExpanded>True</IsExpanded>"
f.puts " <DefaultAutoTypeSequence/>"
f.puts " <EnableAutoType>null</EnableAutoType>"
f.puts " <EnableSearching>null</EnableSearching>"
f.puts " <LastTopVisibleEntry>AAAAAAAAAAAAAAAAAAAAAA==</LastTopVisibleEntry>"
items[group].each do |record|
f.puts xml_entry(record, timestamp)
num_entries += 1
end
f.puts ' </Group>'
end
f.puts '</Group>'
f.puts '<DeletedObjects/>'
f.puts '</Root>'
f.puts '</KeePassFile>'
end
puts 'Done!'
puts "#{num_entries} items converted."
ruby csv2keepassxml -U 1 -u 2 -p 3 -n 4 -t 5 -g 6 lp.csv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment