Skip to content

Commit

Permalink
x/crypto/crypt/md5crypt: MD5 APR1 implementation of crypt(3).
Browse files Browse the repository at this point in the history
This package implements Apache-specific MD5 APR1 password hashing
algorithm, as documented at
https://httpd.apache.org/docs/2.4/en/misc/password_encryptions.html.

Same hashing is also used by various Linux/BSD crypt(3) implementations
with slightly different salt prefix. This is also supported by this
package.
  • Loading branch information
abbot committed Oct 6, 2021
1 parent 089bfa5 commit c5812da
Show file tree
Hide file tree
Showing 2 changed files with 265 additions and 0 deletions.
153 changes: 153 additions & 0 deletions crypt/md5crypt/md5crypt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package md5crypt implements Apache-specific MD5 apr1 password hashing
// algorithm. Details and reference implementation are available at
// https://httpd.apache.org/docs/2.4/en/misc/password_encryptions.html
package md5crypt // import "golang.org/x/crypto/crypt/md5crypt"

import (
"crypto/md5"
"crypto/subtle"
"errors"
)

var (
// APR1Magic salt prefix is used by the Apache for MD5 encryption.
APR1Magic = []byte("$apr1$")
// MD5Magic salt prefix is used by various Linux/BSD crypt implementations.
MD5Magic = []byte("$1$")

// ErrUnsupportedSalt is returned when provided salt or hashed password
// doesn't have APR1Magic or MD5Magic prefix.
ErrUnsupportedSalt = errors.New("crypto/crypt/md5crypt: unsupported salt, must have $apr1$ or $1$ prefix")

// ErrMismatchedHashAndPassword is returned from CompareHashAndPassword when
// a password and hash do not match.
ErrMismatchedHashAndPassword = errors.New("crypto/crypt/md5crypt: hashedPassword is not the hash of the given password")
)

const itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

var md5CryptSwaps = [16]int{12, 6, 0, 13, 7, 1, 14, 8, 2, 15, 9, 3, 5, 10, 4, 11}

// GenerateFromPassword returns the MD5 APR1 hash of the password. Salt must
// start with either APR1Magic or MD5Magic prefix, followed by up to 8 bytes of
// salt prefix and optionally terminate with a '$'. Any remaining salt bytes
// will be ignored.
func GenerateFromPassword(password, salt []byte) ([]byte, error) {
magic, trueSalt, err := decodeSalt(salt)
if err != nil {
return nil, err
}

d := md5.New()
d.Write(password)
d.Write(magic)
d.Write(trueSalt)

d2 := md5.New()
d2.Write(password)
d2.Write(trueSalt)
d2.Write(password)

for i, mixin := 0, d2.Sum(nil); i < len(password); i++ {
d.Write([]byte{mixin[i%16]})
}

for i := len(password); i != 0; i >>= 1 {
if i&1 == 0 {
d.Write([]byte{password[0]})
} else {
d.Write([]byte{0})
}
}

final := d.Sum(nil)

for i := 0; i < 1000; i++ {
d2 := md5.New()
if i&1 == 0 {
d2.Write(final)
} else {
d2.Write(password)
}

if i%3 != 0 {
d2.Write(trueSalt)
}

if i%7 != 0 {
d2.Write(password)
}

if i&1 == 0 {
d2.Write(password)
} else {
d2.Write(final)
}
final = d2.Sum(nil)
}

saltPrefixLength := len(magic) + len(trueSalt)
result := make([]byte, saltPrefixLength, saltPrefixLength+23)
copy(result, salt)
result = append(result, '$')
var v, bits uint
for _, i := range md5CryptSwaps {
v |= (uint(final[i]) << bits)
for bits = bits + 8; bits > 6; bits -= 6 {
result = append(result, itoa64[v&0x3f])
v >>= 6
}
}
result = append(result, itoa64[v&0x3f])
return result, nil
}

// CompareHashAndPassword compares a hashed password with its possible plaintext
// equivalent. Returns nil on success, or an error on failure.
func CompareHashAndPassword(hashedPassword, password []byte) error {
hashed, err := GenerateFromPassword(password, hashedPassword)
if err != nil {
return err
}
if subtle.ConstantTimeCompare(hashed, hashedPassword) == 1 {
return nil
}
return ErrMismatchedHashAndPassword
}

// decodeSalt splits the salt into magic and "true" salt. Returned values are
// subslices of the input slice.
func decodeSalt(salt []byte) (magic []byte, trueSalt []byte, err error) {
maybeAPR1 := salt[:len(APR1Magic)]
maybeMD5 := salt[:len(MD5Magic)]
a := subtle.ConstantTimeCompare(maybeAPR1, APR1Magic)
b := subtle.ConstantTimeCompare(maybeMD5, MD5Magic)
if a+b == 0 {
return nil, nil, ErrUnsupportedSalt
}
if a == 1 {
magic = maybeAPR1
salt = salt[len(APR1Magic):]
} else {
magic = maybeMD5
salt = salt[len(MD5Magic):]
}

if len(salt) == 0 {
return magic, salt, nil
}
slen := len(salt)
if slen > 8 {
slen = 8
}
for i := 0; i < slen; i++ {
if salt[i] == '$' {
return magic, salt[:i], nil
}
}
return magic, salt[:slen], nil
}
112 changes: 112 additions & 0 deletions crypt/md5crypt/md5crypt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package md5crypt

