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