diff --git a/Gemfile b/Gemfile index 19a0e17..1687e18 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,8 @@ source 'https://rubygems.org' +# Specify your gem's dependencies in rotpl.gemspec +gemspec + group :development, :test do - gem "rspec" - gem "rspec-given" gem "pry" - gem "base32" end diff --git a/Gemfile.lock b/Gemfile.lock index 33f7ca1..cd2808a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,43 +1,50 @@ +PATH + remote: . + specs: + rotpl (0.1.0) + base32 (~> 0.3) + GEM remote: https://rubygems.org/ specs: - base32 (0.3.2) - coderay (1.1.1) - diff-lcs (1.3) - given_core (3.8.0) + base32 (0.3.4) + coderay (1.1.3) + diff-lcs (1.6.2) + given_core (3.8.2) sorcerer (>= 0.3.7) - method_source (0.8.2) - pry (0.10.4) - coderay (~> 1.1.0) - method_source (~> 0.8.1) - slop (~> 3.4) - rspec (3.5.0) - rspec-core (~> 3.5.0) - rspec-expectations (~> 3.5.0) - rspec-mocks (~> 3.5.0) - rspec-core (3.5.4) - rspec-support (~> 3.5.0) - rspec-expectations (3.5.0) + method_source (1.1.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + rake (13.3.1) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.5.0) - rspec-given (3.8.0) - given_core (= 3.8.0) + rspec-support (~> 3.13.0) + rspec-given (3.8.2) + given_core (= 3.8.2) rspec (>= 2.14.0) - rspec-mocks (3.5.0) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.5.0) - rspec-support (3.5.0) - slop (3.6.0) - sorcerer (1.0.2) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + sorcerer (2.0.1) PLATFORMS ruby + x86_64-linux DEPENDENCIES - base32 pry - rspec - rspec-given + rake (~> 13.0) + rotpl! + rspec (~> 3.0) + rspec-given (~> 3.8) BUNDLED WITH - 1.12.5 + 2.7.2 diff --git a/README.md b/README.md index e187e52..a68592f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ROTPL - Ruby OTP-Two-Factor-authentication Library +[![Gem Version](https://badge.fury.io/rb/rotpl.svg)](https://badge.fury.io/rb/rotpl) + A Ruby implementation of HMAC-based One-Time Password (HOTP) and Time-based One-Time Password (TOTP) algorithms, fully compatible with Google Authenticator. ## Features @@ -10,32 +12,40 @@ A Ruby implementation of HMAC-based One-Time Password (HOTP) and Time-based One- - Clock skew tolerance (±30 seconds by default) - Clean, testable API with dependency injection - RFC test vector validation +- Comprehensive test suite (58 passing specs) ## Installation -This library is currently distributed as source code. To use it in your project: +Add this line to your application's Gemfile: + +```ruby +gem 'rotpl' +``` -1. Copy the `lib/` directory into your project -2. Install the required dependency for Google Authenticator support: +And then execute: ```bash -gem install base32 +bundle install ``` -Or add to your Gemfile: +Or install it yourself as: -```ruby -gem 'base32' +```bash +gem install rotpl ``` -Note: The `base32` gem is only required if you plan to use `GoogleAuthenticator`. The core `Hotp` and `Totp` classes have no external dependencies. +### Requirements + +- Ruby >= 2.6.0 +- Dependencies are automatically installed with the gem: + - `base32` (~> 0.3) - for Google Authenticator Base32 encoding ## Quick Start ### Google Authenticator (Most Common Use Case) ```ruby -require_relative 'lib/google_authenticator' +require 'rotpl' # Secret from Google Authenticator QR code secret = "JBSWY3DPEHPK3PXP" @@ -58,7 +68,7 @@ end ### Time-based OTP (TOTP) ```ruby -require_relative 'lib/totp' +require 'rotpl' # Use a binary secret (not Base32-encoded) secret = "12345678901234567890" @@ -80,7 +90,7 @@ codes = totp.generate_otp ### Counter-based OTP (HOTP) ```ruby -require_relative 'lib/hotp' +require 'rotpl' secret = "12345678901234567890" @@ -102,7 +112,7 @@ otp = Rotpl::Hotp.generate_otp(secret, 0, code_digits: 8) ### Building a Login System ```ruby -require_relative 'lib/google_authenticator' +require 'rotpl' class TwoFactorAuth def initialize(user_secret) @@ -127,7 +137,9 @@ end ### Generating QR Code Secrets ```ruby -require 'base32' +require 'rotpl' +require 'securerandom' +require 'base32' # included as a dependency with the gem # Generate a random secret for a new user random_secret = Base32.encode(SecureRandom.random_bytes(20)) @@ -141,12 +153,17 @@ user.update(two_factor_secret: random_secret) qr_url = "otpauth://totp/#{user.email}?secret=#{random_secret}&issuer=MyApp" # Generate QR code from qr_url and display to user +# Use a QR code gem like 'rqrcode' to generate the actual QR image ``` ### Handling Clock Skew ```ruby +require 'rotpl' + # TOTP returns 3 codes by default +secret = "12345678901234567890" +totp = Rotpl::Totp.new(secret) codes = totp.generate_otp # codes[0] = previous 30-second window # codes[1] = current 30-second window @@ -161,6 +178,10 @@ end ### Custom Time Windows ```ruby +require 'rotpl' + +secret = "12345678901234567890" + # 60-second windows instead of 30 totp = Rotpl::Totp.new(secret, time_step: 60) @@ -184,16 +205,40 @@ All classes and methods include YARD documentation. Key classes: - `GoogleAuthenticator.new(base32_secret, time_step: 30)` - Initialize with Base32 secret - `#generate_otp(time = Time.now, code_digits: 6)` - Generate codes (returns array of 3) +### Checking the Gem Version + +```ruby +require 'rotpl' +puts Rotpl::VERSION # => "0.1.0" +``` + ## Testing -Run the test suite: +The gem includes a comprehensive test suite with 58 specs covering: +- RFC 4226 (HOTP) test vectors +- RFC 6238 (TOTP) test vectors +- Google Authenticator compatibility +- Clock skew tolerance +- Edge cases and performance + +### Running Tests ```bash +# Clone the repository +git clone https://github.com/parasquid/rotpl.git +cd rotpl + +# Install dependencies bundle install + +# Run all tests rspec + +# Run with documentation format +rspec --format documentation ``` -Tests validate against official RFC 4226 and RFC 6238 test vectors. +All tests validate against official RFC 4226 and RFC 6238 test vectors. ## Security Considerations @@ -204,18 +249,36 @@ Tests validate against official RFC 4226 and RFC 6238 test vectors. - Consider requiring backup codes for account recovery - Secrets should be at least 160 bits (20 bytes) for HMAC-SHA1 +## Development + +After checking out the repo, run `bundle install` to install dependencies. Then, run `rspec` to run the tests. + +To install this gem onto your local machine, run: + +```bash +gem build rotpl.gemspec +gem install rotpl-0.1.0.gem +``` + +To release a new version: +1. Update the version number in `lib/rotpl/version.rb` +2. Update the changelog +3. Run `gem build rotpl.gemspec` +4. Run `gem push rotpl-x.x.x.gem` (requires RubyGems account) + ## Contributing -1. Fork it +1. Fork it (https://github.com/parasquid/rotpl/fork) 2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Don't forget tests! -6. Create new Pull Request +3. Write tests for your changes +4. Make your changes and ensure tests pass (`rspec`) +5. Commit your changes (`git commit -am 'Add some feature'`) +6. Push to the branch (`git push origin my-new-feature`) +7. Create a new Pull Request ## License -GNU LGPL v3 - See LICENSE file for details +GNU LGPL v3 or later - See LICENSE file for details ## Copyright diff --git a/lib/rotpl.rb b/lib/rotpl.rb new file mode 100644 index 0000000..4c8bf02 --- /dev/null +++ b/lib/rotpl.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative "rotpl/version" +require_relative "rotpl/hotp" +require_relative "rotpl/totp" +require_relative "rotpl/google_authenticator" + +# ROTPL - Ruby OTP-Two-Factor-authentication Library +# +# A Ruby implementation of HMAC-based One-Time Password (HOTP) and +# Time-based One-Time Password (TOTP) algorithms, fully compatible +# with Google Authenticator. +# +# @see https://tools.ietf.org/html/rfc4226 RFC 4226 (HOTP) +# @see https://tools.ietf.org/html/rfc6238 RFC 6238 (TOTP) +module Rotpl +end diff --git a/lib/google_authenticator.rb b/lib/rotpl/google_authenticator.rb similarity index 100% rename from lib/google_authenticator.rb rename to lib/rotpl/google_authenticator.rb diff --git a/lib/hotp.rb b/lib/rotpl/hotp.rb similarity index 100% rename from lib/hotp.rb rename to lib/rotpl/hotp.rb diff --git a/lib/totp.rb b/lib/rotpl/totp.rb similarity index 100% rename from lib/totp.rb rename to lib/rotpl/totp.rb diff --git a/lib/rotpl/version.rb b/lib/rotpl/version.rb new file mode 100644 index 0000000..8852fa7 --- /dev/null +++ b/lib/rotpl/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Rotpl + VERSION = "0.1.0" +end diff --git a/rotpl.gemspec b/rotpl.gemspec new file mode 100644 index 0000000..0d2efc6 --- /dev/null +++ b/rotpl.gemspec @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative "lib/rotpl/version" + +Gem::Specification.new do |spec| + spec.name = "rotpl" + spec.version = Rotpl::VERSION + spec.authors = ["parasquid"] + spec.email = ["parasquid@gmail.com"] + + spec.summary = "Ruby OTP-Two-Factor-authentication Library" + spec.description = "A Ruby implementation of HMAC-based One-Time Password (HOTP) and Time-based One-Time Password (TOTP) algorithms, fully compatible with Google Authenticator." + spec.homepage = "https://github.com/parasquid/rotpl" + spec.license = "LGPL-3.0-or-later" + spec.required_ruby_version = ">= 2.6.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "#{spec.homepage}.git" + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor]) + end + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + # Runtime dependencies + spec.add_dependency "base32", "~> 0.3" + + # Development dependencies + spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "rspec-given", "~> 3.8" + spec.add_development_dependency "rake", "~> 13.0" +end diff --git a/spec/feature_spec.rb b/spec/feature_spec.rb index 837dda6..c28e74f 100644 --- a/spec/feature_spec.rb +++ b/spec/feature_spec.rb @@ -1,5 +1,5 @@ require "spec_helper" -require_relative "../lib/hotp" +require "rotpl" describe Rotpl::Hotp do Given(:klass) { Rotpl::Hotp } diff --git a/spec/google_authenticator_spec.rb b/spec/google_authenticator_spec.rb new file mode 100644 index 0000000..b1b9342 --- /dev/null +++ b/spec/google_authenticator_spec.rb @@ -0,0 +1,156 @@ +require "spec_helper" +require "rotpl" + +describe Rotpl::GoogleAuthenticator do + let(:base32_secret) { "JBSWY3DPEHPK3PXP" } + let(:ga) { Rotpl::GoogleAuthenticator.new(base32_secret) } + + describe "#initialize" do + it "creates a GoogleAuthenticator instance with Base32 secret" do + expect(ga).to be_a(Rotpl::GoogleAuthenticator) + end + + it "is a subclass of Totp" do + expect(ga).to be_a(Rotpl::Totp) + end + + it "handles secrets with spaces" do + secret_with_spaces = "JBSW Y3DP EHPK 3PXP" + ga_with_spaces = Rotpl::GoogleAuthenticator.new(secret_with_spaces) + + codes1 = ga.generate_otp(Time.at(59)) + codes2 = ga_with_spaces.generate_otp(Time.at(59)) + + expect(codes1).to eq(codes2) + end + + it "handles lowercase Base32 secrets" do + lowercase_secret = base32_secret.downcase + ga_lowercase = Rotpl::GoogleAuthenticator.new(lowercase_secret) + + codes1 = ga.generate_otp(Time.at(59)) + codes2 = ga_lowercase.generate_otp(Time.at(59)) + + expect(codes1).to eq(codes2) + end + + it "handles mixed case Base32 secrets with spaces" do + mixed_secret = "jbsw Y3Dp EhPk 3pXp" + ga_mixed = Rotpl::GoogleAuthenticator.new(mixed_secret) + + codes1 = ga.generate_otp(Time.at(59)) + codes2 = ga_mixed.generate_otp(Time.at(59)) + + expect(codes1).to eq(codes2) + end + + it "accepts custom time_step" do + ga_custom = Rotpl::GoogleAuthenticator.new(base32_secret, time_step: 60) + expect(ga_custom).to be_a(Rotpl::GoogleAuthenticator) + end + end + + describe "#generate_otp" do + it "generates an array of 3 codes" do + codes = ga.generate_otp + expect(codes).to be_an(Array) + expect(codes.length).to eq(3) + end + + it "generates 6-digit codes by default" do + codes = ga.generate_otp + expect(codes.all? { |c| c.length == 6 }).to be true + expect(codes.all? { |c| c =~ /^\d{6}$/ }).to be true + end + + it "generates 8-digit codes when specified" do + codes = ga.generate_otp(code_digits: 8) + expect(codes.all? { |c| c.length == 8 }).to be true + expect(codes.all? { |c| c =~ /^\d{8}$/ }).to be true + end + + it "generates consistent codes for the same time" do + time = Time.at(1234567890) + codes1 = ga.generate_otp(time) + codes2 = ga.generate_otp(time) + + expect(codes1).to eq(codes2) + end + + it "generates different codes for different times" do + time1 = Time.at(1234567890) + time2 = Time.at(1234567890 + 30) + + codes1 = ga.generate_otp(time1) + codes2 = ga.generate_otp(time2) + + # Current code at time1 should be different from current code at time2 + expect(codes1[1]).not_to eq(codes2[1]) + end + + it "provides clock skew tolerance" do + time = Time.at(1234567890) + codes = ga.generate_otp(time) + + # Should have previous, current, and next window codes + expect(codes[0]).not_to eq(codes[1]) + expect(codes[1]).not_to eq(codes[2]) + expect(codes[0]).not_to eq(codes[2]) + end + end + + describe "typical use case: two-factor authentication" do + let(:user_secret) { "JBSWY3DPEHPK3PXP" } + let(:authenticator) { Rotpl::GoogleAuthenticator.new(user_secret) } + + it "can verify user input during login" do + # Generate valid codes + valid_codes = authenticator.generate_otp + + # Simulate user entering the current code + user_input = valid_codes[1] + + # Verify the code + expect(valid_codes).to include(user_input) + end + + it "accepts codes from adjacent time windows" do + time = Time.now + valid_codes = authenticator.generate_otp(time) + + # All three codes should be considered valid + # (previous, current, and next time window) + expect(valid_codes.length).to eq(3) + valid_codes.each do |code| + expect(valid_codes).to include(code) + end + end + + it "rejects invalid codes" do + valid_codes = authenticator.generate_otp + invalid_code = "000000" + + expect(valid_codes).not_to include(invalid_code) + end + end + + describe "compatibility with Google Authenticator format" do + it "works with standard Google Authenticator Base32 secrets" do + # Common Base32 secret format from Google Authenticator + secrets = [ + "JBSWY3DPEHPK3PXP", + "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + ] + + secrets.each do |secret| + ga = Rotpl::GoogleAuthenticator.new(secret) + codes = ga.generate_otp + + expect(codes).to be_an(Array) + expect(codes.length).to eq(3) + expect(codes.all? { |c| c =~ /^\d{6}$/ }).to be true + end + end + end +end diff --git a/spec/integration_spec.rb b/spec/integration_spec.rb new file mode 100644 index 0000000..b4da42d --- /dev/null +++ b/spec/integration_spec.rb @@ -0,0 +1,207 @@ +require "spec_helper" +require "rotpl" + +describe "Rotpl Integration Tests" do + describe "loading the gem" do + it "defines the Rotpl module" do + expect(defined?(Rotpl)).to eq("constant") + expect(Rotpl).to be_a(Module) + end + + it "defines Rotpl::VERSION" do + expect(Rotpl::VERSION).to be_a(String) + expect(Rotpl::VERSION).to match(/^\d+\.\d+\.\d+$/) + end + + it "loads all main classes" do + expect(defined?(Rotpl::Hotp)).to eq("constant") + expect(defined?(Rotpl::Totp)).to eq("constant") + expect(defined?(Rotpl::GoogleAuthenticator)).to eq("constant") + end + end + + describe "HOTP → TOTP → GoogleAuthenticator inheritance chain" do + it "TOTP uses HOTP internally" do + secret = "12345678901234567890" + time = Time.at(59) + + totp = Rotpl::Totp.new(secret) + codes = totp.generate_otp(time) + + # The current time window (index 1) should match HOTP counter 1 + hotp_code = Rotpl::Hotp.generate_otp(secret, 1) + expect(codes[1]).to eq(hotp_code) + end + + it "GoogleAuthenticator extends TOTP with Base32 decoding" do + # "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" decodes to "12345678901234567890" + base32_secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + binary_secret = "12345678901234567890" + + time = Time.at(59) + + ga = Rotpl::GoogleAuthenticator.new(base32_secret) + totp = Rotpl::Totp.new(binary_secret) + + ga_codes = ga.generate_otp(time) + totp_codes = totp.generate_otp(time) + + expect(ga_codes).to eq(totp_codes) + end + end + + describe "real-world usage scenarios" do + context "setting up 2FA for a new user" do + it "generates a secret, creates QR code URL, and verifies codes" do + # 1. Generate a random Base32 secret for the user + require 'securerandom' + require 'base32' + random_secret = Base32.encode(SecureRandom.random_bytes(20)) + + # 2. Create authenticator + ga = Rotpl::GoogleAuthenticator.new(random_secret) + + # 3. Generate QR code URL (standard format) + user_email = "user@example.com" + issuer = "MyApp" + qr_url = "otpauth://totp/#{user_email}?secret=#{random_secret}&issuer=#{issuer}" + + expect(qr_url).to include("otpauth://totp/") + expect(qr_url).to include(random_secret) + + # 4. User scans QR code and enters first code + codes = ga.generate_otp + user_input = codes[1] # User enters the current code + + # 5. Verify the code + expect(codes).to include(user_input) + end + end + + context "user logging in with 2FA" do + let(:user_secret) { "JBSWY3DPEHPK3PXP" } + let(:authenticator) { Rotpl::GoogleAuthenticator.new(user_secret) } + + it "accepts valid codes within the time window" do + valid_codes = authenticator.generate_otp + + # Simulate user entering each possible valid code + valid_codes.each do |code| + expect(valid_codes).to include(code) + end + end + + it "handles clock drift between client and server" do + # Server time + server_time = Time.now + server_codes = authenticator.generate_otp(server_time) + + # Client time is 15 seconds behind + client_time = server_time - 15 + client_codes = authenticator.generate_otp(client_time) + + # There should be overlap in valid codes due to window tolerance + # The previous window on server overlaps with current window on client + expect((server_codes & client_codes).empty?).to be false + end + end + + context "testing with multiple authenticators" do + it "different secrets generate different codes" do + ga1 = Rotpl::GoogleAuthenticator.new("JBSWY3DPEHPK3PXP") + ga2 = Rotpl::GoogleAuthenticator.new("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ") + + time = Time.at(1234567890) + codes1 = ga1.generate_otp(time) + codes2 = ga2.generate_otp(time) + + expect(codes1).not_to eq(codes2) + end + + it "same secret generates same codes" do + ga1 = Rotpl::GoogleAuthenticator.new("JBSWY3DPEHPK3PXP") + ga2 = Rotpl::GoogleAuthenticator.new("JBSWY3DPEHPK3PXP") + + time = Time.at(1234567890) + codes1 = ga1.generate_otp(time) + codes2 = ga2.generate_otp(time) + + expect(codes1).to eq(codes2) + end + end + + context "backup codes and recovery" do + it "can pre-generate codes for future time windows" do + ga = Rotpl::GoogleAuthenticator.new("JBSWY3DPEHPK3PXP") + + # Generate codes for multiple time windows (e.g., for backup codes) + backup_codes = [] + base_time = Time.now.to_i + + # Generate 5 future time windows (5 x 30 seconds) + 5.times do |i| + future_time = Time.at(base_time + (i * 30)) + codes = ga.generate_otp(future_time) + backup_codes << codes[1] # Take the current window code + end + + expect(backup_codes.length).to eq(5) + expect(backup_codes.uniq.length).to eq(5) # All unique + end + end + end + + describe "edge cases and robustness" do + it "handles very large time values" do + ga = Rotpl::GoogleAuthenticator.new("JBSWY3DPEHPK3PXP") + large_time = Time.at(99999999999) + + codes = ga.generate_otp(large_time) + expect(codes).to be_an(Array) + expect(codes.length).to eq(3) + end + + it "handles time at epoch (0)" do + ga = Rotpl::GoogleAuthenticator.new("JBSWY3DPEHPK3PXP") + epoch_time = Time.at(0) + + codes = ga.generate_otp(epoch_time) + expect(codes).to be_an(Array) + expect(codes.length).to eq(3) + end + + it "generates codes quickly" do + ga = Rotpl::GoogleAuthenticator.new("JBSWY3DPEHPK3PXP") + + start_time = Time.now + 1000.times { ga.generate_otp } + elapsed = Time.now - start_time + + # Should be able to generate 1000 codes in less than 1 second + expect(elapsed).to be < 1.0 + end + + it "produces zero-padded codes" do + # Some time windows might produce codes starting with 0 + ga = Rotpl::GoogleAuthenticator.new("JBSWY3DPEHPK3PXP") + + # Test many time windows to find zero-padded codes + found_zero_padded = false + 100.times do |i| + time = Time.at(i * 30) + codes = ga.generate_otp(time) + if codes.any? { |c| c.start_with?('0') } + found_zero_padded = true + break + end + end + + # At least one code should start with 0 in 100 attempts + # Each code should still be exactly 6 digits + codes = ga.generate_otp(Time.at(1234567890)) + codes.each do |code| + expect(code.length).to eq(6) + end + end + end +end diff --git a/spec/totp_spec.rb b/spec/totp_spec.rb new file mode 100644 index 0000000..ae9b848 --- /dev/null +++ b/spec/totp_spec.rb @@ -0,0 +1,126 @@ +require "spec_helper" +require "rotpl" + +describe Rotpl::Totp do + let(:secret) { "12345678901234567890" } + let(:totp) { Rotpl::Totp.new(secret) } + + describe "#initialize" do + it "creates a TOTP instance with default time_step" do + expect(totp).to be_a(Rotpl::Totp) + end + + it "accepts custom time_step" do + custom_totp = Rotpl::Totp.new(secret, time_step: 60) + expect(custom_totp).to be_a(Rotpl::Totp) + end + end + + describe "#generate_otp" do + context "RFC 6238 test vectors" do + # Test vectors from RFC 6238 + # Using the secret "12345678901234567890" + + it "generates correct OTP for time 59" do + time = Time.at(59) + codes = totp.generate_otp(time) + # Middle code (index 1) is for the current time window + expect(codes[1]).to eq("287082") + end + + it "generates correct OTP for time 1111111109" do + time = Time.at(1111111109) + codes = totp.generate_otp(time) + expect(codes[1]).to eq("081804") + end + + it "generates correct OTP for time 1111111111" do + time = Time.at(1111111111) + codes = totp.generate_otp(time) + expect(codes[1]).to eq("050471") + end + + it "generates correct OTP for time 1234567890" do + time = Time.at(1234567890) + codes = totp.generate_otp(time) + expect(codes[1]).to eq("005924") + end + + it "generates correct OTP for time 2000000000" do + time = Time.at(2000000000) + codes = totp.generate_otp(time) + expect(codes[1]).to eq("279037") + end + + it "generates correct OTP for time 20000000000" do + time = Time.at(20000000000) + codes = totp.generate_otp(time) + expect(codes[1]).to eq("353130") + end + end + + context "clock skew tolerance" do + let(:time) { Time.at(59) } + + it "returns an array of 3 codes" do + codes = totp.generate_otp(time) + expect(codes).to be_an(Array) + expect(codes.length).to eq(3) + end + + it "returns codes for previous, current, and next time windows" do + codes = totp.generate_otp(time) + expect(codes[0]).to eq("755224") # previous window (counter -1) + expect(codes[1]).to eq("287082") # current window (counter 0) + expect(codes[2]).to eq("359152") # next window (counter +1) + end + + it "all codes are strings" do + codes = totp.generate_otp(time) + expect(codes.all? { |c| c.is_a?(String) }).to be true + end + + it "all codes are 6 digits by default" do + codes = totp.generate_otp(time) + expect(codes.all? { |c| c.length == 6 }).to be true + end + end + + context "custom code digits" do + let(:time) { Time.at(59) } + + it "generates 8-digit codes when specified" do + codes = totp.generate_otp(time, code_digits: 8) + expect(codes.all? { |c| c.length == 8 }).to be true + expect(codes[1]).to eq("94287082") + end + + it "generates 7-digit codes when specified" do + codes = totp.generate_otp(time, code_digits: 7) + expect(codes.all? { |c| c.length == 7 }).to be true + end + end + + context "custom time step" do + it "generates different codes with 60-second time step" do + totp_30 = Rotpl::Totp.new(secret, time_step: 30) + totp_60 = Rotpl::Totp.new(secret, time_step: 60) + + time = Time.at(59) + codes_30 = totp_30.generate_otp(time) + codes_60 = totp_60.generate_otp(time) + + expect(codes_30[1]).not_to eq(codes_60[1]) + end + end + + context "current time" do + it "generates codes for current time when no time argument provided" do + codes = totp.generate_otp + expect(codes).to be_an(Array) + expect(codes.length).to eq(3) + expect(codes.all? { |c| c.length == 6 }).to be true + end + end + end +end