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