Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

x/crypto/crypt/md5crypt: MD5 APR1 implementation of crypt(3). #192

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
x/crypto/crypt/md5crypt: MD5 APR1 implementation of crypt(3).
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
commit c5812da7de9dd4eef7c601e293f4836b036b9570
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)
}
}