Class: KeeperSecretsManager::TOTP

Inherits:
Object
  • Object
show all
Defined in:
lib/keeper_secrets_manager/totp.rb

Constant Summary collapse

ALGORITHMS =
{
  'SHA1' => OpenSSL::Digest::SHA1,
  'SHA256' => OpenSSL::Digest::SHA256,
  'SHA512' => OpenSSL::Digest::SHA512
}.freeze

Class Method Summary collapse

Class Method Details

.generate_code(secret, time: Time.now, algorithm: 'SHA1', digits: 6, period: 30) ⇒ String

Generate a TOTP code

Parameters:

  • secret (String)

    Base32 encoded secret

  • time (Time) (defaults to: Time.now)

    Time to generate code for (default: current time)

  • algorithm (String) (defaults to: 'SHA1')

    Hash algorithm: SHA1, SHA256, or SHA512

  • digits (Integer) (defaults to: 6)

    Number of digits (6 or 8)

  • period (Integer) (defaults to: 30)

    Time period in seconds

Returns:

  • (String)

    TOTP code

Raises:

  • (ArgumentError)


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
# File 'lib/keeper_secrets_manager/totp.rb', line 23

def self.generate_code(secret, time: Time.now, algorithm: 'SHA1', digits: 6, period: 30)
  # Validate inputs
  raise ArgumentError, "Invalid algorithm: #{algorithm}" unless ALGORITHMS.key?(algorithm)
  raise ArgumentError, 'Digits must be 6 or 8' unless [6, 8].include?(digits)
  raise ArgumentError, 'Period must be positive' unless period.positive?

  # Decode base32 secret
  key = Base32.decode(secret.upcase.tr(' ', ''))

  # Calculate time counter
  counter = (time.to_i / period).floor

  # Convert counter to 8-byte string (big-endian)
  counter_bytes = [counter].pack('Q>')

  # Generate HMAC
  digest = ALGORITHMS[algorithm].new
  hmac = OpenSSL::HMAC.digest(digest, key, counter_bytes)

  # Extract dynamic binary code
  offset = hmac[-1].ord & 0x0f
  code = (hmac[offset].ord & 0x7f) << 24 |
         (hmac[offset + 1].ord & 0xff) << 16 |
         (hmac[offset + 2].ord & 0xff) << 8 |
         (hmac[offset + 3].ord & 0xff)

  # Generate final OTP value
  otp = code % (10**digits)

  # Pad with leading zeros if necessary
  otp.to_s.rjust(digits, '0')
end

.generate_secret(length: 20) ⇒ String

Generate a random secret suitable for TOTP

Parameters:

  • length (Integer) (defaults to: 20)

    Number of bytes (default: 20 for 160 bits)

Returns:

  • (String)

    Base32 encoded secret



135
136
137
138
# File 'lib/keeper_secrets_manager/totp.rb', line 135

def self.generate_secret(length: 20)
  random_bytes = OpenSSL::Random.random_bytes(length)
  Base32.encode(random_bytes).delete('=')
end

.generate_url(account, secret, issuer: nil, algorithm: 'SHA1', digits: 6, period: 30) ⇒ String

Generate TOTP URL

Parameters:

  • account (String)

    Account name (e.g., email)

  • secret (String)

    Base32 encoded secret

  • issuer (String) (defaults to: nil)

    Service name

  • algorithm (String) (defaults to: 'SHA1')

    Hash algorithm

  • digits (Integer) (defaults to: 6)

    Number of digits

  • period (Integer) (defaults to: 30)

    Time period

Returns:

  • (String)

    TOTP URL



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/keeper_secrets_manager/totp.rb', line 95

def self.generate_url(, secret, issuer: nil, algorithm: 'SHA1', digits: 6, period: 30)
  label = issuer ? "#{issuer}:#{account}" : 

  params = {
    'secret' => secret,
    'algorithm' => algorithm,
    'digits' => digits,
    'period' => period
  }

  params['issuer'] = issuer if issuer

  query = URI.encode_www_form(params)
  "otpauth://totp/#{URI.encode_www_form_component(label)}?#{query}"
end

.parse_url(url) ⇒ Hash

Parse TOTP URL (otpauth://totp/…)

Parameters:

  • url (String)

    TOTP URL

Returns:

  • (Hash)

    Parsed components

Raises:

  • (ArgumentError)


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
# File 'lib/keeper_secrets_manager/totp.rb', line 59

def self.parse_url(url)
  uri = URI(url)

  raise ArgumentError, 'Invalid TOTP URL scheme' unless uri.scheme == 'otpauth'
  raise ArgumentError, 'Invalid TOTP URL type' unless uri.host == 'totp'

  # Extract label (issuer:account or just account)
  path = uri.path[1..-1] # Remove leading /
  if path.include?(':')
    issuer,  = path.split(':', 2)
  else
     = path
    issuer = nil
  end

  # Parse query parameters
  params = URI.decode_www_form(uri.query || '').to_h

  {
    'account' => URI.decode_www_form_component( || ''),
    'issuer' => issuer ? URI.decode_www_form_component(issuer) : params['issuer'],
    'secret' => params['secret'],
    'algorithm' => params['algorithm'] || 'SHA1',
    'digits' => (params['digits'] || '6').to_i,
    'period' => (params['period'] || '30').to_i
  }
end

.validate_code(secret, code, time: Time.now, window: 1, algorithm: 'SHA1', digits: 6, period: 30) ⇒ Boolean

Validate a TOTP code

Parameters:

  • secret (String)

    Base32 encoded secret

  • code (String)

    Code to validate

  • time (Time) (defaults to: Time.now)

    Time to validate against

  • window (Integer) (defaults to: 1)

    Number of periods to check before/after

  • algorithm (String) (defaults to: 'SHA1')

    Hash algorithm

  • digits (Integer) (defaults to: 6)

    Number of digits

  • period (Integer) (defaults to: 30)

    Time period

Returns:

  • (Boolean)

    True if code is valid



120
121
122
123
124
125
126
127
128
129
130
# File 'lib/keeper_secrets_manager/totp.rb', line 120

def self.validate_code(secret, code, time: Time.now, window: 1, algorithm: 'SHA1', digits: 6, period: 30)
  # Check current time and window
  (-window..window).each do |offset|
    test_time = time + (offset * period)
    test_code = generate_code(secret, time: test_time, algorithm: algorithm, digits: digits, period: period)

    return true if test_code == code
  end

  false
end