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# bitwarden importer by Ed Marshall <esm@logic.net>
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# Given a usernamem a bitwarden CSV export file, and a (prompted) master
21# password, import each entry into the bitwarden-ruby database.
22#
23# No check is done to eliminate duplicates, so this is best used on a fresh
24# bitwarden-ruby installation after creating a new account.
25#
26
27require File.realpath(File.dirname(__FILE__) + '/../lib/rubywarden.rb')
28
29require 'csv'
30require 'getoptlong'
31
32def usage
33 puts "usage: #{$PROGRAM_NAME} -f data.csv -u user@example.com"
34 exit 1
35end
36
37def encrypt(str)
38 @u.encrypt_data_with_master_password_key(str, @master_key).to_s
39end
40
41def get_or_create_folder_uuid(str)
42 return @folders[str] if @folders.key? str
43
44 f = Folder.new
45 f.user_uuid = @u.uuid
46 f.name = encrypt(str).to_s
47
48 Folder.transaction do
49 return validation_error('error creating folder') unless f.save
50 end
51
52 @folders[str] = f.uuid
53 f.uuid
54end
55
56username = nil
57file = nil
58@folders = {}
59
60begin
61 GetoptLong.new(
62 ['--file', '-f', GetoptLong::REQUIRED_ARGUMENT],
63 ['--user', '-u', GetoptLong::REQUIRED_ARGUMENT]
64 ).each do |opt, arg|
65 case opt
66 when '--file'
67 file = arg
68 when '--user'
69 username = arg
70 end
71 end
72rescue GetoptLong::InvalidOption
73 usage
74end
75
76usage unless file && username
77
78@u = User.find_by_email(username)
79raise "can't find existing User record for #{username.inspect}" unless @u
80
81print "master password for #{@u.email}: "
82system('stty -echo') if STDIN.tty?
83password = STDIN.gets.chomp
84system('stty echo') if STDIN.tty?
85puts
86
87unless @u.has_password_hash?(Bitwarden.hashPassword(password, @u.email,
88Bitwarden::KDF::TYPES[@u.kdf_type], @u.kdf_iterations))
89 raise "master password does not match stored hash"
90end
91
92@master_key = Bitwarden.makeKey(password, @u.email,
93 Bitwarden::KDF::TYPES[@u.kdf_type], @u.kdf_iterations)
94
95@u.folders.each do |folder|
96 folder_name = @u.decrypt_data_with_master_password_key(folder.name, @master_key)
97 @folders[folder_name] = folder.uuid
98end
99
100to_save = {}
101skipped = 0
102
103CSV.foreach(file, headers: true) do |row|
104 next if row['name'].blank?
105
106 puts "converting #{row['name']}..."
107
108 c = Cipher.new
109 c.user_uuid = @u.uuid
110
111 if row['folder'].present?
112 c.folder_uuid = get_or_create_folder_uuid(row['folder'])
113 end
114
115 c.favorite = (row['favorite'].to_i == 1)
116 c.type = case row['type']
117 when 'login' then Cipher::TYPE_LOGIN
118 when 'note' then Cipher::TYPE_NOTE
119 when 'card' then
120 # Note: not currently exported by bitwarden
121 Cipher::TYPE_CARD
122 else
123 raise "#{row['name']} has unknown entry type '#{row['favorite']}'"
124 end
125
126 cdata = { 'Name' => encrypt(row['name']) }
127 cdata['Notes'] = encrypt(row['notes']) if row['notes'].present?
128 if row['fields'].present?
129 cdata['Fields'] = []
130 row['fields'].split("\n").each do |field|
131 # This is best-effort: the export format doesn't escape the separator
132 # in the key/value bodies, so field separation is ambiguous. :(
133 # It also doesn't include the field type, so we just default to text.
134 k, v = field.split(': ', 2)
135 cdata['Fields'].push(
136 'Type' => 0, # 0 = text, 1 = hidden, 2 = boolean
137 'Name' => encrypt(k),
138 'Value' => encrypt(v)
139 )
140 end
141 end
142 cdata['Uri'] = encrypt(row['login_uri']) if row['login_uri'].present?
143 cdata['Username'] = encrypt(row['login_username']) if row['login_username'].present?
144 cdata['Password'] = encrypt(row['login_password']) if row['login_password'].present?
145 cdata['Totp'] = encrypt(row['login_totp']) if row['login_totp'].present?
146
147 c.data = cdata.to_json
148
149 to_save[c.type] ||= []
150 to_save[c.type].push c
151end
152
153puts
154
155to_save.each do |k, v|
156 puts "#{format('% 4d', v.count)} #{Cipher.type_s(k)}" <<
157 (v.count == 1 ? '' : 's')
158end
159
160puts "#{format('% 4d', skipped)} skipped" if skipped > 0
161
162print 'ready to import? [Y/n] '
163exit 1 if STDIN.gets =~ /n/i
164
165imp = 0
166Cipher.transaction do
167 to_save.each_value do |v|
168 v.each do |c|
169 # TODO: convert data to each field natively and call save! on our own
170 c.migrate_data!
171
172 imp += 1
173 end
174 end
175end
176
177puts "successfully imported #{imp} item#{imp == 1 ? '' : 's'}"
178
179# EOF