import (
"bytes"
"testing"
)

func TestDecodeSupportedSalt(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
salt, want string
wantMagic []byte
}{
{"$1$", "", MD5Magic},
{"$1$a", "a", MD5Magic},
{"$1$a$", "a", MD5Magic},
{"$1$ab", "ab", MD5Magic},
{"$1$ab$", "ab", MD5Magic},
{"$1$abcdefgh", "abcdefgh", MD5Magic},
{"$1$abcdefgh$", "abcdefgh", MD5Magic},
{"$1$abcdefghi", "abcdefgh", MD5Magic},
{"$1$abcdefghi$", "abcdefgh", MD5Magic},
{"$apr1$", "", APR1Magic},
{"$apr1$a", "a", APR1Magic},
{"$apr1$a$", "a", APR1Magic},
{"$apr1$ab", "ab", APR1Magic},
{"$apr1$ab$", "ab", APR1Magic},
{"$apr1$abcdefgh", "abcdefgh", APR1Magic},
{"$apr1$abcdefgh$", "abcdefgh", APR1Magic},
{"$apr1$abcdefghi", "abcdefgh", APR1Magic},
{"$apr1$abcdefghi$", "abcdefgh", APR1Magic},
} {
magic, got, err := decodeSalt([]byte(tt.salt))
if err != nil {
t.Errorf("Error decoding salt %q: %v.", tt.salt, err)
}
if !bytes.Equal(magic, tt.wantMagic) {
t.Errorf("Decoded magic is %q, want %q.", magic, tt.wantMagic)
}
if string(got) != tt.want {
t.Errorf("Decoded salt %q: got %q, want %q.", tt.salt, got, tt.want)
}

}
}

func TestDecodeUnsupportedSalt(t *testing.T) {
_, _, err := decodeSalt([]byte("$2$whatever$"))
if err != ErrUnsupportedSalt {
t.Errorf("Decoding unsupported salt returned error %v, want %v.", err, ErrUnsupportedSalt)
}
}

func TestGenerateFromPassword(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
password, salt, want string
}{
// test vectors generated using htpasswd(1) and crypt(3) on a Linux system.
{"apache", "$apr1$uvV3T7fu", "$apr1$uvV3T7fu$gvDOBExDieXrhdDxL8.hb."},
{"apache", "$apr1$uvV3T7fu$", "$apr1$uvV3T7fu$gvDOBExDieXrhdDxL8.hb."},
{"apache", "$apr1$uvV3T7fu$gvDOBExDieXrhdDxL8.hb.", "$apr1$uvV3T7fu$gvDOBExDieXrhdDxL8.hb."},
{"topsecret", "$apr1$iKNcB2Be$", "$apr1$iKNcB2Be$.IZPKdGtT8wV99cJ2cmm21"},
{"topsecret", "$1$", "$1$$s/sSkcXFvhLMpizXR5c7/0"},
{"topsecret", "$1$$", "$1$$s/sSkcXFvhLMpizXR5c7/0"},
} {
got, err := GenerateFromPassword([]byte(tt.password), []byte(tt.salt))
if err != nil {
t.Errorf("GenerateFromPassword(%q, %q) returned error %v.", tt.password, tt.salt, err)
}
if string(got) != tt.want {
t.Errorf("GenerateFromPassword(%q, %q): got %q, want %q.", tt.password, tt.salt, got, tt.want)
}
if cap(got) != len(tt.want) {
t.Errorf("Returned slice preallocated more memory than required: got %d, want %d.", cap(got), len(tt.want))
}
}
}

func TestGenerateFromPasswordUnsupported(t *testing.T) {
t.Parallel()
_, err := GenerateFromPassword([]byte("topsecret"), []byte("$2$whatever$"))
if err != ErrUnsupportedSalt {
t.Errorf("GenerateFromPassword with unsupported salt returned error %v, want %v.", err, ErrUnsupportedSalt)
}
}

func TestCompareHashAndPassword(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
password, hashedPassword string
}{
{"apache", "$apr1$uvV3T7fu$gvDOBExDieXrhdDxL8.hb."},
{"topsecret", "$apr1$iKNcB2Be$.IZPKdGtT8wV99cJ2cmm21"},
{"topsecret", "$1$$s/sSkcXFvhLMpizXR5c7/0"},
{"topsecret", "$1$ALwsXB9w$B/FdgWMtcav/q8kuxQ/BK1"},
} {
if err := CompareHashAndPassword([]byte(tt.hashedPassword), []byte(tt.password)); err != nil {
t.Errorf("CompareHashAndPassword(%q, %q) returned error %q, want nil.", tt.hashedPassword, tt.password, err)
}
if err := CompareHashAndPassword([]byte(tt.hashedPassword), []byte(tt.password+"x")); err == nil {
t.Errorf("CompareHashAndPassword(%q, %q) returned no error.", tt.hashedPassword, tt.password+"x")
}
}
}

func TestCompareHashAndPasswordUnsupported(t *testing.T) {
t.Parallel()
if err := CompareHashAndPassword([]byte("topsecret"), []byte("9yH.Z916aam4E")); err != ErrUnsupportedSalt {
t.Errorf("CompareHashAndPassword returned error %v for unsupported salt, want %v.", err, ErrUnsupportedSalt)
}
}

0 comments on commit c5812da

Please sign in to comment.