rotp-6.3.0/0000755000175000017500000000000014537202765013005 5ustar squigleysquigleyrotp-6.3.0/Dockerfile-2.70000644000175000017500000000024314537202765015302 0ustar squigleysquigleyFROM ruby:2.7 RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY Gemfile /usr/src/app/ COPY . /usr/src/app RUN bundle install CMD ["bundle", "exec", "rspec"] rotp-6.3.0/.github/0000755000175000017500000000000014537202765014345 5ustar squigleysquigleyrotp-6.3.0/.github/workflows/0000755000175000017500000000000014537202765016402 5ustar squigleysquigleyrotp-6.3.0/.github/workflows/release.yaml0000644000175000017500000000206714537202765020713 0ustar squigleysquigleyname: Release on: push: branches: - 'main' jobs: release: runs-on: ubuntu-latest steps: - uses: google-github-actions/release-please-action@v3 id: release with: release-type: ruby package-name: rotp version-file: "lib/rotp/version.rb" # Checkout code if release was created - uses: actions/checkout@v2 if: ${{ steps.release.outputs.release_created }} # Setup ruby if a release was created - uses: ruby/setup-ruby@v1 with: ruby-version: 3.2 bundler-cache: true if: ${{ steps.release.outputs.release_created }} - name: Run tests run: bundle exec rspec if: ${{ steps.release.outputs.release_created }} # build gem and add to release - name: Upload Release Artifact if: ${{ steps.release.outputs.release_created }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gem build *.gemspec gh release upload ${{ steps.release.outputs.tag_name }} *.gemrotp-6.3.0/.github/workflows/test.yaml0000644000175000017500000000102214537202765020240 0ustar squigleysquigleyname: Tests on: push: branches: [ main ] pull_request: types: [opened, reopened, synchronize] jobs: test: runs-on: ubuntu-latest strategy: matrix: ruby-version: ['3.2', '3.0', '2.7', '2.3', truffleruby-head] steps: - uses: actions/checkout@v3 - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - name: Run tests run: bundle exec rspec rotp-6.3.0/.dockerignore0000644000175000017500000000001414537202765015454 0ustar squigleysquigleyDockerfile* rotp-6.3.0/Dockerfile-2.30000644000175000017500000000024214537202765015275 0ustar squigleysquigleyFROM ruby:2.3 RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY Gemfile /usr/src/app/ COPY . /usr/src/app RUN bundle install CMD ["bundle", "exec", "rspec"] rotp-6.3.0/Gemfile0000644000175000017500000000004514537202765014277 0ustar squigleysquigleysource 'http://rubygems.org' gemspec rotp-6.3.0/Guardfile0000644000175000017500000000055114537202765014633 0ustar squigleysquigleyguard :rspec, cmd: 'bundle exec rspec --format progress' do require 'guard/rspec/dsl' dsl = Guard::RSpec::Dsl.new(self) # RSpec files rspec = dsl.rspec watch(rspec.spec_helper) { rspec.spec_dir } watch(rspec.spec_support) { rspec.spec_dir } watch(rspec.spec_files) # Ruby files ruby = dsl.ruby dsl.watch_spec_files_for(ruby.lib_files) end rotp-6.3.0/rotp.gemspec0000644000175000017500000000167014537202765015342 0ustar squigleysquigleyrequire './lib/rotp/version' Gem::Specification.new do |s| s.name = 'rotp' s.version = ROTP::VERSION s.platform = Gem::Platform::RUBY s.required_ruby_version = '>= 2.3' s.license = 'MIT' s.authors = ['Mark Percival'] s.email = ['mark@markpercival.us'] s.homepage = 'https://github.com/mdp/rotp' s.summary = 'A Ruby library for generating and verifying one time passwords' s.description = 'Works for both HOTP and TOTP, and includes QR Code provisioning' s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } s.require_paths = ['lib'] s.add_development_dependency "rake", "~> 13.0" s.add_development_dependency 'rspec', '~> 3.5' s.add_development_dependency 'simplecov', '~> 0.12' s.add_development_dependency 'timecop', '~> 0.8' end rotp-6.3.0/.release-please-manifest.json0000644000175000017500000000002314537202765020444 0ustar squigleysquigley{ ".": "6.3.0" } rotp-6.3.0/lib/0000755000175000017500000000000014537202765013553 5ustar squigleysquigleyrotp-6.3.0/lib/rotp.rb0000644000175000017500000000023114537202765015060 0ustar squigleysquigleyrequire 'openssl' require 'erb' require 'rotp/base32' require 'rotp/otp' require 'rotp/otp/uri' require 'rotp/hotp' require 'rotp/totp' module ROTP end rotp-6.3.0/lib/rotp/0000755000175000017500000000000014537202765014537 5ustar squigleysquigleyrotp-6.3.0/lib/rotp/hotp.rb0000644000175000017500000000205414537202765016037 0ustar squigleysquigleymodule ROTP class HOTP < OTP # Generates the OTP for the given count # @param [Integer] count counter # @returns [Integer] OTP def at(count) generate_otp(count) end # Verifies the OTP passed in against the current time OTP # @param otp [String/Integer] the OTP to check against # @param counter [Integer] the counter of the OTP # @param retries [Integer] number of counters to incrementally retry def verify(otp, counter, retries: 0) counters = (counter..counter + retries).to_a counters.find do |c| super(otp, at(c)) end end # Returns the provisioning URI for the OTP # This can then be encoded in a QR Code and used # to provision the Google Authenticator app # @param [String] name of the account # @param [Integer] initial_count starting counter value, defaults to 0 # @return [String] provisioning uri def provisioning_uri(name = nil, initial_count = 0) OTP::URI.new(self, account_name: name || @name, counter: initial_count).to_s end end end rotp-6.3.0/lib/rotp/cli.rb0000644000175000017500000000227214537202765015636 0ustar squigleysquigleyrequire 'rotp/arguments' module ROTP class CLI attr_reader :filename, :argv def initialize(filename, argv) @filename = filename @argv = argv end # :nocov: def run puts output end # :nocov: def errors if %i[time hmac].include?(options.mode) if options.secret.to_s == '' red 'You must also specify a --secret. Try --help for help.' elsif options.secret.to_s.chars.any? { |c| ROTP::Base32::CHARS.index(c.upcase).nil? } red 'Secret must be in RFC4648 Base32 format - http://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet' end end end def output return options.warnings if options.warnings return errors if errors return arguments.to_s if options.mode == :help if options.mode == :time ROTP::TOTP.new(options.secret, options).now elsif options.mode == :hmac ROTP::HOTP.new(options.secret, options).at options.counter end end def arguments @arguments ||= ROTP::Arguments.new(filename, argv) end def options arguments.options end def red(string) "\033[31m#{string}\033[0m" end end end rotp-6.3.0/lib/rotp/totp.rb0000644000175000017500000000513314537202765016054 0ustar squigleysquigleymodule ROTP DEFAULT_INTERVAL = 30 class TOTP < OTP attr_reader :interval, :issuer # @option options [Integer] interval (30) the time interval in seconds for OTP # This defaults to 30 which is standard. def initialize(s, options = {}) @interval = options[:interval] || DEFAULT_INTERVAL @issuer = options[:issuer] super end # Accepts either a Unix timestamp integer or a Time object. # Time objects will be adjusted to UTC automatically # @param time [Time/Integer] the time to generate an OTP for, integer unix timestamp or Time object def at(time) generate_otp(timecode(time)) end # Generate the current time OTP # @return [Integer] the OTP as an integer def now generate_otp(timecode(Time.now)) end # Verifies the OTP passed in against the current time OTP # and adjacent intervals up to +drift+. Excludes OTPs # from `after` and earlier. Returns time value of # matching OTP code for use in subsequent call. # @param otp [String] the one time password to verify # @param drift_behind [Integer] how many seconds to look back # @param drift_ahead [Integer] how many seconds to look ahead # @param after [Integer] prevent token reuse, last login timestamp # @param at [Time] time at which to generate and verify a particular # otp. default Time.now # @return [Integer, nil] the last successful timestamp # interval def verify(otp, drift_ahead: 0, drift_behind: 0, after: nil, at: Time.now) timecodes = get_timecodes(at, drift_behind, drift_ahead) timecodes = timecodes.select { |t| t > timecode(after) } if after result = nil timecodes.each do |t| result = t * interval if super(otp, generate_otp(t)) end result end # Returns the provisioning URI for the OTP # This can then be encoded in a QR Code and used # to provision the Google Authenticator app # @param [String] name of the account # @return [String] provisioning URI def provisioning_uri(name = nil) OTP::URI.new(self, account_name: name || @name).to_s end private # Get back an array of timecodes for a period def get_timecodes(at, drift_behind, drift_ahead) now = timeint(at) timecode_start = timecode(now - drift_behind) timecode_end = timecode(now + drift_ahead) (timecode_start..timecode_end).step(1).to_a end # Ensure UTC int def timeint(time) return time.to_i unless time.class == Time time.utc.to_i end def timecode(time) timeint(time) / interval end end end rotp-6.3.0/lib/rotp/base32.rb0000644000175000017500000000367114537202765016152 0ustar squigleysquigleyrequire 'securerandom' module ROTP class Base32 class Base32Error < RuntimeError; end CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.each_char.to_a SHIFT = 5 MASK = 31 class << self def decode(str) buffer = 0 idx = 0 bits_left = 0 str = str.tr('=', '').upcase result = [] str.split('').each do |char| buffer = buffer << SHIFT buffer = buffer | (decode_quint(char) & MASK) bits_left = bits_left + SHIFT if bits_left >= 8 result[idx] = (buffer >> (bits_left - 8)) & 255 idx = idx + 1 bits_left = bits_left - 8 end end result.pack('c*') end def encode(b) data = b.unpack('c*') out = String.new buffer = data[0] idx = 1 bits_left = 8 while bits_left > 0 || idx < data.length if bits_left < SHIFT if idx < data.length buffer = buffer << 8 buffer = buffer | (data[idx] & 255) bits_left = bits_left + 8 idx = idx + 1 else pad = SHIFT - bits_left buffer = buffer << pad bits_left = bits_left + pad end end val = MASK & (buffer >> (bits_left - SHIFT)) bits_left = bits_left - SHIFT out.concat(CHARS[val]) end return out end # Defaults to 160 bit long secret (meaning a 32 character long base32 secret) def random(byte_length = 20) rand_bytes = SecureRandom.random_bytes(byte_length) self.encode(rand_bytes) end # Prevent breaking changes def random_base32(str_len = 32) byte_length = str_len * 5/8 random(byte_length) end private def decode_quint(q) CHARS.index(q) || raise(Base32Error, "Invalid Base32 Character - '#{q}'") end end end end rotp-6.3.0/lib/rotp/otp.rb0000644000175000017500000000540014537202765015665 0ustar squigleysquigleymodule ROTP class OTP attr_reader :secret, :digits, :digest, :name, :issuer, :provisioning_params DEFAULT_DIGITS = 6 # @param [String] secret in the form of base32 # @option options digits [Integer] (6) # Number of integers in the OTP. # Google Authenticate only supports 6 currently # @option options digest [String] (sha1) # Digest used in the HMAC. # Google Authenticate only supports 'sha1' currently # @option options name [String] # The name of the account for the OTP. # Used in the provisioning URL # @option options issuer [String] # The issuer of the OTP. # Used in the provisioning URL # @option options provisioning_params [Hash] ({}) # Additional non-standard params you may want appended to the # provisioning URI. Ex. `image: 'https://example.com/icon.png'` # @returns [OTP] OTP instantiation def initialize(s, options = {}) @digits = options[:digits] || DEFAULT_DIGITS @digest = options[:digest] || 'sha1' @name = options[:name] @issuer = options[:issuer] @provisioning_params = options[:provisioning_params] || {} @secret = s end # @param [Integer] input the number used seed the HMAC # Usually either the counter, or the computed integer # based on the Unix timestamp def generate_otp(input) hmac = OpenSSL::HMAC.digest( OpenSSL::Digest.new(digest), byte_secret, int_to_bytestring(input) ) offset = hmac[-1].ord & 0xf code = (hmac[offset].ord & 0x7f) << 24 | (hmac[offset + 1].ord & 0xff) << 16 | (hmac[offset + 2].ord & 0xff) << 8 | (hmac[offset + 3].ord & 0xff) code_str = (10 ** digits + (code % 10 ** digits)).to_s code_str[-digits..-1] end private def verify(input, generated) raise ArgumentError, '`otp` should be a String' unless input.is_a?(String) time_constant_compare(input, generated) end def byte_secret Base32.decode(@secret) end # Turns an integer to the OATH specified # bytestring, which is fed to the HMAC # along with the secret # def int_to_bytestring(int, padding = 8) unless int >= 0 raise ArgumentError, '#int_to_bytestring requires a positive number' end result = [] until int == 0 result << (int & 0xFF).chr int >>= 8 end result.reverse.join.rjust(padding, 0.chr) end # constant-time compare the strings def time_constant_compare(a, b) return false if a.empty? || b.empty? || a.bytesize != b.bytesize l = a.unpack "C#{a.bytesize}" res = 0 b.each_byte { |byte| res |= byte ^ l.shift } res == 0 end end end rotp-6.3.0/lib/rotp/otp/0000755000175000017500000000000014537202765015341 5ustar squigleysquigleyrotp-6.3.0/lib/rotp/otp/uri.rb0000644000175000017500000000310414537202765016463 0ustar squigleysquigleymodule ROTP class OTP # https://github.com/google/google-authenticator/wiki/Key-Uri-Format class URI def initialize(otp, account_name: nil, counter: nil) @otp = otp @account_name = account_name || '' @counter = counter end def to_s "otpauth://#{type}/#{label}?#{parameters}" end private def algorithm return unless %w[sha256 sha512].include?(@otp.digest) @otp.digest.upcase end def counter return if @otp.is_a?(TOTP) fail if @counter.nil? @counter end def digits return if @otp.digits == DEFAULT_DIGITS @otp.digits end def issuer @otp.issuer&.strip&.tr(':', '_') end def label [issuer, @account_name.rstrip] .compact .map { |s| s.tr(':', '_') } .map { |s| ERB::Util.url_encode(s) } .join(':') end def parameters { secret: @otp.secret, issuer: issuer, algorithm: algorithm, digits: digits, period: period, counter: counter, } .merge(@otp.provisioning_params) .reject { |_, v| v.nil? } .map { |k, v| "#{k}=#{ERB::Util.url_encode(v)}" } .join('&') end def period return if @otp.is_a?(HOTP) return if @otp.interval == DEFAULT_INTERVAL @otp.interval end def type case @otp when TOTP then 'totp' when HOTP then 'hotp' end end end end end rotp-6.3.0/lib/rotp/arguments.rb0000644000175000017500000000447214537202765017100 0ustar squigleysquigleyrequire 'optparse' require 'ostruct' module ROTP class Arguments def initialize(filename, arguments) @filename = filename @arguments = Array(arguments) end def options parse options! end def to_s parser.help + "\n" end private attr_reader :arguments, :filename def options! @options ||= default_options end def default_options OpenStruct.new time: true, counter: 0, mode: :time end def parse return options!.mode = :help if arguments.empty? parser.parse arguments rescue OptionParser::InvalidOption => exception options!.mode = :help options!.warnings = red(exception.message + '. Try --help for help.') end def parser OptionParser.new do |parser| parser.banner = '' parser.separator green(' Usage: ') + bold("#{filename} [options]") parser.separator '' parser.separator green ' Examples: ' parser.separator ' ' + bold("#{filename} --secret p4ssword") + ' # Generates a time-based one-time password' parser.separator ' ' + bold("#{filename} --hmac --secret p4ssword --counter 42") + ' # Generates a counter-based one-time password' parser.separator '' parser.separator green ' Options:' parser.on('-s', '--secret [SECRET]', 'The shared secret') do |secret| options!.secret = secret end parser.on('-c', '--counter [COUNTER]', 'The counter for counter-based hmac OTP') do |counter| options!.counter = counter.to_i end parser.on('-t', '--time', 'Use time-based OTP according to RFC 6238 (default)') do options!.mode = :time end parser.on('-m', '--hmac', 'Use counter-based OTP according to RFC 4226') do options!.mode = :hmac end parser.on_tail('-h', '--help', 'Show this message') do options!.mode = :help end parser.on('-d', '--digest [ALGORITHM]', 'Use algorithm for the digest (default sha1)') do |digest| options!.digest = digest end end end def bold(string) "\033[1m#{string}\033[22m" end def green(string) "\033[32m#{string}\033[0m" end def red(string) "\033[31m#{string}\033[0m" end end end rotp-6.3.0/lib/rotp/version.rb0000644000175000017500000000005314537202765016547 0ustar squigleysquigleymodule ROTP VERSION = '6.3.0'.freeze end rotp-6.3.0/docker-compose.yml0000644000175000017500000000143314537202765016443 0ustar squigleysquigleyversion: "3.8" services: ruby_2_3: build: context: . dockerfile: Dockerfile-2.3 volumes: - "./lib:/usr/src/app/lib" - "./spec:/usr/src/app/spec" ruby_2_5: build: context: . dockerfile: Dockerfile-2.5 volumes: - "./lib:/usr/src/app/lib" - "./spec:/usr/src/app/spec" ruby_2_6: build: context: . dockerfile: Dockerfile-2.6 volumes: - "./lib:/usr/src/app/lib" - "./spec:/usr/src/app/spec" ruby_2_7: build: context: . dockerfile: Dockerfile-2.7 volumes: - "./lib:/usr/src/app/lib" - "./spec:/usr/src/app/spec" ruby_3_0: build: context: . dockerfile: Dockerfile-3.0 volumes: - "./lib:/usr/src/app/lib" - "./spec:/usr/src/app/spec" rotp-6.3.0/Dockerfile-3.00000644000175000017500000000027314537202765015277 0ustar squigleysquigleyFROM ruby:3.0 RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY Gemfile /usr/src/app/ COPY . /usr/src/app RUN gem install bundler RUN bundle install CMD ["bundle", "exec", "rspec"] rotp-6.3.0/LICENSE0000644000175000017500000000205714537202765014016 0ustar squigleysquigleyCopyright (C) 2011 by Mark Percival Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. rotp-6.3.0/.gitignore0000644000175000017500000000006214537202765014773 0ustar squigleysquigley*.gem .bundle .yardoc pkg/* coverage Gemfile.lock rotp-6.3.0/release-please-config.json0000644000175000017500000000047114537202765020034 0ustar squigleysquigley{ "packages": { ".": { "changelog-path": "CHANGELOG.md", "bump-minor-pre-major": false, "bump-patch-for-minor-pre-major": false, "draft": false, "prerelease": false } }, "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" }rotp-6.3.0/CHANGELOG.md0000644000175000017500000000714514537202765014625 0ustar squigleysquigley# Changelog ## [6.3.0](https://github.com/mdp/rotp/compare/v6.2.2...v6.3.0) (2023-08-30) ### Features * Allow for non-standard provisioning URI params, eg. image/icon ([#91](https://github.com/mdp/rotp/issues/91)) ([45d8aac](https://github.com/mdp/rotp/commit/45d8aac8356424897faf3a0dbda59f88b22df775)) ## 6.2.2 - Removed `rjust` from `generate_otp` in favor of more time constant version ## 6.2.1 - Removed old rdoc folder that was triggering a security warning due to an old version of JQuery being included in the HTML docs. This has no impact on the Ruby library. ## 6.2.0 - Update to expand compatibility with Ruby 3. This was only a change to the gemspec, no code changes were necessary. ## 6.1.0 - Fixing URI encoding issues again, breaking out into it's own module due to the complexity - closes #100 (@atcruice) - Add docker-compose.yml to help with easier testing ## 6.0.0 - Dropping support for Ruby <2.3 (Major version bump) - Fix issue when using --enable-frozen-string-literal Ruby option #95 (jeremyevans) - URI Encoding fix #94 (ksuh90) - Update gems (rake, addressable) - Update Travis tests to include Ruby 2.7 ## 5.1.0 - Create `random_base32` to perform `random` to avoid breaking changes - Still needed to bump to 5.x due to Base32 cleanup ## 5.0.0 - Clean up base32 implementation to match Google Autheticator - BREAKING `Base32.random_base32` renamed to random - The argument is now byte length vs output string length for more precise bit strengths ## 4.1.0 - Add a digest option to the CLI #83 - Fix provisioning URI is README #82 - Improvements to docs ## 4.0.2 - Fix gemspec requirment for Addressable ## 4.0.1 - Rubocop for style fixes - Replace deprecated URI.encode with Addressable's version ## 4.0.0 - Simplify API - Remove support for Ruby < 2.0 - BREAKING CHANGE: Removed optional second argument (`padding`) from: - `HOTP#at` - `OTP#generate_otp` - `TOTP#at` - `TOTP#now` (first argument) ## 3.3.1 - Add OpenSSL as a requirement for Ruby 2.5. Fixes #70 & #64 - Allow Base32 with padding. #71 - Prevent verify with drift being negative #69 ## 3.3.0 - Add digest algorithm parameter for non SHA1 digests - #62 from @btalbot ## 3.2.0 - Add 'verify_with_drift_and_prior' to prevent prior token use - #58 from @jlfaber ## 3.1.0 - Add Add digits paramater to provisioning URI. #54 from @sbc100 ## 3.0.1 - Use SecureRandom. See mdp/rotp/pull/52 ## 3.0.0 - Provisioning URL includes issuer label per RFC 5234 See mdp/rotp/pull/51 ## 2.1.2 - Remove string literals to prepare immutable strings in Ruby 3.0 ## 2.1.1 - Reorder the params for Windows Phone Authenticator - #43 ## 2.1.0 - Add a CLI for generating OTP's mdp/rotp/pull/35 ## 2.0.0 - Move to only comparing string OTP's. ## 1.7.0 - Move to only comparing string OTP's. See mdp/rotp/issues/32 - Moved to 2.0.0 - yanked from RubyGems ## 1.6.1 - Remove deprecation warning in Ruby 2.1.0 (@ylansegal) - Add Ruby 2.0 and 2.1 to Travis ## 1.6.0 - Add verify_with_retries to HOTP - Fix 'cgi' require and global DEFAULT_INTERVAL ## 1.5.0 - Add support for "issuer" parameter on provisioning url - Add support for "period/interval" parameter on provisioning url ## 1.4.6 - Revert to previous Base32 ## 1.4.5 - Fix and test correct implementation of Base32 ## 1.4.4 - Fix issue with base32 decoding of strings in a length that's not a multiple of 8 ## 1.4.3 - Bugfix on padding ## 1.4.2 - Better padding options (Pad the output with leading 0's) ## 1.4.1 - Clean up drift logic ## 1.4.0 - Added clock drift support via 'verify_with_drift' for TOTP ## 1.3.0 - Added support for Ruby 1.9.x - Removed dependency on Base32 rotp-6.3.0/spec/0000755000175000017500000000000014537202765013737 5ustar squigleysquigleyrotp-6.3.0/spec/spec_helper.rb0000644000175000017500000000051314537202765016554 0ustar squigleysquigleyrequire 'simplecov' SimpleCov.start do add_filter '/spec/' end require 'rotp' require 'timecop' RSpec.configure do |config| config.disable_monkey_patching! config.raise_errors_for_deprecations! config.color = true config.fail_fast = true config.before do Timecop.return end end require_relative '../lib/rotp' rotp-6.3.0/spec/lib/0000755000175000017500000000000014537202765014505 5ustar squigleysquigleyrotp-6.3.0/spec/lib/rotp/0000755000175000017500000000000014537202765015471 5ustar squigleysquigleyrotp-6.3.0/spec/lib/rotp/base32_spec.rb0000644000175000017500000000516714537202765020120 0ustar squigleysquigleyrequire 'spec_helper' RSpec.describe ROTP::Base32 do describe '.random' do context 'without arguments' do let(:base32) { ROTP::Base32.random } it 'is 20 bytes (160 bits) long (resulting in a 32 character base32 code)' do expect(ROTP::Base32.decode(base32).length).to eq 20 expect(base32.length).to eq 32 end it 'is base32 charset' do expect(base32).to match(/\A[A-Z2-7]+\z/) end end context 'with arguments' do let(:base32) { ROTP::Base32.random 48 } it 'returns the appropriate byte length code' do expect(ROTP::Base32.decode(base32).length).to eq 48 end end context 'alias to older random_base32' do let(:base32) { ROTP::Base32.random_base32(36) } it 'is base32 charset' do expect(base32.length).to eq 36 expect(ROTP::Base32.decode(base32).length).to eq 22 end end end describe '.decode' do context 'corrupt input data' do it 'raises a sane error' do expect { ROTP::Base32.decode('4BCDEFG234BCDEF1') }.to \ raise_error(ROTP::Base32::Base32Error, "Invalid Base32 Character - '1'") end end context 'valid input data' do it 'correctly decodes a string' do expect(ROTP::Base32.decode('2EB7C66WC5TSO').unpack('H*').first).to eq 'd103f17bd6176727' expect(ROTP::Base32.decode('Y6Y5ZCAC7NABCHSJ').unpack('H*').first).to eq 'c7b1dc8802fb40111e49' end it 'correctly decode strings with trailing bits (not a multiple of 8)' do # Dropbox style 26 characters (26*5==130 bits or 16.25 bytes, but chopped to 128) # Matches the behavior of Google Authenticator, drops extra 2 empty bits expect(ROTP::Base32.decode('YVT6Z2XF4BQJNBMTD7M6QBQCEM').unpack('H*').first).to eq 'c567eceae5e0609685931fd9e8060223' # For completeness, test all the possibilities allowed by Google Authenticator # Drop the incomplete empty extra 4 bits (28*5==140bits or 17.5 bytes, chopped to 136 bits) expect(ROTP::Base32.decode('5GGZQB3WN6LD7V3L5HPDYTQUANEQ').unpack('H*').first).to eq 'e98d9807766f963fd76be9de3c4e140349' end context 'with padding' do it 'correctly decodes a string' do expect(ROTP::Base32.decode('234A===').unpack('H*').first).to eq 'd6f8' end end end end describe '.encode' do context 'encode input data' do it 'correctly encodes data' do expect(ROTP::Base32.encode(hex_to_bin('3c204da94294ff82103ee34e96f74b48'))).to eq 'HQQE3KKCST7YEEB64NHJN52LJA' end end end end def hex_to_bin(s) s.scan(/../).map { |x| x.hex }.pack('c*') end rotp-6.3.0/spec/lib/rotp/hotp_spec.rb0000644000175000017500000001066614537202765020013 0ustar squigleysquigleyrequire 'spec_helper' RSpec.describe ROTP::HOTP do let(:counter) { 1234 } let(:token) { '161024' } let(:hotp) { ROTP::HOTP.new('a' * 32) } describe '#at' do let(:token) { hotp.at counter } context 'only the counter as argument' do it 'generates a string OTP' do expect(token).to eq '161024' end end context 'invalid counter' do it 'raises an error' do expect { hotp.at(-123_456) }.to raise_error(ArgumentError) end end context 'RFC compatibility' do let(:hotp) { ROTP::HOTP.new('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ') } it 'matches the RFC documentation examples' do # 12345678901234567890 in Base32 # GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ expect(hotp.at(0)).to eq '755224' expect(hotp.at(1)).to eq '287082' expect(hotp.at(2)).to eq '359152' expect(hotp.at(3)).to eq '969429' expect(hotp.at(4)).to eq '338314' expect(hotp.at(5)).to eq '254676' expect(hotp.at(6)).to eq '287922' expect(hotp.at(7)).to eq '162583' expect(hotp.at(8)).to eq '399871' expect(hotp.at(9)).to eq '520489' end end end describe '#verify' do let(:verification) { hotp.verify token, counter } context 'numeric token' do let(:token) { 161_024 } it 'raises an error' do expect { verification }.to raise_error(ArgumentError) end end context 'string token' do it 'is true' do expect(verification).to be_truthy end end context 'RFC compatibility' do let(:hotp) { ROTP::HOTP.new('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ') } let(:token) { '520489' } it 'verifies and does not allow reuse' do expect(hotp.verify(token, 9)).to be_truthy expect(hotp.verify(token, 10)).to be_falsey end end describe 'with retries' do let(:verification) { hotp.verify token, counter, retries: retries } context 'counter outside than retries' do let(:counter) { 1223 } let(:retries) { 10 } it 'is false' do expect(verification).to be_falsey end end context 'counter exactly in retry range' do let(:counter) { 1224 } let(:retries) { 10 } it 'is true' do expect(verification).to eq 1234 end end context 'counter in retry range' do let(:counter) { 1224 } let(:retries) { 11 } it 'is true' do expect(verification).to eq 1234 end end context 'counter ahead of token' do let(:counter) { 1235 } let(:retries) { 3 } it 'is false' do expect(verification).to be_falsey end end end end describe '#provisioning_uri' do let(:hotp) { ROTP::HOTP.new('a' * 32, name: "m@mdp.im") } let(:params) { CGI.parse URI.parse(uri).query } it 'created from the otp instance data' do expect(hotp.provisioning_uri()) .to eq 'otpauth://hotp/m%40mdp.im?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&counter=0' end it 'allow passing a name to override the OTP name' do expect(hotp.provisioning_uri('mark@percival')) .to eq 'otpauth://hotp/mark%40percival?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&counter=0' end it 'also accepts a custom counter value' do expect(hotp.provisioning_uri('mark@percival', 17)) .to eq 'otpauth://hotp/mark%40percival?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&counter=17' end context 'with non-standard provisioning_params' do let(:hotp) { ROTP::HOTP.new('a' * 32, digits: 8, provisioning_params: {image: 'https://example.com/icon.png'}) } let(:uri) { hotp.provisioning_uri("mark@percival") } it 'includes the issuer as parameter' do expect(params['image'].first).to eq 'https://example.com/icon.png' end end context "with an issuer" do let(:hotp) { ROTP::HOTP.new('a' * 32, name: "m@mdp.im", issuer: "Example.com") } it 'created from the otp instance data' do expect(hotp.provisioning_uri()) .to eq 'otpauth://hotp/Example.com:m%40mdp.im?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&issuer=Example.com&counter=0' end it 'allow passing a name to override the OTP name' do expect(hotp.provisioning_uri('mark@percival')) .to eq 'otpauth://hotp/Example.com:mark%40percival?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&issuer=Example.com&counter=0' end end end end rotp-6.3.0/spec/lib/rotp/arguments_spec.rb0000644000175000017500000000424114537202765021036 0ustar squigleysquigleyrequire 'spec_helper' require 'rotp/arguments' RSpec.describe ROTP::Arguments do let(:arguments) { described_class.new filename, argv } let(:argv) { '' } let(:filename) { 'rotp' } let(:options) { arguments.options } context 'without options' do describe '#help' do it 'shows the help text' do expect(arguments.to_s).to include 'Usage: ' end end describe '#options' do it 'has the default options' do expect(options.mode).to eq :time expect(options.secret).to be_nil expect(options.counter).to eq 0 end end end context 'unknown arguments' do let(:argv) { %w[--does-not-exist -xyz] } describe '#options' do it 'is in help mode' do expect(options.mode).to eq :help end it 'knows about the problem' do expect(options.warnings).to include 'invalid option: --does-not-exist' end end end context 'no arguments' do let(:argv) { [] } describe '#options' do it 'is in help mode' do expect(options.mode).to eq :help end end end context 'asking for help' do let(:argv) { %w[--help] } describe '#options' do it 'is in help mode' do expect(options.mode).to eq :help end end end context 'generating a counter based secret' do let(:argv) { %w[--hmac --secret s3same] } describe '#options' do it 'is in hmac mode' do expect(options.mode).to eq :hmac end it 'knows the secret' do expect(options.secret).to eq 's3same' end end end context 'generating a counter based secret' do let(:argv) { %w[--time --secret s3same] } describe '#options' do it 'is in hmac mode' do expect(options.mode).to eq :time end it 'knows the secret' do expect(options.secret).to eq 's3same' end end end context 'generating a time based secret' do let(:argv) { %w[--secret s3same] } describe '#options' do it 'is in time mode' do expect(options.mode).to eq :time end it 'knows the secret' do expect(options.secret).to eq 's3same' end end end end rotp-6.3.0/spec/lib/rotp/otp/0000755000175000017500000000000014537202765016273 5ustar squigleysquigleyrotp-6.3.0/spec/lib/rotp/otp/uri_spec.rb0000644000175000017500000001064014537202765020432 0ustar squigleysquigleyrequire 'spec_helper' RSpec.describe ROTP::OTP::URI do it 'meets basic functionality' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP') uri = described_class.new(otp, account_name: 'alice@google.com') expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP' end it 'includes issuer' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: 'Example') uri = described_class.new(otp, account_name: 'alice@google.com') expect(uri.to_s).to eq 'otpauth://totp/Example:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example' end it 'encodes the account name' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: 'Provider1') uri = described_class.new(otp, account_name: 'Alice Smith') expect(uri.to_s).to eq 'otpauth://totp/Provider1:Alice%20Smith?secret=JBSWY3DPEHPK3PXP&issuer=Provider1' end it 'encodes the issuer' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: 'Big Corporation') uri = described_class.new(otp, account_name: ' alice@bigco.com') expect(uri.to_s).to eq 'otpauth://totp/Big%20Corporation:%20alice%40bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big%20Corporation' end it 'includes non-default SHA256 algorithm' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', digest: 'sha256') uri = described_class.new(otp, account_name: 'alice@google.com') expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256' end it 'includes non-default SHA512 algorithm' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', digest: 'sha512') uri = described_class.new(otp, account_name: 'alice@google.com') expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512' end it 'includes non-default 8 digits' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', digits: 8) uri = described_class.new(otp, account_name: 'alice@google.com') expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&digits=8' end it 'includes non-default period for TOTP' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', interval: 35) uri = described_class.new(otp, account_name: 'alice@google.com') expect(uri.to_s).to eq 'otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&period=35' end it 'includes non-default counter for HOTP' do otp = ROTP::HOTP.new('JBSWY3DPEHPK3PXP') uri = described_class.new(otp, account_name: 'alice@google.com', counter: 17) expect(uri.to_s).to eq 'otpauth://hotp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&counter=17' end it 'can include all parameters' do otp = ROTP::TOTP.new( 'HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ', digest: 'sha512', digits: 8, interval: 60, issuer: 'ACME Co', ) uri = described_class.new(otp, account_name: 'john.doe@email.com') expect(uri.to_s).to eq'otpauth://totp/ACME%20Co:john.doe%40email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA512&digits=8&period=60' end it 'strips leading and trailing whitespace from the issuer' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: ' Big Corporation ') uri = described_class.new(otp, account_name: ' alice@bigco.com') expect(uri.to_s).to eq 'otpauth://totp/Big%20Corporation:%20alice%40bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big%20Corporation' end it 'strips trailing whitespace from the account name' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP') uri = described_class.new(otp, account_name: ' alice@google.com ') expect(uri.to_s).to eq 'otpauth://totp/%20%20alice%40google.com?secret=JBSWY3DPEHPK3PXP' end it 'replaces colons in the issuer with underscores' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP', issuer: 'Big:Corporation') uri = described_class.new(otp, account_name: 'alice@bigco.com') expect(uri.to_s).to eq 'otpauth://totp/Big_Corporation:alice%40bigco.com?secret=JBSWY3DPEHPK3PXP&issuer=Big_Corporation' end it 'replaces colons in the account name with underscores' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP') uri = described_class.new(otp, account_name: 'Alice:Smith') expect(uri.to_s).to eq 'otpauth://totp/Alice_Smith?secret=JBSWY3DPEHPK3PXP' end it 'handles email account names with sub-addressing' do otp = ROTP::TOTP.new('JBSWY3DPEHPK3PXP') uri = described_class.new(otp, account_name: 'alice+1234@google.com') expect(uri.to_s).to eq 'otpauth://totp/alice%2B1234%40google.com?secret=JBSWY3DPEHPK3PXP' end end rotp-6.3.0/spec/lib/rotp/totp_spec.rb0000644000175000017500000002145414537202765020024 0ustar squigleysquigleyrequire 'spec_helper' TEST_TIME = Time.utc 2016, 9, 23, 9 # 2016-09-23 09:00:00 UTC TEST_TOKEN = '082630'.freeze TEST_SECRET = 'JBSWY3DPEHPK3PXP' RSpec.describe ROTP::TOTP do let(:now) { TEST_TIME } let(:token) { TEST_TOKEN } let(:totp) { ROTP::TOTP.new TEST_SECRET } describe '#at' do let(:token) { totp.at now } it 'is a string number' do expect(token).to eq TEST_TOKEN end context 'RFC compatibility' do let(:totp) { ROTP::TOTP.new('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ') } it 'matches the RFC documentation examples' do expect(totp.at(1_111_111_111)).to eq '050471' expect(totp.at(1_234_567_890)).to eq '005924' expect(totp.at(2_000_000_000)).to eq '279037' end end end describe '#verify' do let(:verification) { totp.verify token, at: now } context 'numeric token' do let(:token) { 82_630 } it 'raises an error with an integer' do expect { verification }.to raise_error(ArgumentError) end end context 'unpadded string token' do let(:token) { '82630' } it 'fails to verify' do expect(verification).to be_falsey end end context 'correctly padded string token' do it 'verifies' do expect(verification).to be_truthy end end context 'RFC compatibility' do let(:totp) { ROTP::TOTP.new 'wrn3pqx5uqxqvnqr' } before do Timecop.freeze now end context 'correct time based OTP' do let(:token) { '102705' } let(:now) { Time.at 1_297_553_958 } it 'verifies' do expect(totp.verify('102705')).to be_truthy end end context 'wrong time based OTP' do it 'fails to verify' do expect(totp.verify('102705')).to be_falsey end end end context 'invalidating reused tokens' do let(:verification) do totp.verify token, after: after, at: now end let(:after) { nil } context 'passing in the `after` timestamp' do let(:after) do totp.verify TEST_TOKEN, after: nil, at: now end it 'returns a timecode' do expect(after).to be_kind_of(Integer) expect(after).to be_within(30).of(now.to_i) end context 'reusing same token' do it 'is false' do expect(verification).to be_falsy end end end end end def get_timecodes(at, b, a) # Test the private method totp.send('get_timecodes', at, b, a) end describe 'drifting timecodes' do it 'should get timecodes behind' do expect(get_timecodes(TEST_TIME + 15, 15, 0)).to eq([49_154_040]) expect(get_timecodes(TEST_TIME, 15, 0)).to eq([49_154_039, 49_154_040]) expect(get_timecodes(TEST_TIME, 40, 0)).to eq([49_154_038, 49_154_039, 49_154_040]) expect(get_timecodes(TEST_TIME, 90, 0)).to eq([49_154_037, 49_154_038, 49_154_039, 49_154_040]) end it 'should get timecodes ahead' do expect(get_timecodes(TEST_TIME, 0, 15)).to eq([49_154_040]) expect(get_timecodes(TEST_TIME + 15, 0, 15)).to eq([49_154_040, 49_154_041]) expect(get_timecodes(TEST_TIME, 0, 30)).to eq([49_154_040, 49_154_041]) expect(get_timecodes(TEST_TIME, 0, 70)).to eq([49_154_040, 49_154_041, 49_154_042]) expect(get_timecodes(TEST_TIME, 0, 90)).to eq([49_154_040, 49_154_041, 49_154_042, 49_154_043]) end it 'should get timecodes behind and ahead' do expect(get_timecodes(TEST_TIME, 30, 30)).to eq([49_154_039, 49_154_040, 49_154_041]) expect(get_timecodes(TEST_TIME, 60, 60)).to eq([49_154_038, 49_154_039, 49_154_040, 49_154_041, 49_154_042]) end end describe '#verify with drift' do let(:verification) { totp.verify token, drift_ahead: drift_ahead, drift_behind: drift_behind, at: now } let(:drift_ahead) { 0 } let(:drift_behind) { 0 } context 'with an old OTP' do let(:token) { totp.at TEST_TIME - 30 } # Previous token at 2016-09-23 08:59:30 UTC let(:drift_behind) { 15 } # Tested at 2016-09-23 09:00:00 UTC, and with drift back to 2016-09-23 08:59:45 UTC # This would therefore include 2 intervals it 'inside of drift range' do expect(verification).to be_truthy end # Tested at 2016-09-23 09:00:20 UTC, and with drift back to 2016-09-23 09:00:05 UTC # This only includes 1 interval, therefore only the current token is valid context 'outside of drift range' do let(:now) { TEST_TIME + 20 } it 'is nil' do expect(verification).to be_nil end end end context 'with a future OTP' do let(:token) { totp.at TEST_TIME + 30 } # The next valid token - 2016-09-23 09:00:30 UTC let(:drift_ahead) { 15 } # Tested at 2016-09-23 09:00:00 UTC, and ahead to 2016-09-23 09:00:15 UTC # This only includes 1 interval, therefore only the current token is valid it 'outside of drift range' do expect(verification).to be_falsey end # Tested at 2016-09-23 09:00:20 UTC, and with drift ahead to 2016-09-23 09:00:35 UTC # This would therefore include 2 intervals context 'inside of drift range' do let(:now) { TEST_TIME + 20 } it 'is true' do expect(verification).to be_truthy end end end end describe '#verify with drift and prevent token reuse' do let(:verification) { totp.verify token, drift_ahead: drift_ahead, drift_behind: drift_behind, after: after, at: now } let(:drift_ahead) { 0 } let(:drift_behind) { 0 } let(:after) { nil } context 'with the `after` timestamp set' do context 'older token' do let(:token) { totp.at TEST_TIME - 30 } let(:drift_behind) { 15 } it 'is true' do expect(verification).to be_truthy expect(verification).to eq((TEST_TIME - 30).to_i) end context 'after it has been used' do let(:after) do totp.verify token, after: nil, at: now, drift_behind: drift_behind end it 'is false' do expect(verification).to be_falsey end end end context 'newer token' do let(:token) { totp.at TEST_TIME + 30 } let(:drift_ahead) { 15 } let(:now) { TEST_TIME + 15 } it 'is true' do expect(verification).to be_truthy expect(verification).to eq((TEST_TIME + 30).to_i) end context 'after it has been used' do let(:after) do totp.verify token, after: nil, at: now, drift_ahead: drift_ahead end it 'is false' do expect(verification).to be_falsey end end end end end describe '#provisioning_uri' do let(:params) { CGI.parse URI.parse(uri).query } context "with a provided name on the TOTP instance" do let(:totp) { ROTP::TOTP.new(TEST_SECRET, name: "m@mdp.im") } it 'creates a provisioning uri from the OTP instance' do expect(totp.provisioning_uri()) .to eq 'otpauth://totp/m%40mdp.im?secret=JBSWY3DPEHPK3PXP' end it 'allow passing a name to override the OTP name' do expect(totp.provisioning_uri('mark@percival')) .to eq 'otpauth://totp/mark%40percival?secret=JBSWY3DPEHPK3PXP' end end context 'with non-standard provisioning_params' do let(:totp) { ROTP::TOTP.new(TEST_SECRET, provisioning_params: { image: 'https://example.com/icon.png' } ) } let(:uri) { totp.provisioning_uri("mark@percival") } it 'includes the issuer as parameter' do expect(params['image'].first).to eq 'https://example.com/icon.png' end end context "with an issuer" do let(:totp) { ROTP::TOTP.new(TEST_SECRET, name: "m@mdp.im", issuer: "Example.com") } it 'creates a provisioning uri from the OTP instance' do expect(totp.provisioning_uri()) .to eq 'otpauth://totp/Example.com:m%40mdp.im?secret=JBSWY3DPEHPK3PXP&issuer=Example.com' end it 'allow passing a name to override the OTP name' do expect(totp.provisioning_uri('mark@percival')) .to eq 'otpauth://totp/Example.com:mark%40percival?secret=JBSWY3DPEHPK3PXP&issuer=Example.com' end end end describe '#now' do before do Timecop.freeze now end context 'Google Authenticator' do let(:totp) { ROTP::TOTP.new 'wrn3pqx5uqxqvnqr' } let(:now) { Time.at 1_297_553_958 } it 'matches the known output' do expect(totp.now).to eq '102705' end end context 'Dropbox 26 char secret output' do let(:totp) { ROTP::TOTP.new 'tjtpqea6a42l56g5eym73go2oa' } let(:now) { Time.at 1_378_762_454 } it 'matches the known output' do expect(totp.now).to eq '747864' end end end end rotp-6.3.0/spec/lib/rotp/cli_spec.rb0000644000175000017500000000325514537202765017604 0ustar squigleysquigleyrequire 'spec_helper' require 'rotp/cli' RSpec.describe ROTP::CLI do let(:cli) { described_class.new('executable', argv) } let(:output) { cli.output } let(:now) { Time.utc 2012, 1, 1 } before do Timecop.freeze now end context 'generating a TOTP' do let(:argv) { %w[--secret JBSWY3DPEHPK3PXP] } it 'prints the corresponding token' do expect(output).to eq '068212' end end context 'generating a TOTP with sha256 digest' do let(:argv) { %w[--secret JBSWY3DPEHPK3PXP --digest sha256] } it 'prints the corresponding token' do expect(output).to eq '544902' end end context 'generating a TOTP with no secret' do let(:argv) { %w[--time --secret] } it 'prints the corresponding token' do expect(output).to match 'You must also specify a --secret' end end context 'generating a TOTP with bad base32 secret' do let(:argv) { %W[--time --secret #{'1' * 32}] } it 'prints the corresponding token' do expect(output).to match 'Secret must be in RFC4648 Base32 format' end end context 'trying to generate an unsupport type' do let(:argv) { %W[--notreal --secret #{'a' * 32}] } it 'prints the corresponding token' do expect(output).to match 'invalid option: --notreal' end end context 'generating a HOTP' do let(:argv) { %W[--hmac --secret #{'a' * 32} --counter 1234] } it 'prints the corresponding token' do expect(output).to eq '161024' end end context 'generating a HOTP' do let(:argv) { %W[--hmac --secret #{'a' * 32} --counter 1234 --digest sha256] } it 'prints the corresponding token' do expect(output).to eq '325941' end end end rotp-6.3.0/bin/0000755000175000017500000000000014537202765013555 5ustar squigleysquigleyrotp-6.3.0/bin/rotp0000755000175000017500000000023314537202765014465 0ustar squigleysquigley#!/usr/bin/env ruby $LOAD_PATH << File.expand_path('../lib', __dir__) require 'rotp' require 'rotp/cli' ROTP::CLI.new(File.basename(__FILE__), ARGV).run rotp-6.3.0/README.md0000644000175000017500000001640514537202765014272 0ustar squigleysquigley## Webauthn and the future of 2FA Although this library will continue to be maintained, if you're implementing a 2FA solution today, you should take a look at [Webauthn](https://webauthn.guide/). It doesn't involve shared secrets and it's supported by most modern browsers and operating systems. ### Ruby resources for Webauthn - [Multi-Factor Authentication for Rails With WebAuthn and Devise](https://www.honeybadger.io/blog/multi-factor-2fa-authentication-rails-webauthn-devise/) - [Webauthn Ruby Gem](https://github.com/cedarcode/webauthn-ruby) - [Rails demo app with Webauthn](https://github.com/cedarcode/webauthn-rails-demo-app) ---- # The Ruby One Time Password Library [![Build Status](https://github.com/mdp/rotp/actions/workflows/test.yaml/badge.svg)](https://github.com/mdp/rotp/actions/workflows/test.yaml) [![Gem Version](https://badge.fury.io/rb/rotp.svg)](https://rubygems.org/gems/rotp) [![Documentation](http://img.shields.io/badge/docs-rdoc.info-blue.svg)](https://www.rubydoc.info/github/mdp/rotp/master) [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/mdp/rotp/blob/master/LICENSE) A ruby library for generating and validating one time passwords (HOTP & TOTP) according to [RFC 4226](https://datatracker.ietf.org/doc/html/rfc4226) and [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238). ROTP is compatible with [Google Authenticator](https://github.com/google/google-authenticator) available for [Android](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) and [iPhone](https://itunes.apple.com/en/app/google-authenticator/id388497605) and any other TOTP based implementations. Many websites use this for [multi-factor authentication](https://www.youtube.com/watch?v=17rykTIX_HY), such as GMail, Facebook, Amazon EC2, WordPress, and Salesforce. You can find a more complete [list here](https://en.wikipedia.org/wiki/Google_Authenticator#Usage). ## Dependencies * OpenSSL * Ruby 2.3 or higher ## Breaking changes ### Breaking changes in >= 6.0 - Dropping support for Ruby <2.3 ### Breaking changes in >= 5.0 - `ROTP::Base32.random_base32` is now `ROTP::Base32.random` and the argument has changed from secret string length to byte length to allow for more precision. There is an alias to allow for `random_base32` for the time being. - Cleaned up the Base32 implementation to match Google Authenticator's version. ### Breaking changes in >= 4.0 - Simplified API - `verify` now takes options for `drift` and `after`,`padding` is no longer an option - `verify` returns a timestamp if true, nil if false - Dropping support for Ruby < 2.0 - Docs for 3.x can be found [here](https://github.com/mdp/rotp/tree/v3.x) ## Installation ```bash gem install rotp ``` ## Library Usage ### Time based OTP's ```ruby totp = ROTP::TOTP.new("base32secret3232", issuer: "My Service") totp.now # => "492039" # OTP verified for current time - returns timestamp of the current interval # period. totp.verify("492039") # => 1474590700 sleep 30 # OTP fails to verify - returns nil totp.verify("492039") # => nil ``` ### Counter based OTP's ```ruby hotp = ROTP::HOTP.new("base32secretkey3232") hotp.at(0) # => "786922" hotp.at(1) # => "595254" hotp.at(1401) # => "259769" # OTP verified with a counter hotp.verify("259769", 1401) # => 1401 hotp.verify("259769", 1402) # => nil ``` ### Preventing reuse of Time based OTP's By keeping track of the last time a user's OTP was verified, we can prevent token reuse during the interval window (default 30 seconds) The following is an example of this in action: ```ruby user = User.find(someUserID) totp = ROTP::TOTP.new(user.otp_secret) totp.now # => "492039" # Let's take a look at the last time the user authenticated with an OTP user.last_otp_at # => 1432703530 # Verify the OTP last_otp_at = totp.verify("492039", after: user.last_otp_at) #=> 1472145760 # ROTP returns the timestamp(int) of the current period # Store this on the user's account user.update(last_otp_at: last_otp_at) # Someone attempts to reuse the OTP inside the 30s window last_otp_at = totp.verify("492039", after: user.last_otp_at) #=> nil # It fails to verify because we are still in the same 30s interval window ``` ### Verifying a Time based OTP with drift Some users may enter a code just after it has expired. By adding 'drift' you can allow for a recently expired token to remain valid. ```ruby totp = ROTP::TOTP.new("base32secret3232") now = Time.at(1474590600) #2016-09-23 00:30:00 UTC totp.at(now) # => "250939" # OTP verified for current time along with 15 seconds earlier # ie. User enters a code just after it expired totp.verify("250939", drift_behind: 15, at: now + 35) # => 1474590600 # User waits too long. Fails to validate previous OTP totp.verify("250939", drift_behind: 15, at: now + 45) # => nil ``` ### Generating a Base32 Secret key ```ruby ROTP::Base32.random # returns a 160 bit (32 character) base32 secret. Compatible with Google Authenticator ``` Note: The Base32 format conforms to [RFC 4648 Base32](http://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet) ### Generating QR Codes for provisioning mobile apps Provisioning URI's generated by ROTP are compatible with most One Time Password applications, including Google Authenticator. ```ruby totp = ROTP::TOTP.new("base32secret3232", issuer: "My Service") totp.provisioning_uri("alice@google.com") # => 'otpauth://totp/My%20Service:alice%40google.com?secret=base32secret3232&issuer=My%20Service' hotp = ROTP::HOTP.new("base32secret3232", issuer: "My Service") hotp.provisioning_uri("alice@google.com", 0) # => 'otpauth://hotp/My%20Service:alice%40google.com?secret=base32secret3232&issuer=My%20Service&counter=0' ``` This can then be rendered as a QR Code which the user can scan using their mobile phone and the appropriate application. #### Working example Scan the following barcode with your phone, using Google Authenticator ![QR Code for OTP](https://cloud.githubusercontent.com/assets/2868/18771262/54f109dc-80f2-11e6-863f-d2be62ee587a.png) Now run the following and compare the output ```ruby require 'rubygems' require 'rotp' totp = ROTP::TOTP.new("JBSWY3DPEHPK3PXP") p "Current OTP: #{totp.now}" ``` ### Testing ```bash bundle install bundle exec rspec ``` ### Testing with Docker In order to make it easier to test against different ruby version, ROTP comes with a set of Dockerfiles for each version that we test against in Travis ```bash docker build -f Dockerfile-2.6 -t rotp_2.6 . docker run --rm -v $(pwd):/usr/src/app rotp_2.6 ``` Alternately, you may use docker-compose to run all the tests: ``` docker-compose up ``` ## Executable Usage The rotp rubygem includes CLI version to help with testing and debugging ```bash # Try this to get an overview of the commands rotp --help # Examples rotp --secret p4ssword # Generates a time-based one-time password rotp --hmac --secret p4ssword --counter 42 # Generates a counter-based one-time password ``` ## Contributors Have a look at the [contributors graph](https://github.com/mdp/rotp/graphs/contributors) on Github. ## License MIT Copyright (C) 2019 by Mark Percival, see [LICENSE](https://github.com/mdp/rotp/blob/master/LICENSE) for details. ## Other implementations A list can be found at [Wikipedia](https://en.wikipedia.org/wiki/Google_Authenticator#Implementations). rotp-6.3.0/.devcontainer/0000755000175000017500000000000014537202765015544 5ustar squigleysquigleyrotp-6.3.0/.devcontainer/Dockerfile0000644000175000017500000000220614537202765017536 0ustar squigleysquigley# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.238.1/containers/ruby/.devcontainer/base.Dockerfile # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.1, 3.0, 2, 2.7, 3-bullseye, 3.1-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 3-buster, 3.1-buster, 3.0-buster, 2-buster, 2.7-buster ARG VARIANT="3.1-bullseye" FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 ARG NODE_VERSION="none" RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi # [Optional] Uncomment this section to install additional OS packages. # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ # && apt-get -y install --no-install-recommends # [Optional] Uncomment this line to install additional gems. # RUN gem install # [Optional] Uncomment this line to install global node packages. # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1rotp-6.3.0/.devcontainer/devcontainer.json0000644000175000017500000000237214537202765021124 0ustar squigleysquigley// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.238.1/containers/ruby { "name": "Ruby", "build": { "dockerfile": "Dockerfile", "args": { // Update 'VARIANT' to pick a Ruby version: 3, 3.1, 3.0, 2, 2.7 // Append -bullseye or -buster to pin to an OS version. // Use -bullseye variants on local on arm64/Apple Silicon. "VARIANT": "3-bullseye", // Options "NODE_VERSION": "16" } }, // Configure tool-specific properties. "customizations": { // Configure properties specific to VS Code. "vscode": { // Add the IDs of extensions you want installed when the container is created. "extensions": [ "rebornix.Ruby" ] } }, // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. // "postCreateCommand": "ruby --version", // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode", "features": { "ghcr.io/devcontainers-contrib/features/act:1": {}, "ghcr.io/devcontainers/features/docker-in-docker:2": {} } }