aboutsummaryrefslogtreecommitdiff
path: root/twofa
blob: a63c37fc310493bd9e19a53c64a8901f1cb78b82 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#!/usr/bin/env ruby

require "openssl"
require "base32"
require "optimist"

def db32(str)
  Base32.decode(str)
end

def hs1(key, td = 30, tx = Time.now.to_i, hsh = "sha1")
  OpenSSL::HMAC.hexdigest(hsh, key.to_s, [tx.to_i/td.to_i].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 Secret
    def initialize(secret, td, dig, hsh)
      @secret = secret
      @td = td
      @dig = dig
      @hsh = hsh
    end

    def verify(tx = Time.now.to_i)
      decoded = db32(@secret)
      hmac = hs1(decoded, @td, tx, @hsh)
      trunc = dt(hmac)
      code = hotp(trunc, @dig)
      "%0#{@dig}d" % code
    end

    def time_remaining(tx = Time.now.to_i)
      (tx.to_i/@td.to_i + 1) * @td - tx
    end
  end

  def initialize(arr)
    @secrets = {}
    arr.each do |secretline|
      i, s, t, d, h = secretline.split
      @secrets[i] = Secret.new(s, t&.to_i || 30, d&.to_i || 6, h || "sha1")
    end
  end

  def [](issuer)
    @secrets[issuer]
  end
end

opts = Optimist::options do
  version "twofa (c) 2019 Nat Lasseter"
  banner <<-EOS
twofa is a command line TOTP code generator.

Usage:
  twofa [opts] ISSUER

where [opts] are:
EOS

  opt :no_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)

SECRETS = Secrets.new(File.readlines(TWOFAFILE).map(&:strip))

ISSUER = ARGV.shift&.strip&.downcase
fatal("Specify issuer") if ISSUER.nil?

sec = SECRETS[ISSUER]
fatal("No such issuer") if sec.nil?

code = sec.verify
time = sec.time_remaining
puts "#{code} (for #{time} more seconds)"

unless opts[:no_clip] then
  require "clipboard"
  Clipboard.copy(code)
  puts "(Copied to clipboard)"
end