An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
1#!/usr/bin/env ruby
2#
3# Copyright (c) 2018 joshua stein <jcs@jcs.org>
4# Keepass importer by Martin Gross <martin@pc-coholic.de>
5#
6# Permission to use, copy, modify, and distribute this software for any
7# purpose with or without fee is hereby granted, provided that the above
8# copyright notice and this permission notice appear in all copies.
9#
10# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
11# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
12# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
13# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
14# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
15# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
16# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17#
18
19#
20# Read a given Keepass kdbx file, optional keyfile, ask for the given user's
21# master password, then lookup the given user in the bitwarden-ruby SQLite
22# database and fetch its key. Each Keepass password entry is encrypted and
23# inserted into the database.
24#
25# No check is done to eliminate duplicates, so this is best used on a fresh
26# bitwarden-ruby installation after creating a new account.
27#
28
29require File.realpath(File.dirname(__FILE__) + "/../lib/rubywarden.rb")
30require "getoptlong"
31require "rubeepass"
32
33def usage
34 puts "usage: #{$0} -f example.kdb [-k keyfile] -u user@example.com"
35 exit 1
36end
37
38username = nil
39file = nil
40keyfile = nil
41@folders = {}
42
43begin
44 GetoptLong.new(
45 [ "--file", "-f", GetoptLong::REQUIRED_ARGUMENT ],
46 [ "--keyfile", "-k", GetoptLong::OPTIONAL_ARGUMENT ],
47 [ "--user", "-u", GetoptLong::REQUIRED_ARGUMENT ],
48 ).each do |opt,arg|
49 case opt
50 when "--file"
51 file = arg
52
53 when "--user"
54 username = arg
55
56 when "--keyfile"
57 keyfile = arg
58 end
59 end
60
61rescue GetoptLong::InvalidOption
62 usage
63end
64
65if !file || !username
66 usage
67end
68
69@u = User.find_by_email(username)
70if !@u
71 raise "can't find existing User record for #{username.inspect}"
72end
73
74print "master password for #{@u.email}: "
75system("stty -echo") if STDIN.tty?
76password = STDIN.gets.chomp
77system("stty echo") if STDIN.tty?
78print "\n"
79
80unless @u.has_password_hash?(Bitwarden.hashPassword(password, @u.email,
81Bitwarden::KDF::TYPES[@u.kdf_type], @u.kdf_iterations))
82 raise "master password does not match stored hash"
83end
84
85@master_key = Bitwarden.makeKey(password, @u.email,
86 Bitwarden::KDF::TYPES[@u.kdf_type], @u.kdf_iterations)
87
88@u.folders.each do |folder|
89 folder_name = @u.decrypt_data_with_master_password_key(folder.name, @master_key)
90 @folders[folder_name] = folder.uuid
91end
92
93def encrypt(str)
94 @u.encrypt_data_with_master_password_key(str, @master_key).to_s
95end
96
97def get_or_create_folder_uuid(str)
98 return @folders[str] if @folders.key? str
99
100 f = Folder.new
101 f.user_uuid = @u.uuid
102 f.name = encrypt(str).to_s
103
104 Folder.transaction do
105 return validation_error('error creating folder') unless f.save
106 end
107
108 @folders[str] = f.uuid
109 f.uuid
110end
111
112@to_save = {}
113
114print "master password for #{file}: "
115system("stty -echo")
116keepasspass = STDIN.gets.chomp
117system("stty echo")
118print "\n"
119
120@keepass = RubeePass.new(file, keepasspass, keyfile).open
121@db = @keepass.db
122
123def getEntries(db)
124 if db.entries.any?
125 db.entries.each do |entry|
126 c = Cipher.new
127 c.user_uuid = @u.uuid
128 c.type = Cipher::TYPE_LOGIN
129
130 cdata = {
131 "Name" => encrypt(entry[1].title.blank? ? "--" : entry[1].title),
132 }
133
134 puts "converting #{Cipher.type_s(c.type)} #{entry[1].title}... "
135
136 if entry[1].group.path != "/"
137 c.folder_uuid = get_or_create_folder_uuid(entry[1].group.path[1..-1])
138 end
139
140 cdata['Uri'] = encrypt(entry[1].url) if entry[1].url.present?
141 cdata['Username'] = encrypt(entry[1].username) if entry[1].username.present?
142 cdata['Password'] = encrypt(entry[1].password) if entry[1].password.present?
143 cdata['Notes'] = encrypt(entry[1].notes) if entry[1].notes.present?
144
145 if entry[1].attachments.any?
146 puts "This entry has an attachment - but it won't be converted as rubywarden does not support attachments yet."
147 end
148
149 if entry[1].additional_attributes.any?
150 cdata['Fields'] = []
151 entry[1].additional_attributes.each_pair do |k, v|
152 cdata['Fields'].push(
153 'Type' => 0, # 0 = text, 1 = hidden, 2 = boolean
154 'Name' => encrypt(k),
155 'Value' => encrypt(v)
156 )
157 end
158 end
159
160 c.data = cdata.to_json
161
162 @to_save[c.type] ||= []
163 @to_save[c.type].push c
164
165 next
166 end
167 end
168 if db.groups.any?
169 db.groups.each do |group|
170 getEntries(group[1])
171 next
172 end
173 end
174end
175
176@to_save.each do |k,v|
177 puts "#{sprintf("% 4d", v.count)} #{Cipher.type_s(k)}" <<
178 (v.count == 1 ? "" : "s")
179end
180
181puts ""
182
183getEntries(@db)
184
185print "ready to import? [Y/n] "
186if STDIN.gets.to_s.match(/n/i)
187 exit 1
188end
189
190imp = 0
191Cipher.transaction do
192 @to_save.each do |_, v|
193 v.each do |c|
194 # TODO: convert data to each field natively
195 c.migrate_data!
196
197 imp += 1
198 end
199 end
200end
201
202puts "successfully imported #{imp} item#{imp == 1 ? "" : "s"}"