1password2pass.rb (4759B)
1 #!/usr/bin/env ruby 2 3 # Copyright (C) 2014 Tobias V. Langhoff <tobias@langhoff.no>. All Rights Reserved. 4 # This file is licensed under GPLv2+. Please see COPYING for more information. 5 # 6 # 1Password Importer 7 # 8 # Reads files exported from 1Password and imports them into pass. Supports comma 9 # and tab delimited text files, as well as logins (but not other items) stored 10 # in the 1Password Interchange File (1PIF) format. 11 # 12 # Supports using the title (default) or URL as pass-name, depending on your 13 # preferred organization. Also supports importing metadata, adding them with 14 # `pass insert --multiline`; the username and URL are compatible with 15 # https://github.com/jvenant/passff. 16 17 require "optparse" 18 require "ostruct" 19 20 accepted_formats = [".txt", ".1pif"] 21 22 # Default options 23 options = OpenStruct.new 24 options.force = false 25 options.name = :title 26 options.notes = true 27 options.meta = true 28 29 optparse = OptionParser.new do |opts| 30 opts.banner = "Usage: #{opts.program_name}.rb [options] filename" 31 opts.on_tail("-h", "--help", "Display this screen") { puts opts; exit } 32 opts.on("-f", "--force", "Overwrite existing passwords") do 33 options.force = true 34 end 35 opts.on("-d", "--default [FOLDER]", "Place passwords into FOLDER") do |group| 36 options.group = group 37 end 38 opts.on("-n", "--name [PASS-NAME]", [:title, :url], 39 "Select field to use as pass-name: title (default) or URL") do |name| 40 options.name = name 41 end 42 opts.on("-m", "--[no-]meta", 43 "Import metadata and insert it below the password") do |meta| 44 options.meta = meta 45 end 46 47 begin 48 opts.parse! 49 rescue OptionParser::InvalidOption 50 $stderr.puts optparse 51 exit 52 end 53 end 54 55 # Check for a valid filename 56 filename = ARGV.pop 57 unless filename 58 abort optparse.to_s 59 end 60 unless accepted_formats.include?(File.extname(filename.downcase)) 61 abort "Supported file types: comma/tab delimited .txt files and .1pif files." 62 end 63 64 passwords = [] 65 66 # Parse comma or tab delimited text 67 if File.extname(filename) =~ /.txt/i 68 require "csv" 69 70 # Very simple way to guess the delimiter 71 delimiter = "" 72 File.open(filename) do |file| 73 first_line = file.readline 74 if first_line =~ /,/ 75 delimiter = "," 76 elsif first_line =~ /\t/ 77 delimiter = "\t" 78 else 79 abort "Supported file types: comma/tab delimited .txt files and .1pif files." 80 end 81 end 82 83 # Import CSV/TSV 84 CSV.foreach(filename, {col_sep: delimiter, headers: true, header_converters: :symbol}) do |entry| 85 pass = {} 86 pass[:name] = "#{(options.group + "/") if options.group}#{entry[options.name]}" 87 pass[:title] = entry[:title] 88 pass[:password] = entry[:password] 89 pass[:login] = entry[:username] 90 pass[:url] = entry[:url] 91 pass[:notes] = entry[:notes] 92 passwords << pass 93 end 94 # Parse 1PIF 95 elsif File.extname(filename) =~ /.1pif/i 96 require "json" 97 98 options.name = :location if options.name == :url 99 100 # 1PIF is almost JSON, but not quite. Remove the ***...*** lines 101 # separating records, and then remove the trailing comma 102 pif = File.open(filename).read.gsub(/^\*\*\*.*\*\*\*$/, ",").chomp.chomp(",") 103 104 # Import 1PIF 105 JSON.parse("[#{pif}]", symbolize_names: true).each do |entry| 106 next unless entry[:typeName] == "webforms.WebForm" 107 next if entry[:secureContents][:fields].nil? 108 109 pass = {} 110 111 pass[:name] = "#{(options.group + "/") if options.group}#{entry[options.name]}" 112 113 pass[:title] = entry[:title] 114 115 pass[:password] = entry[:secureContents][:fields].detect do |field| 116 field[:designation] == "password" 117 end[:value] 118 119 username = entry[:secureContents][:fields].detect do |field| 120 field[:designation] == "username" 121 end 122 # might be nil 123 pass[:login] = username[:value] if username 124 125 pass[:url] = entry[:location] 126 pass[:notes] = entry[:secureContents][:notesPlain] 127 passwords << pass 128 end 129 end 130 131 puts "Read #{passwords.length} passwords." 132 133 errors = [] 134 # Save the passwords 135 passwords.each do |pass| 136 IO.popen("pass insert #{"-f " if options.force}-m \"#{pass[:name]}\" > /dev/null", "w") do |io| 137 io.puts pass[:password] 138 if options.meta 139 io.puts "login: #{pass[:login]}" unless pass[:login].to_s.empty? 140 io.puts "url: #{pass[:url]}" unless pass[:url].to_s.empty? 141 io.puts pass[:notes] unless pass[:notes].to_s.empty? 142 end 143 end 144 if $? == 0 145 puts "Imported #{pass[:name]}" 146 else 147 $stderr.puts "ERROR: Failed to import #{pass[:name]}" 148 errors << pass 149 end 150 end 151 152 if errors.length > 0 153 $stderr.puts "Failed to import #{errors.map {|e| e[:name]}.join ", "}" 154 $stderr.puts "Check the errors. Make sure these passwords do not already "\ 155 "exist. If you're sure you want to overwrite them with the "\ 156 "new import, try again with --force." 157 end