Module: OpenLoam::Totp

Defined in:
lib/open_loam/totp.rb

Overview

RFC 6238 time-based one-time passwords — the shipped second factor. HMAC-SHA1, 30-second step, 6 digits, ±1 step of drift tolerance (so a code entered a few seconds either side of a boundary still verifies). Hand-rolled on OpenSSL rather than pulling a gem; it is ~20 lines of a well-specified algorithm, verified against the RFC's own test vectors in the test suite.

Constant Summary collapse

DIGITS =
6
PERIOD =

seconds per step

30
DRIFT =

accept the neighbouring step on each side

1

Class Method Summary collapse

Class Method Details

.code_at(secret, counter, digits: DIGITS) ⇒ Object

The HOTP code for a given step counter (RFC 4226 dynamic truncation).



46
47
48
49
50
51
# File 'lib/open_loam/totp.rb', line 46

def self.code_at(secret, counter, digits: DIGITS)
  hmac = OpenSSL::HMAC.digest("SHA1", OpenLoam::Base32.decode(secret), [counter].pack("Q>"))
  offset = hmac.bytes.last & 0x0f
  truncated = hmac[offset, 4].unpack1("N") & 0x7fffffff
  (truncated % (10**digits)).to_s.rjust(digits, "0")
end

.generate_secret(bytes: 20) ⇒ Object

A fresh random secret, base32-encoded for authenticator apps. 20 bytes (160 bits) is the RFC-recommended size for SHA1.



19
20
21
# File 'lib/open_loam/totp.rb', line 19

def self.generate_secret(bytes: 20)
  OpenLoam::Base32.encode(SecureRandom.random_bytes(bytes))
end

.matching_step(secret, code, at: Time.now.to_i) ⇒ Object

The step counter a code matches (within the drift window), or nil. Callers that must prevent replay (login, sudo) persist this and reject a code whose step is not strictly greater than the last accepted one. The length/charset check runs FIRST, so a malformed code never reaches the constant-time compare (which assumes equal-length inputs).



33
34
35
36
37
38
39
40
41
42
43
# File 'lib/open_loam/totp.rb', line 33

def self.matching_step(secret, code, at: Time.now.to_i)
  code = code.to_s.gsub(/\s+/, "")
  return nil unless code.match?(/\A\d{#{DIGITS}}\z/)

  counter = at.to_i / PERIOD
  (-DRIFT..DRIFT).each do |offset|
    step = counter + offset
    return step if ActiveSupport::SecurityUtils.secure_compare(code_at(secret, step), code)
  end
  nil
end

.provisioning_uri(secret, account:, issuer:) ⇒ Object

The otpauth:// URI an authenticator app imports (usually via QR — rendering the QR image is a UI nicety left for later; the URI + secret are enough).



55
56
57
58
59
60
61
# File 'lib/open_loam/totp.rb', line 55

def self.provisioning_uri(secret, account:, issuer:)
  label = ERB::Util.url_encode("#{issuer}:#{}")
  query = URI.encode_www_form(
    secret: secret, issuer: issuer, algorithm: "SHA1", digits: DIGITS, period: PERIOD
  )
  "otpauth://totp/#{label}?#{query}"
end

.verify(secret, code, at: Time.now.to_i) ⇒ Object

True if code is valid for secret right now.



24
25
26
# File 'lib/open_loam/totp.rb', line 24

def self.verify(secret, code, at: Time.now.to_i)
  !matching_step(secret, code, at: at).nil?
end