diff --git a/bin/rofi/bwmenu b/bin/rofi/bwmenu new file mode 100755 index 00000000..165ea905 --- /dev/null +++ b/bin/rofi/bwmenu @@ -0,0 +1,257 @@ +#!/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 + 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 + + 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 = " (#{subtitle})" unless urls.empty? + when 2 # secure notes + when 3 # cards + subtitle = " (#{i['card']['brand'].downcase})" + when 4 # identities + end + ["#{icon} #{i['name']}#{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 + 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, item['id'] if item['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["%-#{maxLength}s %s" % [label, valueLabel]] = value + else + rofiData[label] = value + end + end + rofi rofiData, '-p', item['name'] + end +end + +BW.run ARGV.dup diff --git a/config/sxhkd/sxhkdrc b/config/sxhkd/sxhkdrc index 957e9bdf..8bbfe504 100644 --- a/config/sxhkd/sxhkdrc +++ b/config/sxhkd/sxhkdrc @@ -37,8 +37,11 @@ super + space super + Tab $DOTFILES/bin/rofi/windowmenu -super + backslash - $DOTFILES/bin/rofi/passmenu +super + p + $DOTFILES/bin/rofi/bwmenu + +super + shift + p + $DOTFILES/bin/rofi/bwmenu -r super + slash $DOTFILES/bin/rofi/filemenu -x