#!/usr/bin/env ruby #!DESCRIBE: CLI based TOTP 2FA authenticator require "openssl" require "base32" require "optimist" def db32(str) Base32.decode(str) end def hs1(key, counter, hsh = "sha1") OpenSSL::HMAC.hexdigest(hsh, key.to_s, [counter].pack("Q>")) end def dt(str) offset = str[-1].to_i(16) p = str[(offset*2)...((offset+4)*2)] p[0] = (p[0].to_i(16) & 0x7).to_s(16) p.to_i(16) end def hotp(num, dig = 6) num % (10 ** dig) end def fatal(msg) $stderr.puts(msg) exit 1 end class Secrets class MultipleDefaultException < StandardError def initialize super("Multiple default issuers specified in config file") end end class Secret def initialize(method, secret, tdc, dig, hsh) @method = method @secret = secret @tdc = tdc @dig = dig @hsh = hsh end attr_reader :method def verify(tx = Time.now.to_i) case @method when 'totp' decoded = db32(@secret) hmac = hs1(decoded, tx.to_i/@tdc.to_i, @hsh) trunc = dt(hmac) code = hotp(trunc, @dig) "%0#{@dig}d" % code when 'hotp' decoded = db32(@secret) hmac = hs1(decoded, @tdc, @hsh) trunc = dt(hmac) code = hotp(trunc, @dig) @tdc += 1 "%0#{@dig}d" % code else fatal("I don't know how to #{@method}") end end def time_remaining(tx = Time.now.to_i) case @method when 'totp' (tx.to_i/@tdc.to_i + 1) * @tdc - tx end end def puts "#{@secret} #{@tdc} #{@dig} #{@hsh}" end end def initialize(arr) @secrets = {} @default = nil arr.each do |secretline| m, i, s, tc, d, h = secretline.split if m[0] == ?* raise MultipleDefaultException.new unless @default.nil? m = m[1..-1] @default = i end case m when 'totp' tc = tc&.to_i || 30 when 'hotp' tc = tc&.to_i || 0 end @secrets[i] = Secret.new(m, s, tc, d&.to_i || 6, h || "sha1") end end attr_reader :default def [](issuer) @secrets[issuer] end def puts @secrets.map do |i, s| "#{i == @default ? ?* : ''}#{s.method} #{i} #{s.puts}" end end end opts = Optimist::options do version "twofa (c) 2019 Nat Lasseter" banner <<-EOS twofa is a command line OTP code generator. Usage: twofa [opts] ISSUER where [opts] are: EOS opt :dont_clip, "Do not copy code to the clipboard" opt :twofa_file, "Location of the twofa secrets file", type: :string, default: File.join(ENV["HOME"], ".twofa") end TWOFAFILE = opts[:twofa_file] fatal("No 2fa issuers file at #{File.absolute_path(TWOFAFILE)}") unless File.exist?(TWOFAFILE) begin SECRETS = Secrets.new(File.readlines(TWOFAFILE).map(&:strip)) rescue Secrets::MultipleDefaultException => e fatal(e.message) end issuer = ARGV.shift&.strip if issuer.nil? issuer = SECRETS.default fatal("Specify issuer") if issuer.nil? puts "Using default issuer: #{issuer}" end sec = SECRETS[issuer] fatal("No such issuer") if sec.nil? case sec.method when 'totp' code = sec.verify time = sec.time_remaining puts "#{code} (for #{time} more seconds)" when 'hotp' code = sec.verify puts "#{code} (rolled counter)" File.open(TWOFAFILE, ?w).puts SECRETS.puts end unless opts[:dont_clip] then require "clipboard" Clipboard.copy(code) puts "(Copied to clipboard)" end