Class: Passlib::MD5Crypt

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

Overview

Note:

MD5-crypt is a legacy algorithm with a fixed, low round count. It is supported here for verifying existing hashes and migrating users to a stronger scheme. Do not use it for new hashes.

Handles MD5-crypt and Apache MD5-crypt password hashing.

MD5-crypt was designed by Poul-Henning Kamp for FreeBSD in 1994. It runs 1000 rounds of a complex MD5-based mixing function and was widely deployed on Linux systems as the default password scheme. Apache’s variant is identical except for using a different MCF identifier.

Two MCF identifiers are recognized:

  • $1$ — standard MD5-crypt (FreeBSD/Linux)

  • $apr1$ — Apache APR variant (functionally identical, different prefix)

New hashes created via MD5Crypt.create use $1$. To create APR hashes, pass variant: :apr.

Hash format: $1$<salt>$<checksum> or $apr1$<salt>$<checksum>

  • salt — 0-8 characters from ./0-9A-Za-z (default: 8 random characters)

  • checksum — 22 characters from ./0-9A-Za-z

This format is compatible with the md5_crypt and apr_md5_crypt as implemented in Python’s passlib.

Examples:

hash = Passlib::MD5Crypt.create("hunter2")
hash.verify("hunter2")     # => true
hash.to_s                  # => "$1$...$..."

apr = Passlib::MD5Crypt.create("hunter2", variant: :apr)
apr.to_s                   # => "$apr1$...$..."

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) ⇒ MD5Crypt

Creates a new MD5-crypt hash.

Parameters:

  • secret (String)

    the plaintext password

Options Hash (**options):

  • :variant (Symbol)

    selects the MCF identifier: :standard (default, produces $1$) or :apr (produces $apr1$)

  • :salt (String)

    custom salt string, 0-8 characters from ./0-9A-Za-z (default: 8 random characters)

Returns:



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
# File 'lib/passlib/md5_crypt.rb', line 49

class MD5Crypt < Password
  register :md5_crypt, mcf: %w[1 apr1]
  options :variant, :salt

  HASH_CHARS  = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
  MAX_SALT_LEN = 8
  ROUNDS       = 1000

  MAGIC = { standard: "$1$", apr: "$apr1$" }.freeze

  # Byte-index groups for encoding the 16-byte MD5 digest into 22 characters.
  # The ordering matches the original FreeBSD md5crypt implementation by
  # Poul-Henning Kamp. The final group encodes only one byte as 2 characters.
  ENCODE_OFFSETS = [
    [0,   6,  12, 4], [1,  7, 13, 4], [2, 8, 14, 4],
    [3,   9,  15, 4], [4, 10,  5, 4], [nil, nil, 11, 2],
  ].freeze

  LOAD_PATTERN = %r{\A(\$1\$|\$apr1\$)([./0-9A-Za-z]{0,8})\$([./0-9A-Za-z]{22})\z}

  private_constant :HASH_CHARS, :MAX_SALT_LEN, :ROUNDS, :MAGIC,
                   :ENCODE_OFFSETS, :LOAD_PATTERN

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

  # MD5-crypt uses a fixed round count of 1000, so upgrade? is always false.
  # @return [Boolean] always +false+
  def upgrade?
    false
  end

  private

  def create(secret)
    @variant = config.variant || :standard
    @magic   = MAGIC.fetch(@variant) { raise ArgumentError, "unknown md5_crypt variant: #{@variant.inspect}" }
    @salt    = (config.salt || random_salt)[0, MAX_SALT_LEN]
    "#{@magic}#{@salt}$#{md5_digest(secret)}"
  end

  def load(string)
    m = LOAD_PATTERN.match(string) or raise ArgumentError, "invalid md5_crypt hash: #{string.inspect}"
    @magic   = m[1]
    @variant = MAGIC.key(@magic)
    @salt    = m[2]
    string
  end

  def md5_digest(secret)
    pw  = secret.encode("UTF-8").b
    s   = @salt.b
    mag = @magic.b

    # Digest B = MD5(password + salt + password)
    digest_b = OpenSSL::Digest::MD5.digest(pw + s + pw)

    # Digest A = MD5(password + magic + salt + tiled_digest_b + bit_select)
    ctx_a = OpenSSL::Digest::MD5.new
    ctx_a.update(pw)
    ctx_a.update(mag)
    ctx_a.update(s)
    plen = pw.bytesize
    ctx_a.update(digest_b * (plen / 16)) if plen >= 16
    ctx_a.update(digest_b[0, plen % 16]) if (plen % 16) > 0
    len = plen
    while len > 0
      ctx_a.update(len.odd? ? "\x00" : pw[0])
      len >>= 1
    end
    digest_a = ctx_a.digest

    # Main loop: 1000 rounds mixing the current digest with password and salt
    c = digest_a
    ROUNDS.times do |i|
      ctx_c = OpenSSL::Digest::MD5.new
      ctx_c.update(i.odd? ? pw : c)
      ctx_c.update(s)  unless (i % 3).zero?
      ctx_c.update(pw) unless (i % 7).zero?
      ctx_c.update(i.odd? ? c : pw)
      c = ctx_c.digest
    end

    encode(c)
  end

  # Encodes the 16-byte MD5 digest into 22 characters using the FreeBSD
  # md5crypt byte ordering and the crypt64 alphabet.
  def encode(digest)
    b = digest.bytes
    ENCODE_OFFSETS.map { |w2, w1, w0, n|
      v = ((w2 ? b[w2] : 0) << 16) | ((w1 ? b[w1] : 0) << 8) | b[w0]
      n.times.map { c = HASH_CHARS[v & 0x3f]; v >>= 6; c }.join
    }.join
  end

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

Instance Method Details

#create_comparable(secret) ⇒ MD5Crypt

Returns a new instance hashed with the same salt and variant.

Parameters:

  • secret (String)

    the plaintext password to re-hash

Returns:

  • (MD5Crypt)

    a new instance hashed with the same salt and variant



74
75
76
# File 'lib/passlib/md5_crypt.rb', line 74

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

#upgrade?Boolean

MD5-crypt uses a fixed round count of 1000, so upgrade? is always false.

Returns:

  • (Boolean)

    always false



80
81
82
# File 'lib/passlib/md5_crypt.rb', line 80

def upgrade?
  false
end