Last active
August 15, 2018 02:53
-
-
Save hikerpig/45969385fcc792369a6bf3704f1d9be7 to your computer and use it in GitHub Desktop.
lastpass2keepass
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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