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# LastPass importer by Simon Cantem
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 LastPass CSV file, ask for the given user's master password,
21# then lookup the given user in the bitwarden-ruby SQLite database and
22# fetch its key. Each LastPass password & secure note entry is encrypted
23# and 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 "csv"
32
33def usage
34 puts "usage: #{$0} -f data.csv -u user@example.com"
35 exit 1
36end
37
38username = nil
39file = nil
40@folders = {}
41
42begin
43 GetoptLong.new(
44 [ "--file", "-f", GetoptLong::REQUIRED_ARGUMENT ],
45 [ "--user", "-u", GetoptLong::REQUIRED_ARGUMENT ],
46 ).each do |opt,arg|
47 case opt
48 when "--file"
49 file = arg
50
51 when "--user"
52 username = arg
53 end
54 end
55
56rescue GetoptLong::InvalidOption
57 usage
58end
59
60if !file || !username
61 usage
62end
63
64@u = User.find_by_email(username)
65if !@u
66 raise "can't find existing User record for #{username.inspect}"
67end
68
69print "master password for #{@u.email}: "
70system("stty -echo") if STDIN.tty?
71password = STDIN.gets.chomp
72system("stty echo") if STDIN.tty?
73print "\n"
74
75unless @u.has_password_hash?(Bitwarden.hashPassword(password, @u.email,
76Bitwarden::KDF::TYPES[@u.kdf_type], @u.kdf_iterations))
77 raise "master password does not match stored hash"
78end
79
80@master_key = Bitwarden.makeKey(password, @u.email,
81 Bitwarden::KDF::TYPES[@u.kdf_type], @u.kdf_iterations)
82
83@u.folders.each do |folder|
84 folder_name = @u.decrypt_data_with_master_password_key(folder.name, @master_key)
85 @folders[folder_name] = folder.uuid
86end
87
88def encrypt(str)
89 @u.encrypt_data_with_master_password_key(str, @master_key).to_s
90end
91
92def get_or_create_folder_uuid(str)
93 if @folders.has_key? str
94 return @folders[str]
95 end
96
97 f = Folder.new
98 f.user_uuid = @u.uuid
99 f.name = encrypt(str).to_s
100
101 Folder.transaction do
102 if !f.save
103 return validation_error("error creating folder")
104 end
105 end
106
107 @folders[str] = f.uuid
108 return f.uuid
109end
110
111to_save = {}
112skipped = 0
113
114CSV.foreach(file, headers: true) do |row|
115 next if row["name"].blank?
116
117 puts "converting #{row["name"]}..."
118
119 c = Cipher.new
120 c.user_uuid = @u.uuid
121 c.type = Cipher::TYPE_LOGIN
122 c.favorite = (row["fav"].to_i == 1)
123
124 cdata = {
125 "Name" => encrypt(row["name"]),
126 }
127
128 if !row["grouping"].blank?
129 c.folder_uuid = get_or_create_folder_uuid(row["grouping"])
130 end
131
132 # http://sn means it's a secure note
133 if row["url"] == "http://sn"
134 c.type = Cipher::TYPE_NOTE
135 cdata["SecureNote"] = { "Type" => 0 }
136 if !row["extra"].blank?
137 cdata["Notes"] = encrypt(row["extra"])
138 end
139
140 else
141 if !row["url"].blank?
142 cdata["Uri"] = encrypt(row["url"])
143 end
144 if !row["password"].blank?
145 cdata["Password"] = encrypt(row["password"])
146 end
147 if !row["username"].blank?
148 cdata["Username"] = encrypt(row["username"])
149 end
150 if !row["extra"].blank?
151 cdata["Notes"] = encrypt(row["extra"])
152 end
153
154 end
155
156 c.data = cdata.to_json
157
158 to_save[c.type] ||= []
159 to_save[c.type].push c
160end
161
162puts ""
163
164to_save.each do |k,v|
165 puts "#{sprintf("% 4d", v.count)} #{Cipher.type_s(k)}" <<
166 (v.count == 1 ? "" : "s")
167end
168
169if skipped > 0
170 puts "#{sprintf("% 4d", skipped)} skipped"
171end
172
173print "ready to import? [Y/n] "
174if STDIN.gets.to_s.match(/n/i)
175 exit 1
176end
177
178imp = 0
179Cipher.transaction do
180 to_save.each do |k,v|
181 v.each do |c|
182 # TODO: convert data to each field natively and call save! on our own
183 c.migrate_data!
184
185 imp += 1
186 end
187 end
188end
189
190puts "successfully imported #{imp} item#{imp == 1 ? "" : "s"}"
191