diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..83e16f8 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--color +--require spec_helper diff --git a/README.md b/README.md index 160d839..7c289a6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ which is a 'zero-knowledge' mutual authentication system. SRP is an protocol that allows for mutual authentication of a client and server over an insecure network connection without revealing the password to the -server or an evesdropper. If the client lacks the user's password, or the server +server or an eavesdropper. If the client lacks the user's password, or the server lacks the proper verification key, the authentication will fail. This approach is much more secure than the vast majority of authentication systems in common use since the password is never sent over the wire. The password is impossible to @@ -137,7 +137,7 @@ this verification at [https://www.rempe.us/keys/](https://www.rempe.us/keys/). This implementation has been tested for compatibility with the following SRP-6a compliant third-party libraries: -[JSRP / JavaScript](https://github.com/alax/jsrp) +[grempe/jsrp (JavaScript)](https://github.com/grempe/jsrp) ## SRP-6a Protocol Design @@ -203,6 +203,62 @@ The two parties also employ the following safeguards: user's proof is incorrect, it must abort without showing its own proof of K. ``` +### Implementation Decisions + +The interoperability of different implementations of SRP is elusive. The spec +leaves a number of decisions up to the implementer. The choice of hashing +algorithm (H) is left open, the method of verifying shared keys (H_AMK) is +not clearly specified, and the generation of the Verifier (v) is not considered +very strong by modern standards. + +It is also not specified how the client and server should exchange information +over the wire (binary, hex, protobuf, etc). + +It is therefore no wonder that most implementations don't work together. + +This library has also made its own choices. This implementation provides Ruby +code that makes a choice for strength where possible. This code is suitable for +use as both a Ruby client and Ruby SRP server. There is also a JavaScript +client based on the work of alax/jsrp which has been modified to be compatible. + +It is unlikely that any other implementation will just work out of the box. No +support is provided for any other implementations not listed here. + +#### Hashing Algorithm + +The hashing algorithm used internally is either `SHA1` or `SHA256`. Only group sizes +`1024` and `1536` use `SHA1` for legacy support, and the rest will use `SHA256`. + +This matches the choices made in the `jsrp` package. + +#### Calculating `x` + +The derivation of the private key `x` has been strengthened in this +implementation and makes use of SHA256, HMAC-SHA256, and Scrypt. Scrypt +is a modern memory and CPU hard key derivation function and is used +to protect against the possibility of a brute-force attack on the Verifier. +See the `calc_x` method in `lib/sirp/sirp.rb` for details. + +#### Proof of `K` + +According to the Wikipaedia page for Secure Remote Password implementations will +often choose different methods to prove that the client and server have both +negotiatied the same keys. + +``` +Carol → Steve: M1 = H[H(N) XOR H(g) | H(I) | s | A | B | KCarol]. Steve verifies M1. +Steve → Carol: M2 = H(A | M1 | KSteve). Carol verifies M2. +``` + +and + +``` +Carol → Steve: M1 = H(A | B | SCarol). Steve verifies M1. +Steve → Carol: M2 = H(A | M1 | SSteve). Carol verifies M2. +``` + +This implementation makes use of the second method. See `SIRP.calc_H_AMK`. + ## Usage Example In this example the client and server steps are interleaved for demonstration diff --git a/bin/console b/bin/console index d760260..164456a 100755 --- a/bin/console +++ b/bin/console @@ -1,14 +1,16 @@ #!/usr/bin/env ruby require 'bundler/setup' -require 'sirp' +require 'sirp/all' # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. # (If you use this, don't forget to add pry to your Gemfile!) -require 'pry' -Pry.start - -require 'irb' -IRB.start +begin + require 'pry' + Pry.start +rescue + require 'irb' + IRB.start +end diff --git a/lib/sirp.rb b/lib/sirp.rb index 6531833..fa129b8 100644 --- a/lib/sirp.rb +++ b/lib/sirp.rb @@ -1,9 +1,14 @@ -require 'openssl' -require 'digest' +require 'digest/sha2' require 'rbnacl/libsodium' -require 'securer_randomer' -require 'sirp/sirp' +require 'rbnacl' +require 'openssl' + +module SIRP + SafetyCheckError = Class.new(StandardError) +end + +require 'sirp/utils' require 'sirp/parameters' -require 'sirp/client' -require 'sirp/verifier' +require 'sirp/backend' + require 'sirp/version' diff --git a/lib/sirp/all.rb b/lib/sirp/all.rb new file mode 100644 index 0000000..4ed869c --- /dev/null +++ b/lib/sirp/all.rb @@ -0,0 +1,5 @@ +require 'sirp/server' +require 'sirp/register' +require 'sirp/client' + +require 'sirp/backend/scrypt_hmac' diff --git a/lib/sirp/backend.rb b/lib/sirp/backend.rb new file mode 100644 index 0000000..39fc872 --- /dev/null +++ b/lib/sirp/backend.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'contracts' + +module SIRP + class Backend + include Contracts::Core + include Contracts::Builtin + + attr_reader :prime + + def initialize(group=Prime[2048], hash=Digest::SHA256) + @prime = group + @hash = hash + end + + # Modular Exponentiation + # https://en.m.wikipedia.org/wiki/Modular_exponentiation + # http://rosettacode.org/wiki/Modular_exponentiation#Ruby + # + # a^b (mod m) + # + # @param a [Integer] the base value as an Integer, depending on size + # @param b [Integer] the exponent value as an Integer + # @param m [Integer] the modulus value as an Integer + # @return [Integer] the solution as an Integer + Contract Integer, Nat, Nat => Integer + def mod_pow(a, b, m) + # Convert type and use OpenSSL::BN#mod_exp to do the calculation + # Convert back to an Integer so OpenSSL::BN doesn't leak everywhere + a.to_bn.mod_exp(b, m).to_i + end + + # One-Way Hash Function + # + # @param a [Array] the Array of values to be hashed together + # @return [Integer] the hexdigest as an Integer + Contract ArrayOf[Or[String, Nat]] => Integer + def H(a) + hasher = @hash.new + + a.compact.map do |v| + xv = v.is_a?(String) ? v : Utils.num_to_hex(v) + hasher.update(xv.downcase) + end + + digest = hasher.hexdigest + digest.hex + end + + # Multiplier Parameter + # k = H(N, g) (in SRP-6a) + # + # @return [Integer] the 'k' value as an Integer + Contract None => Integer + def k + @k ||= H([prime.N, prime.g].map(&:to_s)) + end + + # Abstract + # Private Key (derived from username, password and salt) + # + # The spec calls for calculating 'x' using: + # + # x = H(salt || H(username || ':' || password)) + # + # @param username [String] the 'username' (I) as a String + # @param password [String] the 'password' (p) as a String + # @param salt [String] the 'salt' in hex + # @return [Integer] the Scrypt+HMAC stretched 'x' value as an Integer + Contract String, String, String => Integer + def calc_x(username, password, salt) + fail NotImplementedError + end + + # Random Scrambling Parameter + # u = H(A, B) + # + # @param xaa [String] the 'A' value in hex + # @param xbb [String] the 'B' value in hex + # @return [Integer] the 'u' value as an Integer + Contract String, String => Integer + def calc_u(xaa, xbb) + u = H([xaa, xbb]) + + u.zero? ? fail(SafetyCheckError, 'u cannot equal 0') : u + end + + # Password Verifier + # v = g^x (mod N) + # + # @param x [Integer] the 'x' value as an Integer + # @return [Integer] the client 'v' value as an Integer + Contract Integer => Integer + def calc_v(x) + mod_pow(prime.g, x, prime.N) + end + + # Client Ephemeral Value + # A = g^a (mod N) + # + # @param a [Integer] the 'a' value as an Integer + # @return [Integer] the client ephemeral 'A' value as an Integer + Contract Integer, Integer, Integer => Integer + def calc_A(a) + mod_pow(prime.g, a, prime.N) + end + + # Server Ephemeral Value + # B = kv + g^b % N + # + # @param b [Integer] the 'b' value as an Integer + # @param v [Integer] the 'v' value as an Integer + # @return [Integer] the verifier ephemeral 'B' value as an Integer + Contract Integer, Integer => Integer + def calc_B(b, v) + (k * v + mod_pow(prime.g, b, prime.N)) % prime.N + end + + # Client Session Key + # S = (B - (k * g^x)) ^ (a + (u * x)) % N + # + # @param bb [Integer] the 'B' value as an Integer + # @param a [Integer] the 'a' value as an Integer + # @param x [Integer] the 'x' value as an Integer + # @param u [Integer] the 'u' value as an Integer + # @return [Integer] the client 'S' value as an Integer + Contract Integer, Integer, Integer, Integer, Integer => Integer + def calc_client_S(bb, a, x, u) + mod_pow((bb - k * mod_pow(prime.g, x, prime.N)), a + u * x, prime.N) + end + + # Server Session Key + # S = (A * v^u) ^ b % N + # + # @param aa [Integer] the 'A' value as a String + # @param b [Integer] the 'b' value as an Integer + # @param v [Integer] the 'v' value as an Integer + # @param u [Integer] the 'u' value as an Integer + # @return [Integer] the verifier 'S' value as an Integer + Contract String, Integer, Integer, Integer => Integer + def calc_server_S(aa, b, v, u) + mod_pow(aa.to_i(16) * mod_pow(v, u, prime.N), b, prime.N) + end + + # M = H( H(N) XOR H(g), H(I), s, A, B, K) + # @param username [String] plain username + # @param xsalt [String] salt value in hex + # @param xaa [String] the 'A' value in hex + # @param xbb [String] the 'B' value in hex + # @param xkk [String] the 'K' value in hex + # @return [String] the 'M' value in hex + Contract String, String, String, String, String => String + def calc_M(username, xsalt, xaa, xbb, xkk) + hn = @hash.hexdigest(prime.N.to_s) + hg = @hash.hexdigest(prime.g.to_s) + hxor = hn.to_i(16) ^ hg.to_i(16) + hi = @hash.hexdigest(username) + Utils.num_to_hex(H([[hxor, hi.to_i(16), xsalt, xaa.to_i(16), xbb.to_i(16), xkk].map(&:to_s).join])) + end + + # K = H(S) + # + # @param ss [Integer] the 'S' value as an Integer + # @return [String] the 'K' value in hex + Contract String => String + def calc_K(ss) + @hash.hexdigest(ss.to_i(16).to_s) + end + + # H(A, M, K) + # + # @param xaa [String] the 'A' value in hex + # @param xmm [String] the 'M' value in hex + # @param xkk [String] the 'K' value in hex + # @return [String] the 'H_AMK' value in hex + Contract String, String, String => String + def calc_H_AMK(xaa, xmm, xkk) + @hash.hexdigest(xaa.to_i(16).to_s + xmm + xkk) + end + + # Client Ephemeral Value + # A = g^a (mod N) + # + # @param a [Bignum] the 'a' value as a Bignum + # @return [Bignum] the client ephemeral 'A' value as a Bignum + Contract Bignum => Bignum + def calc_A(a) + mod_pow(prime.g, a, prime.N) + end + end +end diff --git a/lib/sirp/backend/digest.rb b/lib/sirp/backend/digest.rb new file mode 100644 index 0000000..49f1677 --- /dev/null +++ b/lib/sirp/backend/digest.rb @@ -0,0 +1,21 @@ +module SIRP + class Backend + class Digest < self + + # Private Key (derived from username, password and salt) + # + # x = H(salt || H(username || ':' || password)) + # + # @param username [String] the 'username' (I) as a String + # @param password [String] the 'password' (p) as a String + # @param salt [String] the 'salt' in hex + # @return [Integer] the Scrypt+HMAC stretched 'x' value as an Integer + Contract String, String, String => Integer + def calc_x(username, password, salt) + spad = salt.length.odd? ? '0' : '' + h = spad + salt + @hash.hexdigest([username, password].join(':')) + @hash.hexdigest([h].pack('H*')).hex + end + end + end +end diff --git a/lib/sirp/backend/scrypt_hmac.rb b/lib/sirp/backend/scrypt_hmac.rb new file mode 100644 index 0000000..7d0e3da --- /dev/null +++ b/lib/sirp/backend/scrypt_hmac.rb @@ -0,0 +1,58 @@ +module SIRP + class Backend + class SCryptHMAC < self + # Private Key (derived from username, password and salt) + # + # The spec calls for calculating 'x' using: + # + # x = H(salt || H(username || ':' || password)) + # + # However, this can be greatly strengthened against attacks + # on the verififier. The specified scheme requires only brute + # forcing 2x SHA1 or SHA256 hashes and a modular exponentiation. + # + # The implementation that follows is based on extensive discussion with + # Dmitry Chestnykh (@dchest). This approach is also informed by + # the security audit done on the Spider Oak crypton.io project which + # can be viewed at the link below and talks about the weaknesses in the + # original SRP spec when considering brute force attacks on the verifier. + # + # Security Audit : Page 12: + # https://web.archive.org/web/20150403175113/http://www.leviathansecurity.com/wp-content/uploads/SpiderOak-Crypton_pentest-Final_report_u.pdf + # + # This strengthened version uses SHA256 and HMAC_SHA256 in concert + # with the scrypt memory and CPU hard key stretching algorithm to + # derive a much stronger 'x' value. Since the verifier is directly + # derived from 'x' using Modular Exponentiation this makes brute force + # attack much less likely. The new algorithm is: + # + # prehash_pw = HMAC_SHA256('srp-x-1', password) + # int_key = scrypt(prehash_pw, salt, ...) + # HMAC_SHA256('srp-x-2', int_key + username) + # + # The scrypt values equate to the 'interactive' use constants in libsodium. + # The values given to the RbNaCl::PasswordHash.scrypt can be converted for use + # with https://github.com/dchest/scrypt-async-js using the following conversions: + # + # + # CPU/memory cost parameters + # Conversion from RbNaCl / libsodium and scrypt-async-js + # SCRYPT_OPSLIMIT_INTERACTIVE == 2**19 == (2**24 / 32) == 524288 == logN 14 + # SCRYPT_OPSLIMIT_SENSITIVE == 2**25 == (2**30 / 32) == 33554432 == logN 20 + # + # The value returned should be the final HMAC_SHA256 hex converted to an Integer + # + # @param username [String] the 'username' (I) as a String + # @param password [String] the 'password' (p) as a String + # @param salt [String] the 'salt' in hex + # @return [Integer] the Scrypt+HMAC stretched 'x' value as an Integer + Contract String, String, String => Integer + def calc_x(username, password, salt) + prehash_pw = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), 'srp-x-1', password) + int_key = RbNaCl::PasswordHash.scrypt(prehash_pw, salt.force_encoding('BINARY'), 2**19, 2**24, 32).each_byte.map { |b| b.to_s(16) }.join + x_hex = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), 'srp-x-2', int_key + username) + x_hex.hex + end + end + end +end diff --git a/lib/sirp/client.rb b/lib/sirp/client.rb index 3434c7b..7c3ce67 100644 --- a/lib/sirp/client.rb +++ b/lib/sirp/client.rb @@ -1,96 +1,65 @@ +require 'sirp' + module SIRP class Client - include SIRP - attr_reader :N, :g, :k, :a, :A, :S, :K, :M, :H_AMK, :hash - - # Select modulus (N), generator (g), and one-way hash function (SHA1 or SHA256) - # - # @param group [Integer] the group size in bits - def initialize(group = 2048) - raise ArgumentError, 'must be an Integer' unless group.is_a?(Integer) - raise ArgumentError, 'must be a known group size' unless [1024, 1536, 2048, 3072, 4096, 6144, 8192].include?(group) - - @N, @g, @hash = Ng(group) - @k = calc_k(@N, @g, hash) + attr_reader :backend + + def initialize(group=Prime[2048], hash=Digest::SHA256, backend_cls=Backend::SCryptHMAC) + @backend = backend_cls.new(group, hash) + + @a = RbNaCl::Util.bin2hex(RbNaCl::Random.random_bytes(32)).hex + @A = Utils.num_to_hex(backend.calc_A(@a)) end - # Phase 1 : Step 1 : Start the authentication process by generating the - # client 'a' and 'A' values. Public 'A' should later be sent along with - # the username, to the server verifier to continue the auth process. The - # internal secret 'a' value should remain private. - # - # @return [String] the value of 'A' in hex - def start_authentication - @a ||= SecureRandom.hex(32).hex - @A = num_to_hex(calc_A(@a, @N, @g)) + def start + @A end - # - # Phase 1 : Step 2 : See Verifier#get_challenge_and_proof(username, xverifier, xsalt, xaa) - # + def authenticate(username, password, challenge_attrs) + challenge = Utils.symbolize_keys(challenge_attrs) - # Phase 2 : Step 1 : Process the salt and B values provided by the server. - # - # @param username [String] the client provided authentication username - # @param password [String] the client provided authentication password - # @param xsalt [String] the server provided salt for the username in hex - # @param xbb [String] the server verifier 'B' value in hex - # @return [String] the client 'M' value in hex - def process_challenge(username, password, xsalt, xbb) - raise ArgumentError, 'username must be a string' unless username.is_a?(String) && !username.empty? - raise ArgumentError, 'password must be a string' unless password.is_a?(String) && !password.empty? - raise ArgumentError, 'xsalt must be a string' unless xsalt.is_a?(String) - raise ArgumentError, 'xsalt must be a hex string' unless xsalt =~ /^[a-fA-F0-9]+$/ - raise ArgumentError, 'xbb must be a string' unless xbb.is_a?(String) - raise ArgumentError, 'xbb must be a hex string' unless xbb =~ /^[a-fA-F0-9]+$/ + @username = username + @password = password - # Convert the 'B' hex value to an Integer - bb = xbb.to_i(16) + @salt = challenge[:salt] + @B = challenge[:B] - # SRP-6a safety check - return false if (bb % @N).zero? + validate_params! - x = calc_x(username, password, xsalt, hash) - u = calc_u(@A, xbb, @N, hash) + @K = backend.calc_K(calc_S) + @M = backend.calc_M(username, @salt, @A, @B, @K) - # SRP-6a safety check - return false if u.zero? + # Calculate the H(A,M,K) verifier + @H_AMK = backend.calc_H_AMK(@A, @M, @K) - # Calculate session key 'S' and secret key 'K' - @S = num_to_hex(calc_client_S(bb, @a, @k, x, u, @N, @g)) - @K = sha_hex(@S, hash) + @M + end - # Calculate the 'M' matcher - @M = calc_M(@A, xbb, @K, hash) + def verify(server_H_AMK) + return false unless @H_AMK + return false unless Utils.hex_str?(server_H_AMK) - # Calculate the H(A,M,K) verifier - @H_AMK = num_to_hex(calc_H_AMK(@A, @M, @K, hash)) + Utils.secure_compare(@H_AMK, server_H_AMK) + end - # Return the 'M' matcher to be sent to the server - @M + private + + def validate_params! + fail ArgumentError, 'username must not be an empty string' if Utils.empty?(@username) + fail ArgumentError, 'password must not be an empty string' if Utils.empty?(@password) + fail ArgumentError, 'salt must be a hex string' unless Utils.hex_str?(@salt) + fail ArgumentError, '"B" must be a hex string' unless Utils.hex_str?(@B) + + fail SafetyCheckError, 'B % N cannot equal 0' if (@B.to_i(16) % backend.prime.N).zero? end - # - # Phase 2 : Step 2 : See Verifier#verify_session(proof, client_M) - # - - # Phase 2 : Step 3 : Verify that the server provided H(A,M,K) value - # matches the client generated version. This is the last step of mutual - # authentication and confirms that the client and server have - # completed the auth process. The comparison of local and server - # H_AMK values is done using a secure constant-time comparison - # method so as not to leak information. - # - # @param server_HAMK [String] the server provided H_AMK in hex - # @return [true,false] returns true if the server and client agree on the H_AMK value, false if not - def verify(server_HAMK) - return false unless @H_AMK && server_HAMK - return false unless server_HAMK.is_a?(String) - return false unless server_HAMK =~ /^[a-fA-F0-9]+$/ - - # Hash the comparison params to ensure that both strings - # being compared are equal length 32 Byte strings. - secure_compare(Digest::SHA256.hexdigest(@H_AMK), Digest::SHA256.hexdigest(server_HAMK)) + def calc_S + x = backend.calc_x(@username, @password, @salt) + u = backend.calc_u(@A, @B) + + fail SafetyCheckError, 'u cannot equal 0' if u.zero? + + Utils.num_to_hex(backend.calc_client_S(@B.to_i(16), @a, x, u)) end end end diff --git a/lib/sirp/parameters.rb b/lib/sirp/parameters.rb index 204c741..3633000 100644 --- a/lib/sirp/parameters.rb +++ b/lib/sirp/parameters.rb @@ -1,19 +1,40 @@ +# frozen_string_literal: true + module SIRP - def Ng(group) - case group - when 1024 - @N = %w( + class Prime + @primes = {} + + def self.[](prime_length) + @primes[prime_length] or raise(ArgumentError, 'must be a known group size') + end + + attr_reader :prime_length, :N, :g + + private + + def initialize(prime_length, g, nn) + @prime_length = prime_length + @g = g + @N = nn.join.hex + freeze + end + + @primes[1024] = self.new( + 1024, + 2, + %w( EEAF0AB9 ADB38DD6 9C33F80A FA8FC5E8 60726187 75FF3C0B 9EA2314C 9C256576 D674DF74 96EA81D3 383B4813 D692C6E0 E0D5D8E2 50B98BE4 8E495C1D 6089DAD1 5DC7D7B4 6154D6B6 CE8EF4AD 69B15D49 82559B29 7BCF1885 C529F566 660E57EC 68EDBC3C 05726CC0 2FD4CBF4 976EAA9A FD5138FE 8376435B 9FC61D2F C0EB06E3 - ).join.hex - @g = 2 - @hash = Digest::SHA1 + ) + ) - when 1536 - @N = %w( + @primes[1536] = self.new( + 1536, + 2, + %w( 9DEF3CAF B939277A B1F12A86 17A47BBB DBA51DF4 99AC4C80 BEEEA961 4B19CC4D 5F4F5F55 6E27CBDE 51C6A94B E4607A29 1558903B A0D0F843 80B655BB 9A22E8DC DF028A7C EC67F0D0 8134B1C8 B9798914 9B609E0B @@ -21,12 +42,13 @@ def Ng(group) 6EDF0195 39349627 DB2FD53D 24B7C486 65772E43 7D6C7F8C E442734A F7CCB7AE 837C264A E3A9BEB8 7F8A2FE9 B8B5292E 5A021FFF 5E91479E 8CE7A28C 2442C6F3 15180F93 499A234D CF76E3FE D135F9BB - ).join.hex - @g = 2 - @hash = Digest::SHA1 + ) + ) - when 2048 - @N = %w( + @primes[2048] = self.new( + 2048, + 2, + %w( AC6BDB41 324A9A9B F166DE5E 1389582F AF72B665 1987EE07 FC319294 3DB56050 A37329CB B4A099ED 8193E075 7767A13D D52312AB 4B03310D CD7F48A9 DA04FD50 E8083969 EDB767B0 CF609517 9A163AB3 661A05FB @@ -37,12 +59,13 @@ def Ng(group) 03CE5329 9CCC041C 7BC308D8 2A5698F3 A8D0C382 71AE35F8 E9DBFBB6 94B5C803 D89F7AE4 35DE236D 525F5475 9B65E372 FCD68EF2 0FA7111F 9E4AFF73 - ).join.hex - @g = 2 - @hash = Digest::SHA256 + ) + ) - when 3072 - @N = %w( + @primes[3072] = self.new( + 3072, + 5, + %w( FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 @@ -57,12 +80,13 @@ def Ng(group) 1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF - ).join.hex - @g = 5 - @hash = Digest::SHA256 + ) + ) - when 4096 - @N = %w( + @primes[4096] = self.new( + 4096, + 5, + %w( FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 @@ -82,12 +106,13 @@ def Ng(group) 233BA186 515BE7ED 1F612970 CEE2D7AF B81BDD76 2170481C D0069127 D5B05AA9 93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34063199 FFFFFFFF FFFFFFFF - ).join.hex - @g = 5 - @hash = Digest::SHA256 + ) + ) - when 6144 - @N = %w( + @primes[6144] = self.new( + 6144, + 5, + %w( FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 @@ -116,12 +141,13 @@ def Ng(group) B7C5DA76 F550AA3D 8A1FBFF0 EB19CCB1 A313D55C DA56C9EC 2EF29632 387FE8D7 6E3C0468 043E8F66 3F4860EE 12BF2D5B 0B7474D6 E694F91E 6DCC4024 FFFFFFFF FFFFFFFF - ).join.hex - @g = 5 - @hash = Digest::SHA256 + ) + ) - when 8192 - @N = %w( + @primes[8192] = self.new( + 8192, + 19, + %w( FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 @@ -159,13 +185,9 @@ def Ng(group) 359046F4 EB879F92 4009438B 481C6CD7 889A002E D5EE382B C9190DA6 FC026E47 9558E447 5677E9AA 9E3050E2 765694DF C81F56E8 80B96E71 60C980DD 98EDD3DF FFFFFFFF FFFFFFFF - ).join.hex - @g = 19 - @hash = Digest::SHA256 - else - raise ArgumentError, 'must be a known group size' - end + ) + ) - [@N, @g, @hash] + @primes.freeze end end diff --git a/lib/sirp/register.rb b/lib/sirp/register.rb new file mode 100644 index 0000000..d3d37b7 --- /dev/null +++ b/lib/sirp/register.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'sirp' + +module SIRP + class Register + def initialize(username, password, group=Prime[2048], hash=Digest::SHA256, backend_cls=Backend::SCryptHMAC) + @backend = backend_cls.new(group, hash) + + # TODO: truncate values + @username = username + @password = password + + validate_params! + + @salt = generate_salt + x = @backend.calc_x(@username, @password, @salt) + @v = Utils.num_to_hex(@backend.calc_v(x)) + end + + def credentials + { username: @username, verifier: @v, salt: @salt } + end + + private + + def validate_params! + fail ArgumentError, 'username must not be an empty string' if Utils.empty?(@username) + fail ArgumentError, 'password must not be an empty string' if Utils.empty?(@password) + end + + def generate_salt + RbNaCl::Util.bin2hex(RbNaCl::Random.random_bytes(16)) + end + end +end diff --git a/lib/sirp/server.rb b/lib/sirp/server.rb new file mode 100644 index 0000000..84bceed --- /dev/null +++ b/lib/sirp/server.rb @@ -0,0 +1,9 @@ +require 'sirp' +require 'sirp/server/start' +require 'sirp/server/finish' + +module SIRP + class Server + + end +end diff --git a/lib/sirp/server/finish.rb b/lib/sirp/server/finish.rb new file mode 100644 index 0000000..e79964d --- /dev/null +++ b/lib/sirp/server/finish.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module SIRP + class Server + class Finish + attr_reader :backend, :proof + + def initialize(proof_attrs, mm, group=Prime[2048], hash=Digest::SHA256, backend_cls=Backend::SCryptHMAC) + @backend = backend_cls.new(group, hash) + + @proof = Utils.symbolize_keys(proof_attrs) + @client_M = mm + + validate_params! + + @b = proof[:b].to_i(16) + @v = proof[:v].to_i(16) + + @K = backend.calc_K(calc_S) + @M = backend.calc_M(proof[:I], proof[:s], proof[:A], proof[:B], @K) + end + + def match + success? ? backend.calc_H_AMK(proof[:A], @M, @K) : '' + end + + def success? + Utils.secure_compare(@M, @client_M) + end + + private + + def validate_params! + fail ArgumentError, 'proof must have required hash keys' unless @proof.keys == [:A, :B, :b, :I, :s, :v] + fail ArgumentError, 'client M must be a hex string' unless Utils.hex_str?(@client_M) + end + + def calc_S + u = backend.calc_u(proof[:A], proof[:B]) + Utils.num_to_hex(backend.calc_server_S(proof[:A], @b, @v, u)) + end + + end + end +end diff --git a/lib/sirp/server/start.rb b/lib/sirp/server/start.rb new file mode 100644 index 0000000..86f0024 --- /dev/null +++ b/lib/sirp/server/start.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module SIRP + class Server + + # Host auth: Step 1 + # Create a challenge for the client, and a proof to be stored + # on the server for later use when verifying the client response. + # + # # Client -> Server: username, A + # user = DB[:users].where(username: params[:username]).first + # start = SIRP::Server::Start.new(user, params[:A]) + # # Server stores proof to session + # session[:proof] = start.proof + # # Server -> Client: B, salt + # start.challenge + # + class Start + attr_reader :user, :backend + + # Constructor + # + # @param user_attrs [Hash] server stored username, verifier and salt + # @param aa [String] the client provided 'A' value in hex + # @param group [SIRP::Prime] defaults to Prime of 2048 length + # @param hash one-way hash function + # @param backend_cls subclass of SIRP::Backend, defaults to Backend::SCryptHMAC + def initialize(user_attrs, aa, group=Prime[2048], hash=Digest::SHA256, backend_cls=Backend::SCryptHMAC) + @backend = backend_cls.new(group, hash) + @user = Utils.symbolize_keys(user_attrs) + @A = aa + + validate_params! + + @b = generate_b + @v = user[:verifier] + @B = Utils.num_to_hex(backend.calc_B(@b.hex, @v.to_i(16))) + end + + # Challenge for client + # + # @return [Hash] with B and salt + def challenge + { B: @B, salt: user[:salt] } + end + + def proof + { + A: @A, + B: @B, + b: @b, + I: user[:username], + s: user[:salt], + v: @v + } + end + + private + + def validate_params! + fail ArgumentError, 'username must not be an empty string' if Utils.empty?(user[:username]) + fail ArgumentError, 'verifier must be a hex string' unless Utils.hex_str?(user[:verifier]) + fail ArgumentError, 'salt must be a hex string' unless Utils.hex_str?(user[:salt]) + fail ArgumentError, '"A" must be a hex string' unless Utils.hex_str?(@A) + + fail SafetyCheckError, 'A.to_i(16) % N cannot equal 0' if (@A.to_i(16) % backend.prime.N).zero? + end + + def generate_b + RbNaCl::Util.bin2hex(RbNaCl::Random.random_bytes(32)) + end + end + end +end diff --git a/lib/sirp/sirp.rb b/lib/sirp/sirp.rb deleted file mode 100644 index 75f1d8c..0000000 --- a/lib/sirp/sirp.rb +++ /dev/null @@ -1,163 +0,0 @@ -module SIRP - - # Convert a hex string to an a array of Integer bytes by first converting - # the String to hex, and then converting that hex to an Array of Integer bytes. - # - # @param str [String] a string to convert - # @return [Array] an Array of Integer bytes - def hex_to_bytes(str) - [str].pack('H*').unpack('C*') - end - - # Convert a number to a downcased hex string, prepending '0' to the - # hex string if the hex conversion resulted in an odd length string. - # - # @param num [Integer] a number to convert to a hex string - # @return [String] a hex string - def num_to_hex(num) - hex_str = num.to_s(16) - even_hex_str = hex_str.length.odd? ? '0' + hex_str : hex_str - even_hex_str.downcase - end - - # Applies a one-way hash function, either SHA1 or SHA256, on an - # unpacked hex string. It will generate the same - # one-way hash value for a string that has been unpacked as if the - # hash function had been applied to the string directly. - # - # 'foo'.unpack('H*') - # => ["666f6f"] - # - # > sha_hex('foo'.unpack('H*')[0], Digest::SHA256) - # => "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" - # > Digest::SHA256.hexdigest 'foo' - # => "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" - # - # @param h [String] a hex string to hash - # @param hash_klass [Digest::SHA1, Digest::SHA256] The hash class that responds to hexdigest - # @return [String] a hex string representing the result of the one way hash function - def sha_hex(h, hash_klass) - hash_klass.hexdigest([h].pack('H*')) - end - - # Applies a one-way hash function, either SHA1 or SHA256, on the string provided. - # - # @param s [String] a string to hash - # @param hash_klass [Digest::SHA1, Digest::SHA256] The hash class that responds to hexdigest - # @return [String] a hex string representing the result of the one way hash function - def sha_str(s, hash_klass) - hash_klass.hexdigest(s) - end - - # Constant time string comparison. - # Extracted from Rack::Utils - # https://github.com/rack/rack/blob/master/lib/rack/utils.rb - # - # NOTE: the values compared should be of fixed length, such as strings - # that have already been processed by HMAC. This should not be used - # on variable length plaintext strings because it could leak length info - # via timing attacks. The user provided value should always be passed - # in as the second parameter so as not to leak info about the secret. - # - # @param a [String] the private value - # @param b [String] the user provided value - # @return [true, false] whether the strings match or not - def secure_compare(a, b) - return false unless a.bytesize == b.bytesize - - l = a.unpack('C*') - - r, i = 0, -1 - b.each_byte { |v| r |= v ^ l[i+=1] } - r == 0 - end - - # Modular Exponentiation - # https://en.m.wikipedia.org/wiki/Modular_exponentiation - # http://rosettacode.org/wiki/Modular_exponentiation#Ruby - # - # a^b (mod m) - def mod_exp(a, b, m) - # Use OpenSSL::BN#mod_exp - a.to_bn.mod_exp(b, m) - end - - # Hashing function with padding. - # Input is prefixed with 0 to meet N hex width. - def H(hash_klass, n, *a) - nlen = 2 * ((('%x' % [n]).length * 4 + 7) >> 3) - - hashin = a.map do |s| - next unless s - shex = s.is_a?(String) ? s : num_to_hex(s) - if shex.length > nlen - raise 'Bit width does not match - client uses different prime' - end - '0' * (nlen - shex.length) + shex - end.join('') - - sha_hex(hashin, hash_klass).hex % n - end - - # Multiplier parameter - # k = H(N, g) (in SRP-6a) - def calc_k(n, g, hash_klass) - H(hash_klass, n, n, g) - end - - # Private key (derived from username, raw password and salt) - # x = H(salt || H(username || ':' || password)) - def calc_x(username, password, salt, hash_klass) - spad = salt.length.odd? ? '0' : '' - sha_hex(spad + salt + sha_str([username, password].join(':'), hash_klass), hash_klass).hex - end - - # Random scrambling parameter - # u = H(A, B) - def calc_u(xaa, xbb, n, hash_klass) - H(hash_klass, n, xaa, xbb) - end - - # Password verifier - # v = g^x (mod N) - def calc_v(x, n, g) - mod_exp(g, x, n) - end - - # A = g^a (mod N) - def calc_A(a, n, g) - mod_exp(g, a, n) - end - - # B = g^b + k v (mod N) - def calc_B(b, k, v, n, g) - (mod_exp(g, b, n) + k * v) % n - end - - # Client secret - # S = (B - (k * g^x)) ^ (a + (u * x)) % N - def calc_client_S(bb, a, k, x, u, n, g) - mod_exp((bb - k * mod_exp(g, x, n)) % n, (a + x * u), n) - end - - # Server secret - # S = (A * v^u) ^ b % N - def calc_server_S(aa, b, v, u, n) - mod_exp((mod_exp(v, u, n) * aa), b, n) - end - - # M = H(A, B, K) - def calc_M(xaa, xbb, xkk, hash_klass) - digester = hash_klass.new - digester << hex_to_bytes(xaa).pack('C*') - digester << hex_to_bytes(xbb).pack('C*') - digester << hex_to_bytes(xkk).pack('C*') - digester.hexdigest - end - - # H(A, M, K) - def calc_H_AMK(xaa, xmm, xkk, hash_klass) - byte_string = hex_to_bytes([xaa, xmm, xkk].join('')).pack('C*') - sha_str(byte_string, hash_klass).hex - end -end diff --git a/lib/sirp/utils.rb b/lib/sirp/utils.rb new file mode 100644 index 0000000..09fb915 --- /dev/null +++ b/lib/sirp/utils.rb @@ -0,0 +1,40 @@ +module SIRP + class Utils + HEX_REG = /^\h+$/.freeze + + class << self + def empty?(str) + str.strip.empty? + end + + # NOTE: fallback to ruby < 2.4 required + def hex_str?(str) + HEX_REG.match?(str) + end + + def num_to_hex(num) + hex_str = num.to_s(16) + even_hex_str = hex_str.length.odd? ? '0' + hex_str : hex_str + even_hex_str.downcase + end + + def symbolize_keys(hash) + hash.each_with_object({}) { |(k, v), res| res[k.to_sym] = v } + end + + def secure_compare(a, b) + # Do all comparisons on equal length hashes of the inputs + a = Digest::SHA256.hexdigest(a) + b = Digest::SHA256.hexdigest(b) + return false unless a.bytesize == b.bytesize + + l = a.unpack('C*') + + r = 0 + i = -1 + b.each_byte { |v| r |= v ^ l[i+=1] } + r == 0 + end + end + end +end diff --git a/lib/sirp/verifier.rb b/lib/sirp/verifier.rb deleted file mode 100644 index 9772700..0000000 --- a/lib/sirp/verifier.rb +++ /dev/null @@ -1,121 +0,0 @@ -module SIRP - class Verifier - include SIRP - attr_reader :N, :g, :k, :A, :B, :b, :S, :K, :M, :H_AMK, :hash - - # Select modulus (N), generator (g), and one-way hash function (SHA1 or SHA256) - # - # @param group [Integer] the group size in bits - def initialize(group = 2048) - raise ArgumentError, 'must be an Integer' unless group.is_a?(Integer) - raise ArgumentError, 'must be a known group size' unless [1024, 1536, 2048, 3072, 4096, 6144, 8192].include?(group) - - @N, @g, @hash = Ng(group) - @k = calc_k(@N, @g, hash) - end - - # Phase 0 ; Generate a verifier and salt client-side. This should only be - # used during the initial user registration process. All three values - # should be provided as attributes in the user registration process. The - # verifier and salt should be persisted server-side. The verifier - # should be protected and never made public or given to any user. - # The salt should be returned to any user requesting it to start - # Phase 1 of the authentication process. - # - # @param username [String] the authentication username - # @param password [String] the authentication password - # @return [Hash] a Hash of the username, verifier, and salt - def generate_userauth(username, password) - raise ArgumentError, 'username must be a string' unless username.is_a?(String) && !username.empty? - raise ArgumentError, 'password must be a string' unless password.is_a?(String) && !password.empty? - - @salt ||= SecureRandom.hex(10) - x = calc_x(username, password, @salt, hash) - v = calc_v(x, @N, @g) - { username: username, verifier: num_to_hex(v), salt: @salt } - end - - # Phase 1 : Step 2 : Create a challenge for the client, and a proof to be stored - # on the server for later use when verifying the client response. - # - # @param username [String] the client provided authentication username - # @param xverifier [String] the server stored verifier for the username in hex - # @param xsalt [String] the server stored salt for the username in hex - # @param xaa [String] the client provided 'A' value in hex - # @return [Hash] a Hash with the challenge for the client and a proof for the server - def get_challenge_and_proof(username, xverifier, xsalt, xaa) - raise ArgumentError, 'username must be a string' unless username.is_a?(String) && !username.empty? - raise ArgumentError, 'xverifier must be a string' unless xverifier.is_a?(String) - raise ArgumentError, 'xverifier must be a hex string' unless xverifier =~ /^[a-fA-F0-9]+$/ - raise ArgumentError, 'xsalt must be a string' unless xsalt.is_a?(String) - raise ArgumentError, 'xsalt must be a hex string' unless xsalt =~ /^[a-fA-F0-9]+$/ - raise ArgumentError, 'xaa must be a string' unless xaa.is_a?(String) - raise ArgumentError, 'xaa must be a hex string' unless xaa =~ /^[a-fA-F0-9]+$/ - - # SRP-6a safety check - return false if (xaa.to_i(16) % @N).zero? - - # Generate b and B - v = xverifier.to_i(16) - @b ||= SecureRandom.hex(32).hex - @B = num_to_hex(calc_B(@b, k, v, @N, @g)) - - { - challenge: { B: @B, salt: xsalt }, - proof: { A: xaa, B: @B, b: num_to_hex(@b), I: username, s: xsalt, v: xverifier } - } - end - - # - # Phase 2 : Step 1 : See Client#start_authentication - # - - # Phase 2 : Step 2 : Use the server stored proof and the client provided 'M' value. - # Calculates a server 'M' value and compares it to the client provided one, - # and if they match the client and server have negotiated equal secrets. - # Returns a H(A, M, K) value on success and false on failure. - # - # Sets the @K value, which is the client and server negotiated secret key - # if verification succeeds. This can be used to derive strong encryption keys - # for later use. The client independently calculates the same @K value as well. - # - # If authentication fails the H_AMK value must not be provided to the client. - # - # @param proof [Hash] the server stored proof Hash with keys A, B, b, I, s, v - # @param client_M [String] the client provided 'M' value in hex - # @return [String, false] the H_AMK value in hex for the client, or false if verification failed - def verify_session(proof, client_M) - raise ArgumentError, 'proof must be a hash' unless proof.is_a?(Hash) - raise ArgumentError, 'proof must have required hash keys' unless proof.keys == [:A, :B, :b, :I, :s, :v] - raise ArgumentError, 'client_M must be a string' unless client_M.is_a?(String) - raise ArgumentError, 'client_M must be a hex string' unless client_M =~ /^[a-fA-F0-9]+$/ - - @A = proof[:A] - @B = proof[:B] - @b = proof[:b].to_i(16) - v = proof[:v].to_i(16) - - u = calc_u(@A, @B, @N, hash) - - # SRP-6a safety check - return false if u.zero? - - # Calculate session key 'S' and secret key 'K' - @S = num_to_hex(calc_server_S(@A.to_i(16), @b, v, u, @N)) - @K = sha_hex(@S, hash) - - # Calculate the 'M' matcher - @M = calc_M(@A, @B, @K, hash) - - # Secure constant time comparison, hash the params to ensure - # that both strings being compared are equal length 32 Byte strings. - if secure_compare(Digest::SHA256.hexdigest(@M), Digest::SHA256.hexdigest(client_M)) - # Authentication succeeded, Calculate the H(A,M,K) verifier - @H_AMK = num_to_hex(calc_H_AMK(@A, @M, @K, hash)) - else - # Authentication failed - false - end - end - end -end diff --git a/lib/sirp/version.rb b/lib/sirp/version.rb index 7fb5987..1a83375 100644 --- a/lib/sirp/version.rb +++ b/lib/sirp/version.rb @@ -1,3 +1,3 @@ module SIRP - VERSION = '2.0.0.pre'.freeze + VERSION = '3.0.0.pre'.freeze end diff --git a/sirp.gemspec b/sirp.gemspec index e138d2d..f91f08e 100644 --- a/sirp.gemspec +++ b/sirp.gemspec @@ -34,9 +34,9 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] - # See : https://bugs.ruby-lang.org/issues/9569 spec.add_runtime_dependency 'rbnacl-libsodium', '~> 1.0' - spec.add_runtime_dependency 'securer_randomer', '~> 0.1.0' + spec.add_runtime_dependency 'rbnacl', '~> 4.0.0' + spec.add_runtime_dependency 'contracts', '~> 0.14' spec.add_development_dependency 'bundler', '~> 1.12' spec.add_development_dependency 'rake', '~> 11.0' @@ -45,4 +45,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'coveralls', '~> 0.8' spec.add_development_dependency 'coco', '~> 0.14' spec.add_development_dependency 'wwtd', '~> 1.3' + spec.add_development_dependency 'inch', '~> 0.7' end diff --git a/spec/acceptance/authentication_spec.rb b/spec/acceptance/authentication_spec.rb new file mode 100644 index 0000000..507aab9 --- /dev/null +++ b/spec/acceptance/authentication_spec.rb @@ -0,0 +1,45 @@ +RSpec.describe SIRP do + # Simulate actual authentication scenario over HTTP + # when the server is RESTful and has to persist authentication + # state between challenge and response. + # + context 'simulated authentication' do + let(:username) { 'leonardo' } + let(:password) { 'icnivad' } + + context 'when all params matches' do + it 'should authenticate' do + # Phase 0: Generate a verifier and salt + register = SIRP::Register.new(username, password) + user = register.credentials # This values should be persisted (in DB) + + # Phase 1: Step 1: Start the authentication process by generating the + # client 'a' and 'A' values. + client = SIRP::Client.new + aa = client.start + + # Phase 1: Step 2: Create a challenge for the client, and a proof to be stored + # on the server for later use when verifying the client response. + server_start = SIRP::Server::Start.new(user, aa) + challenge = server_start.challenge + proof = server_start.proof + + # Phase 2: Step 1: Process the salt and B values provided by the server. + matcher = client.authenticate(username, password, challenge) + + # Phase 2: Step 2: Use the server stored proof and the client provided 'M' value. + server_finish = SIRP::Server::Finish.new(proof, matcher) + expect(server_finish.success?).to be(true) + + # Phase 2: Step 3: Verify that the server provided H(A,M,K) value + # matches the client generated version. + server_HAMK = server_finish.match + expect(client.verify(server_HAMK)).to be(true) + end + end + + context 'when params does not match' do + + end + end +end diff --git a/spec/authentication_spec.rb b/spec/authentication_spec.rb deleted file mode 100644 index 97b580e..0000000 --- a/spec/authentication_spec.rb +++ /dev/null @@ -1,140 +0,0 @@ -# encoding: utf-8 -require 'spec_helper' - -describe SIRP do - # Simulate actual authentication scenario over HTTP - # when the server is RESTful and has to persist authentication - # state between challenge and response. - # - context 'simulated authentication' do - before :all do - @username = 'leonardo' - password = 'icnivad' - @auth = SIRP::Verifier.new(1024).generate_userauth(@username, password) - - # Simulate database persistance layer - @db = { - @username => { - verifier: @auth[:verifier], - salt: @auth[:salt] - } - } - end - - it 'should authenticate with matching server and client params' do - client = SIRP::Client.new(1024) - verifier = SIRP::Verifier.new(1024) - - # phase 1 (client) - aa = client.start_authentication - - # phaase 1 (server) - v = @auth[:verifier] - salt = @auth[:salt] - - cp = verifier.get_challenge_and_proof(@username, v, salt, aa) - - # phase 2 (client) - client_M = client.process_challenge(@username, 'icnivad', salt, cp[:proof][:B]) - - # phase 2 (server) - proof = { A: aa, B: cp[:proof][:B], b: cp[:proof][:b], I: @username, s: salt, v: v } - server_H_AMK = verifier.verify_session(proof, client_M) - expect(server_H_AMK).to be_truthy - - # phase 2 (client) - expect(client.verify(server_H_AMK)).to be true - end - - it 'should not authenticate when a bad password is injected in the flow' do - client = SIRP::Client.new(1024) - verifier = SIRP::Verifier.new(1024) - - # phase 1 (client) - aa = client.start_authentication - - # phaase 1 (server) - v = @auth[:verifier] - salt = @auth[:salt] - cp = verifier.get_challenge_and_proof(@username, v, salt, aa) - - # phase 2 (client) - client_M = client.process_challenge(@username, 'BAD PASSWORD', salt, cp[:proof][:B]) - - # phase 2 (server) - proof = { A: aa, B: cp[:proof][:B], b: cp[:proof][:b], I: @username, s: salt, v: v } - server_H_AMK = verifier.verify_session(proof, client_M) - expect(server_H_AMK).to be false - - # phase 2 (client) - expect(client.verify(server_H_AMK)).to be false - end - - it 'should authenticate when simulating a stateless server' do - username = @username - - # START PHASE 1 - - # P1 : client generates A and begins auth - client = SIRP::Client.new(1024) - aa = client.start_authentication - - # P1 : username and A are sent (client -> server) - - # P1 : server finds user in DB - _user = @db[username] - expect(_user).not_to be_nil - v = _user[:verifier] - salt = _user[:salt] - - # P1 : server generates B, saves A and B to DB - verifier = SIRP::Verifier.new(1024) - _session = verifier.get_challenge_and_proof(username, v, salt, aa) - expect(_session[:challenge][:B]).to eq verifier.B - expect(_session[:challenge][:salt]).to eq salt - - # P1 : store proof to memory - _user[:session_proof] = _session[:proof] - - # P1 : clear variables to simulate end of phase 1 - verifier = username = v = bb = salt = nil - - # P1 : server sends salt and B to client - client_response = _session[:challenge] - - # P1 : client receives B and salt (server -> client) - bb = client_response[:B] - salt = client_response[:salt] - - # START PHASE 2 - - # P2 : client generates session key - # at this point _client_srp.a should be persisted! - # calculate_client_key is stateful! - mmc = client.process_challenge(@username, 'icnivad', salt, bb) - expect(client.A).to be_truthy - expect(client.M).to eq mmc - expect(client.K).to be_truthy - expect(client.H_AMK).to be_truthy - - # P2 : client sends username and M -> server - client_M = client.M - - # P2 : server receives client M (client -> server) - _user = @db[@username] - - # P2 : server retrives session from DB - proof = _user[:session_proof] - verifier = SIRP::Verifier.new(1024) - server_H_AMK = verifier.verify_session(proof, client_M) - expect(server_H_AMK).to be_truthy - - # Now the two parties have a shared, strong session key K. - # To complete authentication, they need to prove to each other that - # their keys match. - - expect(client.verify(server_H_AMK)).to be true - expect(client.K).to eq verifier.K - end - end -end diff --git a/spec/client_spec.rb b/spec/client_spec.rb deleted file mode 100644 index eca2b78..0000000 --- a/spec/client_spec.rb +++ /dev/null @@ -1,66 +0,0 @@ -# encoding: utf-8 -require 'spec_helper' - -describe SIRP do - # From http://srp.stanford.edu/demo/demo.html using 1024 bit SHA1 values. - context 'client' do - before :all do - @username = 'user' - @password = 'password' - @salt = '16ccfa081895fe1ed0bb' - @a = '7ec87196e320a2f8dfe8979b1992e0d34439d24471b62c40564bb4302866e1c2' - @b = '8143e2f299852a05717427ea9d87c6146e747d0da6e95f4390264e55a43ae96' - end - - it 'should fail to initialize with a bad group size' do - expect { SIRP::Client.new(1234) }.to raise_error(ArgumentError, 'must be a known group size') - end - - it 'should calculate A from random a' do - client = SIRP::Client.new(1024) - aa1 = client.start_authentication - expect(('%b' % aa1.to_i(16)).length).to be >= 1000 - - client = SIRP::Client.new(1024) - aa2 = client.start_authentication - expect(('%b' % aa2.to_i(16)).length).to be >= 1000 - - expect(aa1).not_to eq aa2 - end - - it 'should calculate A deterministicly from known @a' do - client = SIRP::Client.new(1024) - client.set_a(@a.to_i(16)) - aa = client.start_authentication - expect(aa).to eq '165366e23a10006a62fb8a0793757a299e2985103ad2e8cdee0cc37cac109f3f338ee12e2440eda97bfa7c75697709a5dc66faadca7806d43ea5839757d134ae7b28dd3333049198cc8d328998b8cd8352ff4e64b3bd5f08e40148d69b0843bce18cbbb30c7e4760296da5c92717fcac8bddc7875f55302e55d90a34226868d2' - end - - it 'should calculate client session (S) and secret (K)' do - client = SIRP::Client.new(1024) - client.set_a(@a.to_i(16)) - aa = client.start_authentication - - # Simulate server B - bb = '56777d24af1121bd6af6aeb84238ff8d250122fe75ed251db0f47c289642ae7adb9ef319ce3ab23b6ecc97e5904749fc42f12bb016ecf39691db541f066667b8399bfa685c82b03ad8f92f75975ed086dbe0d470d4dd907ce11b19ee41b74aee72bd8445cde6b58c01f678e39ed9cd6b93c79382637df90777a96c10a768c510' - mm = client.process_challenge(@username, @password, @salt, bb) - - # Client keys - expect(client.S).to eq '7f44592cc616e0d761b2d3309d513b69b386c35f3ed9b11e6d43f15799b673d6dcfa4117b4456af978458d62ad61e1a37be625f46d2a5bd9a50aae359e4541275f0f4bd4b4caed9d2da224b491231f905d47abd9953179aa608854b84a0e0c6195e73715932b41ab8d0d4a2977e7642163be6802c5907fb9e233b8c96e457314' - expect(client.K).to eq '404bf923682abeeb3c8c9164d2cdb6b6ba21b64d' - end - - it 'should verify true with matching server H_AMK' do - server_HAMK = 'abc123' - client = SIRP::Client.new(1024) - client.set_h_amk('abc123') - expect(client.verify(server_HAMK)).to be true - end - - it 'should verify false with non-matching server H_AMK' do - server_HAMK = 'bbaadd' - client = SIRP::Client.new(1024) - client.set_h_amk('abc123') - expect(client.verify(server_HAMK)).to be false - end - end -end diff --git a/spec/parameters_spec.rb b/spec/parameters_spec.rb deleted file mode 100644 index caaf101..0000000 --- a/spec/parameters_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# encoding: utf-8 -require 'spec_helper' - -describe SIRP do - include SIRP - # Test predefined values for N and g. - # Values are from vectors listed in RFC 5054 Appendix B. - # - context 'parameters' do - it 'should raise an error on unknown verifier group size' do - expect { Ng(1234) }.to raise_error(ArgumentError, 'must be a known group size') - end - - before :all do - @params = [ - { group: 1024, generator: 2, hash: Digest::SHA1, hash_nn: '0995b627385b26f55dc1fe18de984252e0357b9f2c884d8d3f9fd9f2de32f408' }, - { group: 1536, generator: 2, hash: Digest::SHA1, hash_nn: 'ba36a6059669d1d9eb4125d63eeca771bb7bb54efa14cb5ef8efd07ef8bf2094' }, - { group: 2048, generator: 2, hash: Digest::SHA256, hash_nn: 'ef88b43c555c005c89f9c32dbd2ced49b0bb57e2cd1f2b5e9eca181afdf09c56' }, - { group: 3072, generator: 5, hash: Digest::SHA256, hash_nn: '30a45e27c3a0a6f934cd558e88e937625082b19bd435f74f04d7500e5032d88e' }, - { group: 4096, generator: 5, hash: Digest::SHA256, hash_nn: '233836aba654664fc65121b25f1760c0e72456e834bc42315fa21d38ade81cac' }, - { group: 6144, generator: 5, hash: Digest::SHA256, hash_nn: 'b84b67a0c9b0d7870cedf59880bed18dff60d4e965fe0f82ee70618861cc0a07' }, - { group: 8192, generator: 19, hash: Digest::SHA256, hash_nn: 'a408aa7fd5e69ae6886c3b3fd50051efc417d62cf224cebf8d8aeb49654185ed' } - ] - end - - it 'should be correct when accessed through a SIRP::Verifier' do - @params.each do |p| - v = SIRP::Verifier.new(p[:group]) - expect(('%b' % v.N).length).to eq(p[:group]) - expect(Digest::SHA256.hexdigest(('%x' % v.N))).to eq(p[:hash_nn]) - expect(v.g).to eq(p[:generator]) - expect(v.hash).to eq(p[:hash]) - end - end - - it 'should be correct when accessed through a Ng' do - @params.each do |p| - nn, g, h = Ng(p[:group]) - expect(('%b' % nn).length).to eq(p[:group]) - expect(Digest::SHA256.hexdigest(('%x' % nn))).to eq(p[:hash_nn]) - expect(g).to eq(p[:generator]) - expect(h).to eq(p[:hash]) - end - end - end -end diff --git a/spec/shared/params_context.rb b/spec/shared/params_context.rb new file mode 100644 index 0000000..a7050c2 --- /dev/null +++ b/spec/shared/params_context.rb @@ -0,0 +1,13 @@ +RSpec.shared_context 'precalculated values' do + let(:username) { 'user' } + let(:verifier) { 'b20536acc536952df844101d940e8d1dd1f5b10a336fc90c642db1de4b9a0b86cf50c3b7b8a6b857b99f75887fe252be709d797c32072b9446c9f678909313c901a473f9c52b6556993026fa72432d21169dfcffd71c02f8191fb00ac4f8f3b02b9f6f519aeb1b13a902208d26a95766be32a057c726482e103637f31be6e23c' } + let(:salt) { 'eb25522cc747e55b31116b427f017fc8' } + + let(:aa) { '5dcede9bf0aa4975d2fd9357cde0b92fbc65bef5d3d2e901d913ad40fb4ccfae0f19c853e7ea99a642f07464de68a57f27a7b03f5a0ba642346d6b5403d4e12095f6d4d1957f05be4410131043522fc19b67660b94a1c0ef0836ee6a622000fa8907e72b999287dd51acb2ab0de806f9acec90e225d488465817537b9e6994e1' } + let(:bb) { 'b60db854be4edadd3f2e89fabf79aa48306d262ca8ae41d57cba6aa1122b63681f49da88b1d5ddcd753f40b6b9366c16fe476350f56963a72e59ac489ab9295fa6bf1b404d126bf07e093c42e690751bcff51ac18ddb90451f699582378f21d8a2b1a331c36697947889c3d4549c4a91d55e7fe0e376e6335ab27b4ec8490f6b' } + let(:a) { 25238626187718218014362019996016886172365815594880298997364951189126567240922 } + let(:b) { '50fb5a94be79cc0398d1dd94d49aec2e9fc63e63d57d01eb84c521606677d95b' } + + let(:group) { SIRP::Prime[1024] } + let(:hash) { Digest::SHA256 } +end diff --git a/spec/sirp_spec.rb b/spec/sirp_spec.rb deleted file mode 100644 index 471f230..0000000 --- a/spec/sirp_spec.rb +++ /dev/null @@ -1,184 +0,0 @@ -# encoding: utf-8 -require 'spec_helper' - -# Test SRP functions. -# Some values are from http://srp.stanford.edu/demo/demo.html using 256 bit values. -# -describe SIRP do - include SIRP - before :all do - @N = '115b8b692e0e045692cf280b436735c77a5a9e8a9e7ed56c965f87db5b2a2ece3'.to_i(16) - @g = 2 - @username = 'user' - @password = 'password' - @salt = '16ccfa081895fe1ed0bb' - @a = '7ec87196e320a2f8dfe8979b1992e0d34439d24471b62c40564bb4302866e1c2'.to_i(16) - @b = '8143e2f299852a05717427ea9d87c6146e747d0da6e95f4390264e55a43ae96'.to_i(16) - end - - context 'hex_to_bytes' do - it 'should calculate expected results' do - expect(hex_to_bytes('abcdef0123456789')) - .to eq [171, 205, 239, 1, 35, 69, 103, 137] - end - end - - context 'num_to_hex' do - it 'should calculate expected results' do - num = 999_999_999_999 - expect(num_to_hex(num)) - .to eq 'e8d4a50fff' - expect('e8d4a50fff'.hex).to eq num - end - end - - context 'sha_hex' do - it 'should calculate expected results for SHA1' do - str = 'foo' - str_unpacked = str.unpack('H*')[0] - str_sha = Digest::SHA1.hexdigest(str) - expect(sha_hex(str_unpacked, Digest::SHA1)).to eq str_sha - end - - it 'should calculate expected results for SHA256' do - str = 'foo' - str_unpacked = str.unpack('H*')[0] - str_sha = Digest::SHA256.hexdigest(str) - expect(sha_hex(str_unpacked, Digest::SHA256)).to eq str_sha - end - end - - context 'sha_str' do - it 'should calculate expected results for SHA1' do - expect(sha_str('foo', Digest::SHA1)) - .to eq '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' - end - - it 'should calculate expected results for SHA256' do - expect(sha_str('foo', Digest::SHA256)) - .to eq '2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae' - end - end - - context 'mod_exp' do - it 'should calculate expected results' do - a = 2988348162058574136915891421498819466320163312926952423791023078876139 - b = 2351399303373464486466122544523690094744975233415544072992656881240319 - m = 10**40 - c = mod_exp(a, b, m) - expect(c).to eq 1527229998585248450016808958343740453059 - end - end - - context 'H' do - it 'should calculate expected results' do - a = 2988348162058574136915891421498819466320163312926952423791023078876139 - b = 2351399303373464486466122544523690094744975233415544072992656881240319 - c = H(Digest::SHA1, a, b) - expect(c).to eq 870206349645559849154987479939336526106829135959 - end - - it 'should raise an error when given invalid args' do - expect { H(Digest::SHA1, 1, '123456789abcdef') } - .to raise_error(RuntimeError, 'Bit width does not match - client uses different prime') - end - end - - context 'calc_k' do - it 'should calculate expected results' do - k = calc_k(@N, @g, Digest::SHA1) - expect(('%x' % k)).to eq 'dbe5dfe0704fee4c85ff106ecd38117d33bcfe50' - expect(('%b' % k).length).to eq 160 - end - end - - context 'calc_x' do - it 'should calculate expected results' do - x = calc_x(@username, @password, @salt, Digest::SHA1) - expect(('%x' % x)).to eq 'bdd0a4e1c9df4082684d8d358b8016301b025375' - expect(('%b' % x).length).to eq 160 - end - end - - context 'calc_u' do - it 'should calculate expected results' do - aa = 'b1c4827b0ce416953789db123051ed990023f43b396236b86e12a2c69638fb8e' - bb = 'fbc56086bb51e26ee1a8287c0a7f3fd4e067e55beb8530b869b10b961957ff68' - u = calc_u(aa, bb, @N, Digest::SHA1) - expect(('%x' % u)).to eq 'c60b17ddf568dd5743d0e3ba5621646b742432c5' - expect(('%b' % u).length).to eq 160 - end - end - - context 'calc_v' do - it 'should calculate expected results' do - x = 'bdd0a4e1c9df4082684d8d358b8016301b025375'.to_i(16) - v = calc_v(x, @N, @g) - expect(('%x' % v)).to eq 'ce36e101ed8c37ed98ba4e441274dabd1062f3440763eb98bd6058e5400b6309' - expect(('%b' % v).length).to eq 256 - end - end - - context 'calc_A' do - it 'should calculate expected results' do - aa = calc_A(@a, @N, @g) - expect(('%x' % aa)).to eq 'b1c4827b0ce416953789db123051ed990023f43b396236b86e12a2c69638fb8e' - expect(('%b' % aa).length).to eq 256 - end - end - - context 'calc_B' do - it 'should calculate expected results' do - k = 'dbe5dfe0704fee4c85ff106ecd38117d33bcfe50'.to_i(16) - v = 'ce36e101ed8c37ed98ba4e441274dabd1062f3440763eb98bd6058e5400b6309'.to_i(16) - bb = calc_B(@b, k, v, @N, @g) - expect(('%x' % bb)).to eq 'fbc56086bb51e26ee1a8287c0a7f3fd4e067e55beb8530b869b10b961957ff68' - expect(('%b' % bb).length).to eq 256 - end - end - - context 'calc_client_S' do - it 'should calculate expected results' do - bb = 'fbc56086bb51e26ee1a8287c0a7f3fd4e067e55beb8530b869b10b961957ff68'.to_i(16) - k = 'dbe5dfe0704fee4c85ff106ecd38117d33bcfe50'.to_i(16) - x = 'bdd0a4e1c9df4082684d8d358b8016301b025375'.to_i(16) - u = 'c60b17ddf568dd5743d0e3ba5621646b742432c5'.to_i(16) - ss = calc_client_S(bb, @a, k, x, u, @N, @g) - expect(('%x' % ss)).to eq 'a606c182e364d2c15f9cdbeeeb63bb00c831d1da65eedc1414f21157d0312a5a' - expect(('%b' % ss).length).to eq 256 - end - end - - context 'calc_server_S' do - it 'should calculate expected results' do - aa = 'b1c4827b0ce416953789db123051ed990023f43b396236b86e12a2c69638fb8e'.to_i(16) - v = 'ce36e101ed8c37ed98ba4e441274dabd1062f3440763eb98bd6058e5400b6309'.to_i(16) - u = 'c60b17ddf568dd5743d0e3ba5621646b742432c5'.to_i(16) - ss = calc_server_S(aa, @b, v, u, @N) - expect(('%x' % ss)).to eq 'a606c182e364d2c15f9cdbeeeb63bb00c831d1da65eedc1414f21157d0312a5a' - expect(('%b' % ss).length).to eq 256 - end - end - - context 'calc_M' do - it 'should calculate expected results' do - xaa = 'b1c4827b0ce416953789db123051ed990023f43b396236b86e12a2c69638fb8e' - xbb = 'fbc56086bb51e26ee1a8287c0a7f3fd4e067e55beb8530b869b10b961957ff68' - xss = 'a606c182e364d2c15f9cdbeeeb63bb00c831d1da65eedc1414f21157d0312a5a' - xkk = sha_hex(xss, Digest::SHA1) - expect(xkk).to eq '5844898ea6e5f5d9b737bc0ba2fb9d5edd3f8e67' - mm = calc_M(xaa, xbb, xkk, Digest::SHA1) - expect(mm).to eq '0c6de5c7892a71bf971d733a511c44940e227941' - end - end - - context 'calc_H_AMK' do - it 'should calculate expected results' do - xaa = 'b1c4827b0ce416953789db123051ed990023f43b396236b86e12a2c69638fb8e' - xmm = 'd597503056af882d5b27b419302ac7b2ea9d7468' - xkk = '5844898ea6e5f5d9b737bc0ba2fb9d5edd3f8e67' - h_amk = calc_H_AMK(xaa, xmm, xkk, Digest::SHA1) - expect(('%x' % h_amk)).to eq '530fccc1c4aa82ae5c5cdfa8bdec987c6032451d' - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e17f8b3..3e52fbe 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,3 @@ -# encoding: utf-8 - # coveralls.io and coco are incompatible. Run each in their own env. if ENV['TRAVIS'] || ENV['CI'] || ENV['JENKINS_URL'] || ENV['TDDIUM'] || ENV['COVERALLS_RUN_LOCALLY'] # coveralls.io : web based code coverage @@ -10,33 +8,36 @@ require 'coco' end -require 'sirp' +require 'sirp/all' + +Dir[File.join(Dir.pwd, 'spec/shared/**/*.rb')].each { |f| require f } -# Monkey-patch Client and Verifier classes for testing convenience -module SIRP - class Verifier - def set_aa(val) - @A = val - end +RSpec.configure do |config| + config.filter_run :focus + config.run_all_when_everything_filtered = true + config.shared_context_metadata_behavior = :apply_to_host_groups - def set_b(val) - @b = val - end + if config.files_to_run.one? + config.full_backtrace = true - def set_salt(val) - @salt = val - end + config.default_formatter = 'doc' end -end -module SIRP - class Client - def set_a(val) - @a = val - end + config.order = :random + + Kernel.srand(config.seed) + + config.disable_monkey_patching! + config.warnings = false + + config.expect_with :rspec do |expectations| + expectations.syntax = :expect + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end - def set_h_amk(val) - @H_AMK = val - end + config.mock_with :rspec do |mocks| + mocks.syntax = :expect + mocks.verify_partial_doubles = true + mocks.verify_doubled_constant_names = true end end diff --git a/spec/unit/backend/digest_spec.rb b/spec/unit/backend/digest_spec.rb new file mode 100644 index 0000000..a3ae92d --- /dev/null +++ b/spec/unit/backend/digest_spec.rb @@ -0,0 +1,17 @@ +require 'sirp/backend/digest' + +RSpec.describe SIRP::Backend::Digest do + let(:instance) { described_class.new(SIRP::Prime[2048], Digest::SHA1) } + + describe '#calc_x' do + subject { instance.calc_x(username, password, salt) } + + let(:username) { 'user' } + let(:password) { 'password' } + let(:salt) { '16ccfa081895fe1ed0bb' } + + it 'should calculate expected result' do + expect('%x' % subject).to eql('bdd0a4e1c9df4082684d8d358b8016301b025375') + end + end +end diff --git a/spec/unit/backend/scrypt_hmac_spec.rb b/spec/unit/backend/scrypt_hmac_spec.rb new file mode 100644 index 0000000..db1bc1b --- /dev/null +++ b/spec/unit/backend/scrypt_hmac_spec.rb @@ -0,0 +1,17 @@ +require 'sirp/backend/scrypt_hmac' + +RSpec.describe SIRP::Backend::SCryptHMAC do + let(:instance) { described_class.new(SIRP::Prime[2048], Digest::SHA2) } + + describe '#calc_x' do + subject { instance.calc_x(username, password, salt) } + + let(:username) { 'user' } + let(:password) { 'password' } + let(:salt) { '01ebb2496e4e8d32e6f7967ee9fec64e' } + + it 'should calculate expected result' do + expect('%x' % subject).to eql('1fbca479f5b4b660a2d5ce1c05193232ba6732377b5072648bae764ce51bb093') + end + end +end diff --git a/spec/unit/client_spec.rb b/spec/unit/client_spec.rb new file mode 100644 index 0000000..c872ac6 --- /dev/null +++ b/spec/unit/client_spec.rb @@ -0,0 +1,126 @@ +RSpec.describe SIRP::Client do + let(:instance) { described_class.new(group, hash) } + + let(:username) { 'user' } + let(:password) { 'password' } + + let(:group) { SIRP::Prime[1024] } + let(:hash) { Digest::SHA256 } + + describe '#start' do + subject { instance.start } + + let(:a) { '5b23c5d12d41b23f98a11f12a57f85b9' } + + before(:each) do + allow(RbNaCl::Util).to receive(:bin2hex).and_return(a) + end + + it 'should return A' do + expect(subject).to eql('07bf1c86f9ab4be3ec66eefedc377bfde24c812c6bb61dfab45814f440066be74a12dfaa96c58c5cce6649e9c0094f2d6128505393c548fe4b897dd0c14e42ac0df6f46dd66fed42d6bbaeaa8e04696859e45cc93c8e02441579690da8d442b62f54aaeedf967b19c2f51b765fc0ed20bb559ca67e9c2384d792864e3446ad0d') + end + end + + describe '#authenticate' do + subject { instance.authenticate(username, password, challenge) } + + let(:challenge) do + { + salt: salt, + B: bb + } + end + + let(:salt) { '7e5dc1b0b253af9d1b11dc514b5c3a2a' } + let(:bb) { '149f99673b11e3cacc4fbb53f695b60502485f596915775254e434a78d6ef879cfa84f76fc065203d5a94e6ee3a4289a071045867b885e6d36667ff90cc77ad003757fd11f919c17739c021318f47fa256b1f542651eca81abfabe7f9ed7aef4a65d3a4d00075694fdfcdf289e98c888b63d01334da3876bfa332c89e73759e4cb4cbf233281609499bc51d22634b9a6d15e1a34f5aac189c6701eacd5a3999a014457038742a20fd00b9502fb957e97b4ed80f858077c55f1dd52c768d33cadf91f7c00dadf6450b3a9464bd1b4aad3b5779361d169c3f9679706ab57dfb2a97d980e22e99e2cd59862684b852dd73647e86a8b74117b02795ae27539a753d2' } + + context 'when username is an empty string' do + let(:username) { '' } + + it 'should fail' do + expect { subject }.to raise_error(ArgumentError, 'username must not be an empty string') + end + end + + context 'when username is an empty string with whitespace chars' do + let(:username) { "\x00\t\n\v\f\r " } + + it 'should fail' do + expect { subject }.to raise_error(ArgumentError, 'username must not be an empty string') + end + end + + context 'when password is an empty string' do + let(:password) { '' } + + it 'should fail' do + expect { subject }.to raise_error(ArgumentError, 'password must not be an empty string') + end + end + + context 'when password is an empty string with whitespace chars' do + let(:password) { "\x00\t\n\v\f\r " } + + it 'should fail' do + expect { subject }.to raise_error(ArgumentError, 'password must not be an empty string') + end + end + + context 'when salt is an empty string' do + let(:salt) { '' } + + it 'should fail' do + expect { subject }.to raise_error(ArgumentError, 'salt must be a hex string') + end + end + + context 'when salt is not a hex string' do + let(:salt) { '💩' } + + it 'should fail' do + expect { subject }.to raise_error(ArgumentError, 'salt must be a hex string') + end + end + + context 'when "B" is an empty string' do + let(:bb) { '' } + + it 'should fail' do + expect { subject }.to raise_error(ArgumentError, '"B" must be a hex string') + end + end + + context 'when "B" is not a hex string' do + let(:bb) { '💩' } + + it 'should fail' do + expect { subject }.to raise_error(ArgumentError, '"B" must be a hex string') + end + end + + context 'when B % N == 0' do + let(:bb) { group.N.to_s(16) } + + it 'should fail' do + expect { subject }.to raise_error(SIRP::SafetyCheckError, 'B % N cannot equal 0') + end + end + end + + describe '#verify' do + subject { instance.verify(server_hamk) } + + context 'when server H(AMK) is an empty string' do + let(:server_hamk) { '' } + + it { expect(subject).to be(false) } + end + + context 'when server H(AMK) is not a hex string' do + let(:server_hamk) { '💩' } + + it { expect(subject).to be(false) } + end + end + +end diff --git a/spec/unit/register_spec.rb b/spec/unit/register_spec.rb new file mode 100644 index 0000000..36195e9 --- /dev/null +++ b/spec/unit/register_spec.rb @@ -0,0 +1,72 @@ +RSpec.describe SIRP::Register do + let(:instance) { described_class.new(username, password, group, hash) } + + let(:username) { 'user' } + let(:password) { 'password' } + + let(:group) { SIRP::Prime[1024] } + let(:hash) { Digest::SHA256 } + + describe '.new' do + context 'when username is an empty string' do + let(:username) { '' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'username must not be an empty string') + end + end + + context 'when username is an empty string with whitespace chars' do + let(:username) { "\x00\t\n\v\f\r " } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'username must not be an empty string') + end + end + + context 'when password is an empty string' do + let(:password) { '' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'password must not be an empty string') + end + end + + context 'when password is an empty string with whitespace chars' do + let(:password) { "\x00\t\n\v\f\r " } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'password must not be an empty string') + end + end + end + + describe '#credentials' do + subject { instance.credentials } + + let(:salt) { '5b23c5d12d41b23f98a11f12a57f85b9' } + + before(:each) do + allow(RbNaCl::Util).to receive(:bin2hex).and_return(salt) + end + + describe ':username' do + it 'returns given username' do + expect(subject[:username]).to equal(username) + end + end + + describe ':verifier' do + it 'returns expected verifier' do + expect(subject[:verifier]).to eql('cdd1c991ee190f3481e33c24b2b420d3d99d36a224d401e5c78b84062827193878503c90dd9aa47802b47948dab4eec8d5c6c4ddc4711ef4532de7ff0412d0df106d4b377e8b1c8dbf0092c27b40900d34fad913bfa1aac53b1e211766b283817b1bacae5eeca2933ac779cfae83840e8f50b46ea5a23614d9aa24e41fc0740a') + end + end + + describe ':salt' do + it 'returns generated salt' do + expect(subject[:salt]).to equal(salt) + end + end + end + +end diff --git a/spec/unit/server/finish_spec.rb b/spec/unit/server/finish_spec.rb new file mode 100644 index 0000000..9cabc48 --- /dev/null +++ b/spec/unit/server/finish_spec.rb @@ -0,0 +1,114 @@ +RSpec.describe SIRP::Server::Finish do + include_context 'precalculated values' + + let(:instance) { described_class.new(proof, mm, group, hash) } + + let(:proof) do + { + A: aa, + B: bb, + b: b, + I: username, + s: salt, + v: verifier + } + end + + let(:mm) { 'f168015612fd8618724ce316a290e7ec8a49c43d8960ee8f4532b1b99675d257' } + + describe '.new' do + context 'when "M" is an empty string' do + let(:mm) { '' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'client M must be a hex string') + end + end + + context 'when "M" is an empty string with whitespace chars' do + let(:mm) { "\x00\t\n\v\f\r " } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'client M must be a hex string') + end + end + + context 'when "M" is not hex string' do + let(:mm) { '💩' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'client M must be a hex string') + end + end + + [:A, :B, :b, :I, :s, :v].each do |key| + context "without proof[:#{key}]" do + before(:each) do + proof.delete(key) + end + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'proof must have required hash keys') + end + end + end + + context 'when proof have string keys' do + let(:proof) do + { + 'A' => aa, + 'B' => bb, + 'b' => b, + 'I' => username, + 's' => salt, + 'v' => verifier + } + end + + it 'should not fail' do + expect { instance }.to_not raise_error + end + end + end + + describe '#success?' do + subject { instance.success? } + + context 'when valid params' do + it { expect(subject).to be(true) } + end + + context 'when invalid params' do + context '"M" not valid' do + let(:mm) { RbNaCl::Util.bin2hex(RbNaCl::Random.random_bytes(32)) } # I'll laugh a lot when this will fail + + it { expect(subject).to be(false) } + end + + context 'username not valid' do + let(:username) { 'resu' } + + it { expect(subject).to be(false) } + end + end + end + + describe '#match' do + subject { instance.match } + + context 'when valid params' do + it 'should return expected H(A,M,K)' do + expect(subject).to eql('02b661acc0c7d5e9354e81c41304df451b8b570227c2cbb7b82197561f980354') + end + end + + context 'when invalid params' do + let(:username) { 'resu' } + + it 'should return empty string' do + expect(subject).to eql('') + end + end + end + +end diff --git a/spec/unit/server/start_spec.rb b/spec/unit/server/start_spec.rb new file mode 100644 index 0000000..f0dfae5 --- /dev/null +++ b/spec/unit/server/start_spec.rb @@ -0,0 +1,178 @@ +RSpec.describe SIRP::Server::Start do + include_context 'precalculated values' + + let(:instance) { described_class.new(user, aa, group, hash) } + + let(:user) do + { + username: username, + verifier: verifier, + salt: salt + } + end + + describe '.new' do + context 'when username is an empty string' do + let(:username) { '' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'username must not be an empty string') + end + end + + context 'when username is an empty string with whitespace chars' do + let(:username) { "\x00\t\n\v\f\r " } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'username must not be an empty string') + end + end + + context 'when verifier is an empty string' do + let(:verifier) { '' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'verifier must be a hex string') + end + end + + context 'when verifier is not hex string' do + let(:verifier) { '💩' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'verifier must be a hex string') + end + end + + context 'when salt is an empty string' do + let(:salt) { '' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'salt must be a hex string') + end + end + + context 'when salt is an empty string with whitespace chars' do + let(:salt) { "\x00\t\n\v\f\r " } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'salt must be a hex string') + end + end + + context 'when salt is not hex string' do + let(:salt) { '💩' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, 'salt must be a hex string') + end + end + + context 'when "A" is an empty string' do + let(:aa) { '' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, '"A" must be a hex string') + end + end + + context 'when "A" is an empty string with whitespace chars' do + let(:aa) { "\x00\t\n\v\f\r " } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, '"A" must be a hex string') + end + end + + context 'when "A" is not a hex string' do + let(:aa) { '💩' } + + it 'should fail to initialize' do + expect { instance }.to raise_error(ArgumentError, '"A" must be a hex string') + end + end + + context 'when A.to_i(16) % N == 0' do + let(:aa) { group.N.to_s(16) } + + it 'should fail to initialize' do + expect { instance }.to raise_error(SIRP::SafetyCheckError, 'A.to_i(16) % N cannot equal 0') + end + end + + context 'when user have string keys' do + let(:user) do + { + "username" => username, + "verifier" => verifier, + "salt" => salt + } + end + + it 'should not fails' do + expect { instance }.to_not raise_error + end + end + end + + describe '#challenge' do + subject { instance.challenge } + + it 'should contain B and salt' do + expect(subject.keys).to contain_exactly(:B, :salt) + end + + it 'returns salt' do + expect(subject[:salt]).to equal(salt) + end + + context 'with predefined b' do + before(:each) do + allow(RbNaCl::Util).to receive(:bin2hex).and_return(b) + end + + it 'should generate expected B' do + expect(subject[:B]).to eql('b60db854be4edadd3f2e89fabf79aa48306d262ca8ae41d57cba6aa1122b63681f49da88b1d5ddcd753f40b6b9366c16fe476350f56963a72e59ac489ab9295fa6bf1b404d126bf07e093c42e690751bcff51ac18ddb90451f699582378f21d8a2b1a331c36697947889c3d4549c4a91d55e7fe0e376e6335ab27b4ec8490f6b') + end + end + end + + describe '#proof' do + subject { instance.proof } + + it 'should contain A, B, b, I, s and v' do + expect(subject.keys).to contain_exactly(:A, :B, :b, :I, :s, :v) + end + + it 'returns "A"' do + expect(subject[:A]).to equal(aa) + end + + it 'returns username as "I"' do + expect(subject[:I]).to equal(username) + end + + it 'returns salt as "s"' do + expect(subject[:s]).to equal(salt) + end + + it 'returns verifier as "v"' do + expect(subject[:v]).to equal(verifier) + end + + context 'with predefined "b"' do + before(:each) do + allow(RbNaCl::Util).to receive(:bin2hex).and_return(b) + end + + it 'returns B' do + expect(subject[:B]).to eql('b60db854be4edadd3f2e89fabf79aa48306d262ca8ae41d57cba6aa1122b63681f49da88b1d5ddcd753f40b6b9366c16fe476350f56963a72e59ac489ab9295fa6bf1b404d126bf07e093c42e690751bcff51ac18ddb90451f699582378f21d8a2b1a331c36697947889c3d4549c4a91d55e7fe0e376e6335ab27b4ec8490f6b') + end + + it 'returns b' do + expect(subject[:b]).to equal(b) + end + end + end + +end diff --git a/spec/verifier_spec.rb b/spec/verifier_spec.rb deleted file mode 100644 index 6957ae8..0000000 --- a/spec/verifier_spec.rb +++ /dev/null @@ -1,126 +0,0 @@ -# encoding: utf-8 -require 'spec_helper' - -# From http://srp.stanford.edu/demo/demo.html using 1024 bit SHA1 values. -describe SIRP do - before :all do - @username = 'user' - @password = 'password' - @salt = '16ccfa081895fe1ed0bb' - @a = '7ec87196e320a2f8dfe8979b1992e0d34439d24471b62c40564bb4302866e1c2' - @b = '8143e2f299852a05717427ea9d87c6146e747d0da6e95f4390264e55a43ae96' - end - - context 'initialize' do - it 'should fail to initialize with a bad group size' do - expect { SIRP::Verifier.new(1234) }.to raise_error(ArgumentError, 'must be a known group size') - end - - it 'should calculate k' do - k = SIRP::Verifier.new(1024).k - expect(k).to eq '7556aa045aef2cdd07abaf0f665c3e818913186f'.to_i(16) - end - end - - context 'generate_userauth' do - it 'should ' do - auth = SIRP::Verifier.new(1024).generate_userauth(@username, @password) - expect(auth[:username]).to eq @username - expect(auth[:verifier]).to be_truthy - expect([:salt]).to be_truthy - end - - it 'should calculate verifier with given salt' do - verifier = SIRP::Verifier.new(1024) - verifier.set_salt(@salt) - auth = verifier.generate_userauth(@username, @password) - v = auth[:verifier] - salt = auth[:salt] - expect(salt).to eq @salt - expect(v).to eq '321307d87ca3462f5b0cb5df295bea04498563794e5401899b2f32dd5cab5b7de9da78e7d62ea235e6d7f43a4ea09fea7c0dafdee6e79a1d12e2e374048deeaf5ba7c68e2ad952a3f5dc084400a7f1599a31d6d9d50269a9208db88f84090e8aa3c7b019f39529dcc19baa985a8d7ffb2d7628071d2313c9eaabc504d3333688' - end - - it 'should generate salt and calculate verifier' do - verifier = SIRP::Verifier.new(1024) - auth = verifier.generate_userauth(@username, @password) - v = auth[:verifier] - salt = auth[:salt] - expect(('%b' % v.to_i(16)).length).to be >= 1000 - expect(('%b' % salt.to_i(16)).length).to be >= 50 - end - end - - context 'get_challenge_and_proof' do - it 'SRP6a Safety : should return false if A % N == 0' do - verifier = SIRP::Verifier.new(1024) - @auth = verifier.generate_userauth('foo', 'bar') - nn = verifier.N - verifier.set_aa(nn.to_s(16)) - expect(verifier.get_challenge_and_proof(@username, @auth[:verifier], @auth[:salt], verifier.A)).to be false - end - - it 'should return expected results' do - verifier = SIRP::Verifier.new(1024) - @auth = verifier.generate_userauth('foo', 'bar') - cp = verifier.get_challenge_and_proof(@username, @auth[:verifier], @auth[:salt], @auth[:verifier]) - expect(cp).to be_a Hash - expect(cp.key?(:challenge)).to be true - expect(cp[:challenge].key?(:B)).to be true - expect(cp[:challenge].key?(:salt)).to be true - expect(cp.key?(:proof)).to be true - expect(cp[:proof].key?(:A)).to be true - expect(cp[:proof].key?(:B)).to be true - expect(cp[:proof].key?(:b)).to be true - expect(cp[:proof].key?(:I)).to be true - expect(cp[:proof].key?(:s)).to be true - expect(cp[:proof].key?(:v)).to be true - end - - it 'should generate expected B with predefined b' do - v = '321307d87ca3462f5b0cb5df295bea04498563794e5401899b2f32dd5cab5b7de9da78e7d62ea235e6d7f43a4ea09fea7c0dafdee6e79a1d12e2e374048deeaf5ba7c68e2ad952a3f5dc084400a7f1599a31d6d9d50269a9208db88f84090e8aa3c7b019f39529dcc19baa985a8d7ffb2d7628071d2313c9eaabc504d3333688' - verifier = SIRP::Verifier.new(1024) - @auth = verifier.generate_userauth('foo', 'bar') - verifier.set_b(@b.to_i(16)) - cp = verifier.get_challenge_and_proof(@username, v, @auth[:salt], @auth[:verifier]) - expect(('%b' % cp[:proof][:b].to_i(16)).length).to be > 200 - expect(('%b' % cp[:challenge][:B].to_i(16)).length).to be >= 1000 - expect(cp[:challenge][:B]).to eq '56777d24af1121bd6af6aeb84238ff8d250122fe75ed251db0f47c289642ae7adb9ef319ce3ab23b6ecc97e5904749fc42f12bb016ecf39691db541f066667b8399bfa685c82b03ad8f92f75975ed086dbe0d470d4dd907ce11b19ee41b74aee72bd8445cde6b58c01f678e39ed9cd6b93c79382637df90777a96c10a768c510' - end - end - - context 'verify_session' do - it 'should calculate server session and key' do - # A is received in phase 1 - aa = '165366e23a10006a62fb8a0793757a299e2985103ad2e8cdee0cc37cac109f3f338ee12e2440eda97bfa7c75697709a5dc66faadca7806d43ea5839757d134ae7b28dd3333049198cc8d328998b8cd8352ff4e64b3bd5f08e40148d69b0843bce18cbbb30c7e4760296da5c92717fcac8bddc7875f55302e55d90a34226868d2' - # B and b are saved from phase 1 - bb = '56777d24af1121bd6af6aeb84238ff8d250122fe75ed251db0f47c289642ae7adb9ef319ce3ab23b6ecc97e5904749fc42f12bb016ecf39691db541f066667b8399bfa685c82b03ad8f92f75975ed086dbe0d470d4dd907ce11b19ee41b74aee72bd8445cde6b58c01f678e39ed9cd6b93c79382637df90777a96c10a768c510' - # v is from db - v = '321307d87ca3462f5b0cb5df295bea04498563794e5401899b2f32dd5cab5b7de9da78e7d62ea235e6d7f43a4ea09fea7c0dafdee6e79a1d12e2e374048deeaf5ba7c68e2ad952a3f5dc084400a7f1599a31d6d9d50269a9208db88f84090e8aa3c7b019f39529dcc19baa985a8d7ffb2d7628071d2313c9eaabc504d3333688' - _proof = { A: aa, B: bb, b: @b, I: @username, s: @salt, v: v } - verifier = SIRP::Verifier.new(1024) - verifier.verify_session(_proof, 'abc123') - expect(verifier.S).to eq '7f44592cc616e0d761b2d3309d513b69b386c35f3ed9b11e6d43f15799b673d6dcfa4117b4456af978458d62ad61e1a37be625f46d2a5bd9a50aae359e4541275f0f4bd4b4caed9d2da224b491231f905d47abd9953179aa608854b84a0e0c6195e73715932b41ab8d0d4a2977e7642163be6802c5907fb9e233b8c96e457314' - expect(verifier.K).to eq '404bf923682abeeb3c8c9164d2cdb6b6ba21b64d' - end - - it 'should calculate verifier M and server proof' do - # A is received in phase 1 - aa = '165366e23a10006a62fb8a0793757a299e2985103ad2e8cdee0cc37cac109f3f338ee12e2440eda97bfa7c75697709a5dc66faadca7806d43ea5839757d134ae7b28dd3333049198cc8d328998b8cd8352ff4e64b3bd5f08e40148d69b0843bce18cbbb30c7e4760296da5c92717fcac8bddc7875f55302e55d90a34226868d2' - # B and b are saved from phase 1 - bb = '56777d24af1121bd6af6aeb84238ff8d250122fe75ed251db0f47c289642ae7adb9ef319ce3ab23b6ecc97e5904749fc42f12bb016ecf39691db541f066667b8399bfa685c82b03ad8f92f75975ed086dbe0d470d4dd907ce11b19ee41b74aee72bd8445cde6b58c01f678e39ed9cd6b93c79382637df90777a96c10a768c510' - # v is from db - v = '321307d87ca3462f5b0cb5df295bea04498563794e5401899b2f32dd5cab5b7de9da78e7d62ea235e6d7f43a4ea09fea7c0dafdee6e79a1d12e2e374048deeaf5ba7c68e2ad952a3f5dc084400a7f1599a31d6d9d50269a9208db88f84090e8aa3c7b019f39529dcc19baa985a8d7ffb2d7628071d2313c9eaabc504d3333688' - # S is validated - ss = '7f44592cc616e0d761b2d3309d513b69b386c35f3ed9b11e6d43f15799b673d6dcfa4117b4456af978458d62ad61e1a37be625f46d2a5bd9a50aae359e4541275f0f4bd4b4caed9d2da224b491231f905d47abd9953179aa608854b84a0e0c6195e73715932b41ab8d0d4a2977e7642163be6802c5907fb9e233b8c96e457314' - - client_M = 'b2c4a9a9cf40fb2db67bbab4ebe36a50223e51e9' - _proof = { A: aa, B: bb, b: @b, I: @username, s: @salt, v: v } - - verifier = SIRP::Verifier.new(1024) - verifier.verify_session(_proof, client_M) - expect(verifier.M).to eq client_M - expect(verifier.S).to eq ss - expect(verifier.H_AMK).to eq 'a93d906ef5c0a15a8e525da6a271692d2e553c72' - end - end -end