Skip to content
Snippets Groups Projects
1password2pass.rb 4.45 KiB
Newer Older
  • Learn to ignore specific revisions
  • #!/usr/bin/env ruby
    
    # Copyright (C) 2014 Tobias V. Langhoff <tobias@langhoff.no>. All Rights Reserved.
    # This file is licensed under GPLv2+. Please see COPYING for more information.
    # 
    # 1Password Importer
    # 
    # Reads files exported from 1Password and imports them into pass. Supports comma
    # and tab delimited text files, as well as logins (but not other items) stored
    # in the 1Password Interchange File (1PIF) format.
    # 
    # Supports using the title (default) or URL as pass-name, depending on your
    # preferred organization. Also supports importing metadata, adding them with
    # `pass insert --multiline`; the username and URL are compatible with
    # https://github.com/jvenant/passff.
    
    require "optparse"
    require "ostruct"
    
    accepted_formats = [".txt", ".1pif"]
    
    # Default options
    options = OpenStruct.new
    options.force = false
    options.name = :title
    options.notes = true
    options.meta = true
    
    optparse = OptionParser.new do |opts|
      opts.banner = "Usage: #{opts.program_name}.rb [options] filename"
      opts.on_tail("-h", "--help", "Display this screen") { puts opts; exit }
      opts.on("-f", "--force", "Overwrite existing passwords") do
        options.force = true
      end
      opts.on("-d", "--default [FOLDER]", "Place passwords into FOLDER") do |group|
        options.group = group
      end
      opts.on("-n", "--name [PASS-NAME]", [:title, :url],
              "Select field to use as pass-name: title (default) or URL") do |name|
        options.name = name
      end
      opts.on("-m", "--[no-]meta",
              "Import metadata and insert it below the password") do |meta|
        options.meta = meta
      end
    
      begin 
        opts.parse!
      rescue OptionParser::InvalidOption
        $stderr.puts optparse
        exit
      end
    end
    
    # Check for a valid filename
    filename = ARGV.pop
    unless filename
      abort optparse.to_s
    end
    unless accepted_formats.include?(File.extname(filename.downcase))
      abort "Supported file types: comma/tab delimited .txt files and .1pif files."
    end
    
    passwords = []
    
    # Parse comma or tab delimited text
    if File.extname(filename) =~ /.txt/i
      require "csv"
    
      # Very simple way to guess the delimiter
      delimiter = ""
      File.open(filename) do |file|
        first_line = file.readline
        if first_line =~ /,/
          delimiter = ","
        elsif first_line =~ /\t/
          delimiter = "\t"
        else
          abort "Supported file types: comma/tab delimited .txt files and .1pif files."
        end
      end
    
      # Import CSV/TSV
      CSV.foreach(filename, {col_sep: delimiter, headers: true, header_converters: :symbol}) do |entry|
        pass = {}
        pass[:name] = "#{(options.group + "/") if options.group}#{entry[options.name]}"
        pass[:title] = entry[:title]
        pass[:password] = entry[:password]
        pass[:login] = entry[:username]
        pass[:url] = entry[:url]
        pass[:notes] = entry[:notes]
        passwords << pass
      end
    # Parse 1PIF
    elsif File.extname(filename) =~ /.1pif/i
      require "json"
    
      # 1PIF is almost JSON, but not quite
      pif = "[#{File.open(filename).read}]"
      pif.gsub!(/^\*\*\*.*\*\*\*$/, ",")
      pif = JSON.parse(pif, {symbolize_names: true})
    
      options.name = :location if options.name == :url
    
      # Import 1PIF
      pif.each do |entry|
        next unless entry[:typeName] == "webforms.WebForm"
        pass = {}
        pass[:name] = "#{(options.group + "/") if options.group}#{entry[options.name]}"
        pass[:title] = entry[:title]
        pass[:password] = entry[:secureContents][:fields].detect do |field|
          field[:name] == "password"
        end[:value]
        pass[:login] = entry[:secureContents][:fields].detect do |field|
          field[:name] == "username"
        end[:value]
        pass[:url] = entry[:location]
        pass[:notes] = entry[:secureContents][:notesPlain]
        passwords << pass
      end
    end
    
    puts "Read #{passwords.length} passwords."
    
    errors = []
    # Save the passwords
    passwords.each do |pass|
      IO.popen("pass insert #{"-f " if options.force}-m '#{pass[:name]}' > /dev/null", "w") do |io|
        io.puts pass[:password]
        if options.meta
          io.puts "login: #{pass[:login]}" unless pass[:login].to_s.empty?
          io.puts "url: #{pass[:url]}" unless pass[:url].to_s.empty?
          io.puts pass[:notes] unless pass[:notes].to_s.empty?
        end
      end
      if $? == 0
        puts "Imported #{pass[:name]}"
      else
        $stderr.puts "ERROR: Failed to import #{pass[:name]}"
        errors << pass
      end
    end
    
    if errors.length > 0
      $stderr.puts "Failed to import #{errors.map {|e| e[:name]}.join ", "}"
      $stderr.puts "Check the errors. Make sure these passwords do not already "\
                   "exist. If you're sure you want to overwrite them with the "\
                   "new import, try again with --force."
    end