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