An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
at master 191 lines 4.5 kB view raw
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