module SessionKeys

SessionKeys deterministic cryptographic key generation.

Constants

SCRYPT_DIGEST_SIZE

Size in bytes of the scrypt derived output

VERSION

Public Class Methods

generate(id, password, min_password_entropy = 75) click to toggle source

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

@param id [String] a unique US-ASCII or UTF-8 encoded String identifier such as a username or

email address. Max length 256 characters.

@param password [String] a cryptographically strong US-ASCII or UTF-8 encoded password or

passphrase. Max length 256 characters.

@param min_password_entropy [Integer] the minimum (75) estimated entropy allowed

for the password. This will be measured with Zxcvbn.

@return [Hash] returns a Hash of keys and derived key material. @raise [ArgumentError] if invalid arguments are provided.

# 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