Class: Passlib::SHA2Crypt

Inherits:
Password show all
Defined in:
lib/passlib/sha2_crypt.rb

Overview

Handles SHA-256 ($5$) and SHA-512 ($6$) crypt password hashing via OpenSSL.

Implements the SHA-crypt algorithm as specified by Ulrich Drepper (www.akkadia.org/drepper/SHA-crypt.txt). Both 256-bit and 512-bit variants are supported and auto-detected on load.

Examples:

hash = Passlib::ShaCrypt.create("hunter2", bits: 512, rounds: 10_000)
hash.verify("hunter2")  # => true
hash.to_s               # => "$6$rounds=10000$...$..."

Constant Summary

Constants included from Internal::DSL

Internal::DSL::Config

Instance Attribute Summary

Attributes inherited from Password

#config, #string

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Password

available?, #initialize, #inspect, load, #pretty_print, #verify

Methods included from Internal::DSL

#identifier

Constructor Details

This class inherits a constructor from Passlib::Password

Class Method Details

.create(secret, **options) ⇒ SHA2Crypt

Creates a new SHA-crypt hash.

Parameters:

  • secret (String)

    the plaintext password

Options Hash (**options):

  • :bits (Integer)

    selects the SHA variant — 256 (SHA-256, MCF id $5$) or 512 (SHA-512, MCF id $6$) (default: 512)

  • :rounds (Integer)

    number of hashing rounds (default: 535,000 for SHA-256, 656,000 for SHA-512, clamped to 1,000–999,999,999)

  • :salt (String)

    custom salt string up to 16 characters (default: random)

Returns:



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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/passlib/sha2_crypt.rb', line 24

