Class: NusaDB::Scram

Inherits:
Object
  • Object
show all
Defined in:
lib/nusadb/scram.rb

Overview

Client-side SCRAM-SHA-256 (RFC 5802 / RFC 7677), docs/wire-protocol.md §7.2. Uses Ruby's bundled OpenSSL / SecureRandom — no gems.

Base64 goes through Array#pack / String#unpack1 rather than the base64 library, which strict_encode64 / strict_decode64 are themselves thin wrappers over ("m0" is strict: no line breaks, and decoding rejects malformed input with ArgumentError). Ruby 3.4 demoted base64 from a default gem to a bundled one, so require 'base64' raises LoadError under Bundler unless the app declares it — which every Rails app using the ActiveRecord adapter would have to. The core methods keep the gem genuinely dependency-free.

Constant Summary collapse

GS2_HEADER =
'n,,'
CHANNEL_BINDING =
[GS2_HEADER].pack('m0')

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(user) ⇒ Scram

Returns a new instance of Scram.



22
23
24
25
26
27
# File 'lib/nusadb/scram.rb', line 22

def initialize(user)
  nonce = [SecureRandom.random_bytes(18)].pack('m0')
  @client_first_bare = "n=#{user},r=#{nonce}"
  @full_client_first = GS2_HEADER + @client_first_bare
  @expected_signature = ''
end

Instance Attribute Details

#client_first_bareObject (readonly)

Returns the value of attribute client_first_bare.



20
21
22
# File 'lib/nusadb/scram.rb', line 20

def client_first_bare
  @client_first_bare
end

#full_client_firstObject (readonly)

Returns the value of attribute full_client_first.



20
21
22
# File 'lib/nusadb/scram.rb', line 20

def full_client_first
  @full_client_first
end

Instance Method Details

#client_final(password, server_first) ⇒ Object



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/nusadb/scram.rb', line 29

def client_final(password, server_first)
  combined_nonce = nil
  salt = nil
  iterations = 0
  server_first.split(',').each do |field|
    key, _, value = field.partition('=')
    next if value.nil? || key.empty?

    case key
    when 'r' then combined_nonce = value
    when 's' then salt = value.unpack1('m0')
    when 'i' then iterations = value.to_i
    end
  end
  if combined_nonce.nil? || salt.nil? || iterations <= 0
    raise NusaError.new('nusadb: malformed server-first message', '08P01')
  end

  salted = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, 32, OpenSSL::Digest::SHA256.new)
  client_key = hmac(salted, 'Client Key')
  stored_key = OpenSSL::Digest::SHA256.digest(client_key)

  without_proof = "c=#{CHANNEL_BINDING},r=#{combined_nonce}"
  auth_message = "#{@client_first_bare},#{server_first},#{without_proof}"
  client_sig = hmac(stored_key, auth_message)
  proof = xor(client_key, client_sig)
  final = "#{without_proof},p=#{[proof].pack('m0')}"

  server_key = hmac(salted, 'Server Key')
  @expected_signature = [hmac(server_key, auth_message)].pack('m0')
  final
end

#verify_server_final(server_final) ⇒ Object



62
63
64
65
66
67
68
69
70
# File 'lib/nusadb/scram.rb', line 62

def verify_server_final(server_final)
  got = ''
  server_final.split(',').each do |field|
    got = field[2..] if field.start_with?('v=')
  end
  OpenSSL.fixed_length_secure_compare(got, @expected_signature)
rescue ArgumentError
  false # lengths differ
end