261 lines
		
	
	
		
			8.3 KiB
		
	
	
	
		
			Plaintext
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			261 lines
		
	
	
		
			8.3 KiB
		
	
	
	
		
			Plaintext
		
	
	
		
			Executable file
		
	
	
	
	
#!/usr/bin/env cached-nix-shell
 | 
						|
#! nix-shell -p ruby_2_7 -i "ruby -S"
 | 
						|
 | 
						|
# A rofi client for bitwarden. Similar to passmenu, but uses rofi. (Badly)
 | 
						|
# written in ruby because data persistence and frontend logic would be a pain to
 | 
						|
# write in bash or zsh. I'll refactor this later perhaps. Maybe.
 | 
						|
 | 
						|
require 'shellwords'
 | 
						|
require 'json'
 | 
						|
require 'optparse'
 | 
						|
require 'uri'
 | 
						|
require 'base64'
 | 
						|
require 'fileutils'
 | 
						|
 | 
						|
PersistentData = Struct.new(:path, :ttl, :pid, :data) do
 | 
						|
  DATADIR = "#{ENV['XDG_DATA_HOME']}/bwmenu"
 | 
						|
 | 
						|
  def self.load name, ttl: nil
 | 
						|
    path = "#{DATADIR}/#{name}"
 | 
						|
    data = JSON::load(Base64.decode64(File.read(path))) if File.exists?(path)
 | 
						|
    d = PersistentData.new *(data || {}).values
 | 
						|
    d.path = path
 | 
						|
    d.ttl  = ttl
 | 
						|
    d
 | 
						|
  end
 | 
						|
 | 
						|
  def expire
 | 
						|
    File.delete(self.path) if File.exists?(self.path)
 | 
						|
  end
 | 
						|
 | 
						|
  def save data = nil, onfinish = nil
 | 
						|
    self.data = data if data
 | 
						|
    FileUtils.mkdir_p DATADIR unless File.directory?(DATADIR)
 | 
						|
    File.open(self.path, 'w') do |f|
 | 
						|
      f.chmod 0600
 | 
						|
      f.write Base64.encode64(self.to_h.to_json)
 | 
						|
    end
 | 
						|
    if self.pid and system("ps -f p #{self.pid}")
 | 
						|
      Process.kill self.pid
 | 
						|
    end
 | 
						|
    if self.ttl
 | 
						|
      self.pid = fork do
 | 
						|
        Signal.trap('TERM') { throw :terminate }
 | 
						|
        catch :terminate do
 | 
						|
          sleep self.ttl
 | 
						|
        end
 | 
						|
        onfinish.call(self, data) if onfinish.respond_to? :call
 | 
						|
        self.expire
 | 
						|
      end
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
module BW
 | 
						|
  extend self
 | 
						|
 | 
						|
  EXE = {}
 | 
						|
  def self.bin exe
 | 
						|
    EXE[exe] = `which #{exe} 2>/dev/null`.rstrip unless EXE[exe]
 | 
						|
    raise "Couldn't find #{exe} in PATH" if EXE[exe].empty?
 | 
						|
    EXE[exe]
 | 
						|
  end
 | 
						|
 | 
						|
  def self.sh *args, input: nil, sudo: false, nooutput: false
 | 
						|
    exe, *args = args.flatten.compact.map(&:to_s)
 | 
						|
    bin exe
 | 
						|
    cmd = [sudo ? "sudo" : nil, exe, *args.shelljoin].compact.join(' ')
 | 
						|
    $stderr.puts "$ #{cmd}"
 | 
						|
    if input
 | 
						|
      IO.popen(cmd, "r+") do |pipe|
 | 
						|
        pipe.puts input
 | 
						|
        pipe.close_write
 | 
						|
        (pipe.gets || "").rstrip unless nooutput
 | 
						|
      end
 | 
						|
    else
 | 
						|
      (`#{cmd}` || "").rstrip
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def self.rofi items, *args
 | 
						|
    input = items.keys.join("\n") if items
 | 
						|
    result = sh(['rofi', '-dmenu', '-markup-rows',
 | 
						|
                 '-theme', 'theme/passmenu.rasi',
 | 
						|
                 *args],
 | 
						|
                input: input)
 | 
						|
    if items
 | 
						|
      items[result]
 | 
						|
    else
 | 
						|
      result
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def self.notify message, title: nil, urgency: "normal"
 | 
						|
    sh 'notify-send', '-u', urgency, '-a', "Bitwarden", *[title, message].compact
 | 
						|
  end
 | 
						|
 | 
						|
  def self.error message
 | 
						|
    $stderr.puts message
 | 
						|
    notify message, urgency: 'low'
 | 
						|
    exit 1
 | 
						|
  end
 | 
						|
 | 
						|
  def self.bw *args
 | 
						|
    sh [:bw, '--session', @@session_id, *args]
 | 
						|
  end
 | 
						|
 | 
						|
  def self.xclip *args, **opts
 | 
						|
    sh :xclip, '-selection', 'clipboard', *args, **opts
 | 
						|
  end
 | 
						|
 | 
						|
  @@session = nil
 | 
						|
  @@session_id = nil
 | 
						|
  def self.run argv
 | 
						|
    opts = {}
 | 
						|
    OptionParser.new do |opt|
 | 
						|
      opt.banner = "Usage: bwmenu [options] [URL]"
 | 
						|
      opt.on       '--autolock TTL',   "How long to keep the vault open"
 | 
						|
      opt.on       '--autoclear TTL'   "How long to keep secrets in the clipboard"
 | 
						|
      opt.on       '--debug',          "Be more verbose"
 | 
						|
      opt.on '-f', '--field FIELD',    "Extract specific field from selected entry; accepts 'whole' for whole entry"
 | 
						|
      opt.on '-o', '--output',         "Write output to stdout rather than the clipboard"
 | 
						|
      opt.on       '--select',         "Select first entry"
 | 
						|
      opt.on       '--select-if-only', "Select first entry if only entry"
 | 
						|
      opt.on       '--default ID',     "Highlight item with ID by default"
 | 
						|
      opt.on '-r',                     "Resume where you last left off"
 | 
						|
    end.parse! argv, into: opts
 | 
						|
 | 
						|
    clipboard_ttl = opts[:autoclear] || 20
 | 
						|
    vault_ttl     = opts[:autolock]  || 900
 | 
						|
 | 
						|
    @@session = PersistentData.load :session, ttl: vault_ttl
 | 
						|
    if @@session.data
 | 
						|
      sid, data = @@session.data
 | 
						|
      @@session_id = sid
 | 
						|
    else
 | 
						|
      pass = rofi nil, %w{-p Master\ Password -password -lines 0}
 | 
						|
      error "No password given. Aborting..." if pass.empty?
 | 
						|
      sid  = sh [:bw, :unlock, '--raw', pass]
 | 
						|
      error "Failed to unlock vault. Aborting..." unless $?.success?
 | 
						|
      @@session_id = sid
 | 
						|
      data = JSON.parse(bw(:list, :items))
 | 
						|
      @@session.save [sid, data]
 | 
						|
    end
 | 
						|
    raise "No session ID" unless @@session_id
 | 
						|
 | 
						|
    last = PersistentData.load :last_id
 | 
						|
    last_id, last_field = last.data if last
 | 
						|
    if opts[:r] and last_id.data
 | 
						|
      selected = data.find { |u| u['id'] == last_id }
 | 
						|
    else
 | 
						|
      select_type = opts[:"select-if-only"] ? :if_only : opts[:select]
 | 
						|
      selected = list_items data, argv.first,
 | 
						|
                            default: opts[:default],
 | 
						|
                            select:  select_type
 | 
						|
    end
 | 
						|
 | 
						|
    begin
 | 
						|
      error "Nothing selected. Aborting..." if selected.empty?
 | 
						|
      last_id = selected['id']
 | 
						|
      item = show_item selected, field: opts[:field]
 | 
						|
      if not item
 | 
						|
        error "Nothing selected. Aborting..." if selected.empty?
 | 
						|
      elsif item.respond_to? :call
 | 
						|
        item.call
 | 
						|
      elsif opts[:output]
 | 
						|
        puts item.rstrip
 | 
						|
      else
 | 
						|
        notify "Copied to clipboard for #{clipboard_ttl} seconds"
 | 
						|
        xclip '-in', input: item.rstrip, nooutput: true
 | 
						|
        clipboard = PersistentData.load :clipboard, ttl: clipboard_ttl
 | 
						|
        clipboard.save(
 | 
						|
          item.rstrip,
 | 
						|
          lambda { |_, data| xclip('-in', '/dev/null') if item.rstrip == xclip('-out') }
 | 
						|
        )
 | 
						|
      end
 | 
						|
    ensure
 | 
						|
      last.save [ last_id, last_field ]
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  #
 | 
						|
  def list_items data, query = nil, default: nil, select: false
 | 
						|
    rofiData = {}
 | 
						|
    rofiData["Sync Vault"]            = lambda { notify "Sync vault", bw(:sync) },
 | 
						|
    rofiData["Open Vault in Browser"] = lambda { sh ENV['BROWSER'], bw(:config, :server) }
 | 
						|
    data = data.map do |i|
 | 
						|
      default_id = i['id'] if i['id'] == default
 | 
						|
      icon = ["", "", "", ""][i['type'] - 1]
 | 
						|
      case i['type']
 | 
						|
      when 1  # logins
 | 
						|
        urls = (i['login']['uris'] || []).filter_map do |u|
 | 
						|
          return unless u['uri']
 | 
						|
          uri = URI(u['uri'])
 | 
						|
          not i['name'].include?((uri.hostname || uri.path).downcase) and
 | 
						|
            uri.hostname || uri.path
 | 
						|
        end
 | 
						|
        subtitle = urls.join ", " if i['type'] == 1
 | 
						|
        subtitle = " <span size=\"x-small\">(#{subtitle})</span>" unless urls.empty?
 | 
						|
      when 2  # secure notes
 | 
						|
      when 3  # cards
 | 
						|
        subtitle = " <span size=\"x-small\">(#{i['card']['brand'].downcase})</span>"
 | 
						|
      when 4  # identities
 | 
						|
      end
 | 
						|
      ["<span font_desc=\"Fira Code\" font_weight=\"bold\">#{icon} </span> <b>#{i['name']}</b>#{subtitle}", i]
 | 
						|
    end
 | 
						|
    case select
 | 
						|
    when :if_only
 | 
						|
      return data[0][1] if data.length == 1
 | 
						|
    when true
 | 
						|
      return data[0][1]
 | 
						|
    end
 | 
						|
    rofi rofiData.merge(data.to_h), '-p', 'Bitwarden', '-filter', (query || "")
 | 
						|
  end
 | 
						|
 | 
						|
  def show_item item, field: nil
 | 
						|
    return item.to_json if field == 'whole'
 | 
						|
    preamble = {}
 | 
						|
    data = {}
 | 
						|
    case item['type']
 | 
						|
    when 1  # logins
 | 
						|
      url = item['login']['uris'][0]['uri']
 | 
						|
      preamble[" Launch #{url}"] = lambda { sh ENV['BROWSER'], url }
 | 
						|
      data['Username'] = item['login']['username']
 | 
						|
      data['Password'] = item['login']['password']
 | 
						|
      data['TOTP'] = bw :get, :totp, url if item['login']['totp']
 | 
						|
      return (data[field.capitalize] || item[field]) if field
 | 
						|
    when 2  # secure notes
 | 
						|
      data['Notes'] = item['notes']
 | 
						|
      return item[field] if field
 | 
						|
    when 3  # cards
 | 
						|
      cardInfo = item['card']
 | 
						|
      data['Card Type']     = cardInfo['brand']
 | 
						|
      data['Card Name']     = cardInfo['cardholderName']
 | 
						|
      data['Card Number']   = cardInfo['number']
 | 
						|
      data['Expiry Date']   = "#{cardInfo['expMonth']}/#{cardInfo['expYear']}"
 | 
						|
      data['Security Code'] = cardInfo['code']
 | 
						|
      return cardInfo[field] if field
 | 
						|
    when 4  # identities
 | 
						|
      item['identity'].each { |k,v| data[k] = v if v }
 | 
						|
      return item['identity'][field] if field
 | 
						|
    end
 | 
						|
    (item['fields'] || []).each do |field|
 | 
						|
      data[field['name']] = field['value']
 | 
						|
    end
 | 
						|
    maxLength = data.keys.map(&:length).max + 5
 | 
						|
    rofiData = {}
 | 
						|
    preamble.merge(data).each do |label, value|
 | 
						|
      if value.kind_of? String
 | 
						|
        valueLabel = value
 | 
						|
        valueLabel = value.gsub /./, '*' if label == 'Password'
 | 
						|
        rofiData["<span font_desc=\"Fira Code\" font_weight=\"bold\">%-#{maxLength}s</span> %s" % [label, valueLabel]] = value
 | 
						|
      else
 | 
						|
        rofiData[label] = value
 | 
						|
      end
 | 
						|
    end
 | 
						|
    rofi rofiData, '-p', item['name']
 | 
						|
  end
 | 
						|
end
 | 
						|
 | 
						|
BW.run ARGV.dup
 |