class SHA2Crypt < Password
  HASH_CHARS      = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
  IMPLICIT_ROUNDS = 5_000
  MIN_ROUNDS      = 1_000
  MAX_ROUNDS      = 999_999_999
  MAX_SALT_LEN    = 16

  VARIANTS = {
    256 => { mcf_id: "5", digest: OpenSSL::Digest::SHA256, block_size: 32, default_rounds: 535_000, checksum_len: 43  },
    512 => { mcf_id: "6", digest: OpenSSL::Digest::SHA512, block_size: 64, default_rounds: 656_000, checksum_len: 86  },
  }.freeze

  # Byte index groups for SHA-crypt base64 encoding.
  # nil entries encode as 0 (padding for the final partial group).
  ENCODE_GROUPS = {
    256 => [
      [0,   10,  20,  4], [21,  1,   11,  4], [12,  22,  2,   4], [3,   13,  23,  4],
      [24,  4,   14,  4], [15,  25,  5,   4], [6,   16,  26,  4], [27,  7,   17,  4],
      [18,  28,  8,   4], [9,   19,  29,  4], [nil, 31,  30,  3],
    ].freeze,
    512 => [
      [0,   21,  42,  4], [22,  43,  1,   4], [44,  2,   23,  4], [3,   24,  45,  4],
      [25,  46,  4,   4], [47,  5,   26,  4], [6,   27,  48,  4], [28,  49,  7,   4],
      [50,  8,   29,  4], [9,   30,  51,  4], [31,  52,  10,  4], [53,  11,  32,  4],
      [12,  33,  54,  4], [34,  55,  13,  4], [56,  14,  35,  4], [15,  36,  57,  4],
      [37,  58,  16,  4], [59,  17,  38,  4], [18,  39,  60,  4], [40,  61,  19,  4],
      [62,  20,  41,  4], [nil, nil, 63,  2],
    ].freeze,
  }.freeze

  MCF_TO_BITS  = { "5" => 256, "6" => 512 }.freeze
  LOAD_PATTERN = %r{\A\$(5|6)\$(?:rounds=(\d+)\$)?([./0-9A-Za-z]{0,16})\$([./0-9A-Za-z]+)\z}

  private_constant :HASH_CHARS, :IMPLICIT_ROUNDS, :MIN_ROUNDS, :MAX_ROUNDS, :MAX_SALT_LEN,
                   :VARIANTS, :ENCODE_GROUPS, :MCF_TO_BITS, :LOAD_PATTERN

  register :sha2_crypt, mcf: %w[5 6]
  options :bits, :rounds, :salt

  # @param secret [String] the plaintext password to re-hash
  # @return [SHA2Crypt] a new instance hashed with the same salt, rounds, and bits
  def create_comparable(secret)
    self.class.create(secret, salt: @salt, rounds: @rounds, bits: @bits)
  end

  def upgrade?
    @rounds != (config.rounds || VARIANTS[@bits][:default_rounds])
  end

  private

  def create(secret)
    @bits    = config.bits || 512
    variant  = VARIANTS[@bits] or raise ArgumentError, "invalid bits: #{@bits.inspect}, must be 256 or 512"
    rounds   = config.rounds || variant[:default_rounds]
    @rounds  = rounds.clamp(MIN_ROUNDS, MAX_ROUNDS)
    @salt    = (config.salt || random_salt)[0, MAX_SALT_LEN]
    crypt(secret.to_s.b, @salt.b, @rounds, variant)
  end

  def load(string)
    match   = LOAD_PATTERN.match(string) or raise UnknownHashFormat, "invalid sha_crypt hash: #{string.inspect}"
    @bits   = MCF_TO_BITS[match[1]]
    variant = VARIANTS[@bits]
    raise UnknownHashFormat, "invalid sha_crypt hash: #{string.inspect}" unless match[4].length == variant[:checksum_len]
    @rounds = match[2] ? match[2].to_i : IMPLICIT_ROUNDS
    @salt   = match[3]
    string
  end

  def random_salt
    Internal.random_bytes(MAX_SALT_LEN).bytes.map { HASH_CHARS[it & 63] }.join
  end

  def crypt(password, salt, rounds, variant)
    digest_class = variant[:digest]
    block_size   = variant[:block_size]
    mcf_id       = variant[:mcf_id]

    # Steps 1-3: start digest A, add password and salt
    ctx_a = digest_class.new
    ctx_a.update(password)
    ctx_a.update(salt)

    # Steps 4-8: digest B = digest(password + salt + password)
    ctx_b = digest_class.new
    ctx_b.update(password)
    ctx_b.update(salt)
    ctx_b.update(password)
    digest_b = ctx_b.digest

    # Steps 9-10: add digest B to A in block_size-byte blocks, then the remainder
    plen = password.bytesize
    (plen / block_size).times { ctx_a.update(digest_b) }
    ctx_a.update(digest_b.b[0, plen % block_size]) if (plen % block_size) > 0

    # Step 11: for each bit of plen (LSB first): 1-bit → add digest B, 0-bit → add password
    len = plen
    while len > 0
      ctx_a.update(len.odd? ? digest_b : password)
      len >>= 1
    end

    # Step 12: finish digest A
    digest_a = ctx_a.digest

    # Steps 13-16: P sequence — repeat password plen times through digest, tile to plen bytes
    ctx_p = digest_class.new
    plen.times { ctx_p.update(password) }
    digest_p = ctx_p.digest
    p_str    = (digest_p * ((plen / block_size) + 1)).b[0, plen]

    # Steps 17-20: S sequence — repeat salt (16 + first byte of digest A) times, tile to slen bytes
    slen  = salt.bytesize
    ctx_s = digest_class.new
    (16 + digest_a.getbyte(0)).times { ctx_s.update(salt) }
    digest_s = ctx_s.digest
    s_str    = (digest_s * ((slen / block_size) + 1)).b[0, slen]

    # Step 21: main loop — rounds iterations mixing C, P, and S
    c = digest_a
    rounds.times do |i|
      ctx_c = digest_class.new
      ctx_c.update(i.odd? ? p_str : c)
      ctx_c.update(s_str) unless (i % 3).zero?
      ctx_c.update(p_str) unless (i % 7).zero?
      ctx_c.update(i.odd? ? c : p_str)
      c = ctx_c.digest
    end

    # Step 22: encode output with variant-specific byte ordering
    encoded = encode(c)
    rounds == IMPLICIT_ROUNDS ? "$#{mcf_id}$#{salt}$#{encoded}" : "$#{mcf_id}$rounds=#{rounds}$#{salt}$#{encoded}"
  end

  def encode(digest)
    b = digest.bytes
    ENCODE_GROUPS[@bits].map { |w2, w1, w0, n|
      b64_group(w2 ? b[w2] : 0, w1 ? b[w1] : 0, w0 ? b[w0] : 0, n)
    }.join
  end

  # Encode (w2, w1, w0) as n characters from HASH_CHARS, extracting 6-bit chunks LSB-first
  def b64_group(w2, w1, w0, n)
    v = (w2 << 16) | (w1 << 8) | w0
    n.times.map { c = HASH_CHARS[v & 0x3f]; v >>= 6; c }.join
  end
end

Instance Method Details

#create_comparable(secret) ⇒ SHA2Crypt

Returns a new instance hashed with the same salt, rounds, and bits.

Parameters:

  • secret (String)

    the plaintext password to re-hash

Returns:

  • (SHA2Crypt)

    a new instance hashed with the same salt, rounds, and bits



65
66
67
# File 'lib/passlib/sha2_crypt.rb', line 65

def create_comparable(secret)
  self.class.create(secret, salt: @salt, rounds: @rounds, bits: @bits)
end

#upgrade?Boolean

Returns:

  • (Boolean)


69
70
71
# File 'lib/passlib/sha2_crypt.rb', line 69

def upgrade?
  @rounds != (config.rounds || VARIANTS[@bits][:default_rounds])
end