An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
1#!/usr/bin/env ruby
2#
3# Copyright (c) 2017 joshua stein <jcs@jcs.org>
4#
5# Permission to use, copy, modify, and distribute this software for any
6# purpose with or without fee is hereby granted, provided that the above
7# copyright notice and this permission notice appear in all copies.
8#
9# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16#
17
18#
19# Read a given 1Password Interchange Format (1pif) file, ask for the given
20# user's master password, then lookup the given user in the bitwarden-ruby
21# SQLite database and fetch its key. Each 1Password password entry is
22# encrypted and inserted into the database.
23#
24# No check is done to eliminate duplicates, so this is best used on a fresh
25# bitwarden-ruby installation after creating a new account.
26#
27
28require File.realpath(File.dirname(__FILE__) + "/../lib/rubywarden.rb")
29require "getoptlong"
30require 'uri'
31
32def usage
33 puts "usage: #{$0} -f data.1pif -u user@example.com"
34 exit 1
35end
36
37username = nil
38file = nil
39
40begin
41 GetoptLong.new(
42 [ "--file", "-f", GetoptLong::REQUIRED_ARGUMENT ],
43 [ "--user", "-u", GetoptLong::REQUIRED_ARGUMENT ],
44 ).each do |opt,arg|
45 case opt
46 when "--file"
47 file = arg
48
49 when "--user"
50 username = arg
51 end
52 end
53
54rescue GetoptLong::InvalidOption
55 usage
56end
57
58if !file || !username
59 usage
60end
61
62@u = User.find_by_email(username)
63if !@u
64 raise "can't find existing User record for #{username.inspect}"
65end
66
67print "master password for #{@u.email}: "
68system("stty -echo") if STDIN.tty?
69password = STDIN.gets.chomp
70system("stty echo") if STDIN.tty?
71print "\n"
72
73unless @u.has_password_hash?(Bitwarden.hashPassword(password, @u.email,
74Bitwarden::KDF::TYPES[@u.kdf_type], @u.kdf_iterations))
75 raise "master password does not match stored hash"
76end
77
78@master_key = Bitwarden.makeKey(password, @u.email,
79 Bitwarden::KDF::TYPES[@u.kdf_type], @u.kdf_iterations)
80
81def encrypt(str)
82 @u.encrypt_data_with_master_password_key(str, @master_key).to_s
83end
84
85to_save = {}
86skipped = 0
87
88def save_field(cdata, field)
89 field['v'] = field['v'].to_s if field['v']
90 return if field['value'].blank? && field['v'].blank?
91
92 field['t'] = 'unnamed field' if field['t'].blank?
93
94 case field['designation']
95 when 'username'
96 cdata["Username"] = encrypt(field['value'])
97 return
98 when 'password'
99 @current_password = field['value']
100 cdata['Password'] = encrypt(field['value'])
101 return
102 end
103
104 case field['k']
105 when 'string'
106 cdata['Fields'].push("Type" => 0,
107 "Name" => encrypt(field['t']),
108 "Value" => encrypt(field['v']))
109 when 'concealed'
110 if field['n'] =~ /^TOTP/
111 totp_secret = if field['v'] =~ %r{^otpauth://}
112 URI.decode_www_form(URI.parse(field['v']).query).assoc('secret').last
113 else
114 field['v']
115 end
116
117 cdata['Totp'] = encrypt(totp_secret)
118 else # some other password
119 return if field['v'] == @current_password
120 cdata['Fields'].push("Type" => 1,
121 "Name" => encrypt(field['t']),
122 "Value" => encrypt(field['v']))
123 end
124 end
125end
126
127File.read(file).split("\n").each do |line|
128 next if line[0] != "{"
129 i = JSON.parse(line)
130
131 c = Cipher.new
132 c.user_uuid = @u.uuid
133 c.type = Cipher::TYPE_LOGIN
134 c.favorite = !!(i["openContents"] && i["openContents"]["faveIndex"])
135
136 cdata = {
137 "Name" => encrypt(i["title"].blank? ? "--" : i["title"]),
138 }
139
140 if i["createdAt"]
141 c.created_at = Time.at(i["createdAt"].to_i)
142 end
143 if i["updatedAt"]
144 c.updated_at = Time.at(i["updatedAt"].to_i)
145 end
146
147 case i["typeName"]
148 when "passwords.Password"
149 if i["location"].present?
150 cdata["Uri"] = encrypt(i["location"])
151 end
152
153 when "securenotes.SecureNote"
154 c.type = Cipher::TYPE_NOTE
155 cdata["SecureNote"] = { "Type" => 0 }
156
157 when "wallet.computer.Router"
158 next if i["secureContents"]["wireless_password"].nil?
159 cdata["Password"] = encrypt(i["secureContents"]["wireless_password"])
160
161 when "wallet.financial.CreditCard"
162 c.type = Cipher::TYPE_CARD
163
164 if i["secureContents"]["cardholder"].present?
165 cdata["CardholderName"] = encrypt(i["secureContents"]["cardholder"])
166 end
167 if i["secureContents"]["type"].present?
168 cdata["Brand"] = encrypt(i["secureContents"]["type"])
169 end
170 if i["secureContents"]["ccnum"].present?
171 cdata["Number"] = encrypt(i["secureContents"]["ccnum"])
172 end
173 if i["secureContents"]["expiry_mm"].present?
174 cdata["ExpMonth"] = encrypt(i["secureContents"]["expiry_mm"])
175 end
176 if i["secureContents"]["expiry_yy"].present?
177 cdata["ExpYear"] = encrypt(i["secureContents"]["expiry_yy"])
178 end
179 if i["secureContents"]["cvv"].present?
180 cdata["Code"] = encrypt(i["secureContents"]["cvv"])
181 end
182
183 when "webforms.WebForm"
184 if i["location"].present?
185 cdata["Uri"] = encrypt(i["location"])
186 end
187
188 when "identities.Identity",
189 "system.folder.Regular",
190 "system.folder.SavedSearch",
191 "wallet.computer.License",
192 "wallet.computer.UnixServer",
193 "wallet.computer.License",
194 "wallet.government.SsnUS",
195 "wallet.government.Passport",
196 "wallet.financial.BankAccountUS",
197 "wallet.government.DriversLicense",
198 "wallet.membership.Membership",
199 "wallet.onlineservices.Email.v2",
200 "wallet.computer.Database"
201 puts "skipping #{i["typeName"]} #{i["title"]}"
202 skipped += 1
203 next
204
205
206 else
207 raise "unimplemented: #{i["typeName"].inspect}"
208 end
209
210 puts "converting #{Cipher.type_s(c.type)} #{i["title"]}... "
211
212 if i["secureContents"]
213 @current_password = nil
214 if i["secureContents"]["notesPlain"].present?
215 cdata["Notes"] = encrypt(i["secureContents"]["notesPlain"])
216 end
217
218 if i["secureContents"]["password"].present?
219 @current_password = cdata["Password"] = encrypt(i["secureContents"]["password"])
220 end
221
222 cdata["Fields"] = []
223 (i["secureContents"]["fields"] || []).each do |field|
224 save_field(cdata, field)
225 end
226
227 (i['secureContents']['sections'] || []).map { |x| x['fields'] }.compact.flatten.each do |field|
228 save_field(cdata, field)
229 end
230 end
231
232 c.data = cdata.to_json
233
234 to_save[c.type] ||= []
235 to_save[c.type].push c
236end
237
238puts ""
239
240to_save.each do |k,v|
241 puts "#{sprintf("% 4d", v.count)} #{Cipher.type_s(k)}" <<
242 (v.count == 1 ? "" : "s")
243end
244
245if skipped > 0
246 puts "#{sprintf("% 4d", skipped)} skipped"
247end
248
249print "ready to import? [Y/n] "
250if STDIN.gets.to_s.match(/n/i)
251 exit 1
252end
253
254imp = 0
255Cipher.transaction do
256 to_save.each do |_, v|
257 v.each do |c|
258 # TODO: convert data to each field natively and call save! on our own
259 c.migrate_data!
260
261 imp += 1
262 end
263 end
264end
265
266puts "successfully imported #{imp} item#{imp == 1 ? "" : "s"}"