Module: SessionKeys

Defined in:
lib/session_keys.rb,
lib/session_keys/version.rb

Overview

SessionKeys deterministic cryptographic key generation.

Constant Summary collapse

SCRYPT_DIGEST_SIZE =

Size in bytes of the scrypt derived output

256
VERSION =
'2.0.0'

Class Method Summary collapse

Class Method Details

.generate(id, password, min_password_entropy = 75) ⇒ Hash

Deterministically generates a collection of derived encryption key material from a provided id and password/passphrase. Uses SHA256 and scrypt for key derivation.

Parameters:

  • id (String)

    a unique US-ASCII or UTF-8 encoded String identifier such as a username or email address. Max length 256 characters.

  • password (String)

    a cryptographically strong US-ASCII or UTF-8 encoded password or passphrase. Max length 256 characters.

  • min_password_entropy (Integer) (defaults to: 75)

    the minimum (75) estimated entropy allowed for the password. This will be measured with Zxcvbn.

Returns:

  • (Hash)

    returns a Hash of keys and derived key material.

Raises:

  • (ArgumentError)

    if invalid arguments are provided.



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/session_keys.rb', line 24

def self.generate(id, password, min_password_entropy = 75)
  unless id.is_a?(String) && ['US-ASCII', 'UTF-8'].include?(id.encoding.name)
    raise ArgumentError, 'invalid id, not a US-ASCII or UTF-8 string'
  end

  unless id.length.between?(1,256)
    raise ArgumentError, 'invalid id, must be between 1 and 256 characters in length'
  end

  unless password.is_a?(String) && ['US-ASCII', 'UTF-8'].include?(password.encoding.name)
    raise ArgumentError, 'invalid password, not a US-ASCII or UTF-8 string'
  end

  # Enforce max length only due to Zxcvbn taking a *long* time to
  # process long strings and determine entropy.
  unless password.length.between?(1,256)
    raise ArgumentError, 'invalid password, must be between 1 and 256 characters in length'
  end

  unless min_password_entropy.is_a?(Integer) && min_password_entropy.between?(1, 512)
    raise ArgumentError, 'invalid min_password_entropy, must be an Integer between 1 and 512'
  end

  password_test = Zxcvbn.test(password)
  unless password_test.entropy.round >= min_password_entropy
    raise ArgumentError, "invalid password, must be at least #{min_password_entropy} bits of estimated entropy"
  end

  start_processing_time = Time.now

  id_sha256_bytes = RbNaCl::Hash.sha256(id.bytes.pack('C*'))
  id_sha256_hex = id_sha256_bytes.bytes.map { |byte| '%02x' % byte }.join

  # libsodium : By design, a password whose length is 65 bytes or more is
  # reduced to SHA-256(password). This can have security implications if the
  # password is present in another password database using raw, unsalted
  # SHA-256. Or when upgrading passwords previously hashed with unsalted
  # SHA-256 to scrypt. If this is a concern, passwords should be pre-hashed
  # before being hashed using scrypt.
  scrypt_key = RbNaCl::Hash.sha256(password.bytes.pack('C*'))

  # Tie the sycrypt password bytes to the ID they are associate with by
  # utilizing the ID as the salt. Include the ID length and an additional
  # string to harden the salt.
  scrypt_salt = RbNaCl::Hash.sha256([id_sha256_hex,
                                     id_sha256_hex.length,
                                     'session_keys'].join('').bytes.pack('C*'))

  # Derive SCRYPT_DIGEST_SIZE secret bytes
  password_digest = RbNaCl::PasswordHash.scrypt(
    scrypt_key,
    scrypt_salt,
    16384 * 32,
    16384 * 32 * 32,
    SCRYPT_DIGEST_SIZE
  ).bytes

  num_keys = SCRYPT_DIGEST_SIZE / 32

  byte_keys = []
  num_keys.times { byte_keys << password_digest.shift(32) }

  hex_keys = byte_keys.map { |key|
    key.map { |byte| '%02x' % byte }.join
  }

  nacl_encryption_key_pairs = byte_keys.map { |key|
    seed = key.pack('C*').force_encoding('ASCII-8BIT')
    sec_key = RbNaCl::PrivateKey.new(seed)
    pub_key = sec_key.public_key
    {secret_key: sec_key, public_key: pub_key}
  }

  nacl_encryption_key_pairs_base64 = nacl_encryption_key_pairs.map { |keypair|
    pub_key = Base64.strict_encode64(keypair[:public_key].to_bytes)
    sec_key = Base64.strict_encode64(keypair[:secret_key].to_bytes)
    {secret_key: sec_key, public_key: pub_key}
  }

  nacl_signing_key_pairs = byte_keys.map { |key|
    seed = key.pack('C*').force_encoding('ASCII-8BIT')
    sec_key = RbNaCl::SigningKey.new(seed)
    pub_key = sec_key.verify_key
    {secret_key: sec_key, public_key: pub_key}
  }

  nacl_signing_key_pairs_base64 = nacl_signing_key_pairs.map { |keypair|
    pub_key = Base64.strict_encode64(keypair[:public_key].to_bytes)
    sec_key = Base64.strict_encode64(keypair[:secret_key].to_bytes)
    {secret_key: sec_key, public_key: pub_key}
  }

  {
    id: id_sha256_hex,
    byte_keys: byte_keys,
    hex_keys: hex_keys,
    nacl_encryption_key_pairs: nacl_encryption_key_pairs,
    nacl_encryption_key_pairs_base64: nacl_encryption_key_pairs_base64,
    nacl_signing_key_pairs: nacl_signing_key_pairs,
    nacl_signing_key_pairs_base64: nacl_signing_key_pairs_base64,
    process_time: ((Time.now - start_processing_time)*1000).round(2)
  }
end