main
  1require 'rqrcode'
  2require 'socket'
  3require 'thor'
  4require 'uri'
  5
  6module TFA
  7  class CLI < Thor
  8    package_name "TFA"
  9    class_option :filename
 10    class_option :directory
 11    class_option :passphrase
 12
 13    desc "add NAME SECRET", "add a new secret to the database"
 14    def add(name, secret)
 15      storage.save(name, clean(secret))
 16      "Added #{name}"
 17    end
 18
 19    desc "destroy NAME", "remove the secret associated with the name"
 20    def destroy(name)
 21      storage.delete(name)
 22    end
 23
 24    desc "show NAME", "shows the secret for the given key"
 25    method_option :format, default: "raw", enum: ["raw", "qrcode", "uri"], desc: "The format to export"
 26    def show(name = nil)
 27      return storage.all.map { |x| x.keys }.flatten.sort unless name
 28
 29      case options[:format]
 30      when "qrcode"
 31        RQRCode::QRCode.new(uri_for(name, storage.secret_for(name))).as_ansi(
 32          light: "\033[47m", dark: "\033[40m", fill_character: '  ', quiet_zone_size: 1
 33        )
 34      when "uri"
 35        uri_for(name, storage.secret_for(name))
 36      else
 37        storage.secret_for(name)
 38      end
 39    end
 40
 41    desc "totp NAME", "generate a Time based One Time Password using the secret associated with the given NAME."
 42    def totp(name)
 43      TotpCommand.new(storage).run(name)
 44    end
 45
 46    desc "now SECRET", "generate a Time based One Time Password for the given secret"
 47    def now(secret)
 48      TotpCommand.new(storage).run('', secret)
 49    end
 50
 51    desc "upgrade", "upgrade the database."
 52    def upgrade
 53      if !File.exist?(pstore_path)
 54        say_status :error, "Unable to detect #{pstore_path}", :red
 55        return
 56      end
 57      if File.exist?(secure_path)
 58        say_status :error, "The new database format was detected.", :red
 59        return
 60      end
 61
 62      if yes? "Upgrade to #{secure_path}?"
 63        secure_storage
 64        pstore_storage.each do |row|
 65          row.each do |name, secret|
 66            secure_storage.save(name, secret) if yes?("Migrate `#{name}`?")
 67          end
 68        end
 69        File.delete(pstore_path) if yes?("Delete `#{pstore_path}`?")
 70      end
 71    end
 72
 73    desc "encrypt", "encrypts the tfa database"
 74    def encrypt
 75      return unless ensure_upgraded!
 76
 77      secure_storage.encrypt!
 78    end
 79
 80    desc "decrypt", "decrypts the tfa database"
 81    def decrypt
 82      return unless ensure_upgraded!
 83
 84      secure_storage.decrypt!
 85    end
 86
 87    private
 88
 89    def storage
 90      File.exist?(pstore_path) ? pstore_storage : secure_storage
 91    end
 92
 93    def pstore_storage
 94      @pstore_storage ||= Storage.new(pstore_path)
 95    end
 96
 97    def secure_storage
 98      @secure_storage ||= SecureStorage.new(Storage.new(secure_path), ->{ passphrase })
 99    end
100
101    def filename
102      options[:filename] || '.tfa'
103    end
104
105    def directory
106      options[:directory] || Dir.home
107    end
108
109    def pstore_path
110      File.join(directory, "#{filename}.pstore")
111    end
112
113    def secure_path
114      File.join(directory, filename)
115    end
116
117    def clean(secret)
118      if secret.include?("=")
119        /secret=([^&]*)/.match(secret).captures.first
120      else
121        secret
122      end
123    end
124
125    def passphrase
126      @passphrase ||=
127        begin
128          result = options[:passphrase] || ask("Enter passphrase:\n", echo: false)
129          raise "Invalid Passphrase" if result.nil? || result.strip.empty?
130          result
131        end
132    end
133
134    def ensure_upgraded!
135      return true if upgraded?
136
137      error = "Use the `upgrade` command to upgrade your database."
138      say_status :error, error, :red
139      false
140    end
141
142    def upgraded?
143      !File.exist?(pstore_path) && File.exist?(secure_path)
144    end
145
146    def uri_for(issuer, secret)
147      URI.encode("otpauth://totp/#{issuer}/#{ENV['LOGNAME']}@#{Socket.gethostname}?secret=#{secret}&issuer=#{issuer}")
148    end
149  end
150end