Skip to content

Commit d268163

Browse files
committed
feat: add support for sha256 and sha512 htpasswd formats
Fixes issue #3495 We currently support only bcrypt htpasswd hashes, however bcrypt is not FIPS-140 approved since it uses Blowfish. This PR adds support for sha256 and sha512 formats and enforces that bcrypt be disabled when fips140 mode is enabled. Signed-off-by: Ramkumar Chinchani <[email protected]>
1 parent a0943ec commit d268163

File tree

9 files changed

+305
-23
lines changed

9 files changed

+305
-23
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.4
44

55
require (
66
github.com/99designs/gqlgen v0.17.81
7+
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
78
github.com/Masterminds/semver v1.5.0
89
github.com/alicebob/miniredis/v2 v2.35.0
910
github.com/aquasecurity/trivy v0.65.0
@@ -69,6 +70,7 @@ require (
6970
github.com/stretchr/testify v1.11.1
7071
github.com/swaggo/http-swagger v1.3.4
7172
github.com/swaggo/swag v1.16.6
73+
github.com/tg123/go-htpasswd v1.2.4
7274
github.com/tiendc/go-deepcopy v1.7.1
7375
github.com/vektah/gqlparser/v2 v2.5.30
7476
github.com/zitadel/oidc/v3 v3.45.0

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
689689
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
690690
github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ=
691691
github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
692+
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
693+
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
692694
github.com/GoogleCloudPlatform/docker-credential-gcr v2.0.5+incompatible h1:juIaKLLVhqzP55d8x4cSVgwyQv76Z55/fRv/UBr2KkQ=
693695
github.com/GoogleCloudPlatform/docker-credential-gcr v2.0.5+incompatible/go.mod h1:BB1eHdMLYEFuFdBlRMb0N7YGVdM5s6Pt0njxgvfbGGs=
694696
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc=
@@ -2055,6 +2057,8 @@ github.com/testcontainers/testcontainers-go/modules/localstack v0.38.0 h1:3ljIy6
20552057
github.com/testcontainers/testcontainers-go/modules/localstack v0.38.0/go.mod h1:BTsbqWC9huPV8Jg8k46Jz4x1oRAA9XGxneuuOOIrtKY=
20562058
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
20572059
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
2060+
github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=
2061+
github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=
20582062
github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg=
20592063
github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU=
20602064
github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI=

pkg/api/htpasswd.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"bufio"
55
"context"
6+
"crypto/fips140"
67
"errors"
78
"os"
89
"os/signal"
@@ -11,8 +12,10 @@ import (
1112
"syscall"
1213

1314
"github.com/fsnotify/fsnotify"
15+
"github.com/tg123/go-htpasswd"
1416
"golang.org/x/crypto/bcrypt"
1517

18+
zerr "zotregistry.dev/zot/v2/errors"
1619
"zotregistry.dev/zot/v2/pkg/log"
1720
)
1821

@@ -87,14 +90,35 @@ func (s *HTPasswd) Authenticate(username, passphrase string) (ok, present bool)
8790
return false, false
8891
}
8992

90-
err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase))
91-
ok = err == nil
93+
err := zerr.ErrInvalidCred
94+
95+
if strings.HasPrefix(passphraseHash, "$2a$") || strings.HasPrefix(passphraseHash, "$2b$") ||
96+
strings.HasPrefix(passphraseHash, "$2y$") {
9297

93-
if err != nil && !errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
94-
// Log that user's hash has unsupported format. Better than silently return 401.
95-
s.log.Warn().Err(err).Str("username", username).Msg("htpasswd bcrypt compare failed")
98+
err = bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase))
99+
100+
if err != nil && !errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
101+
// Log that user's hash has unsupported format. Better than silently return 401.
102+
s.log.Warn().Err(err).Str("username", username).Msg("htpasswd bcrypt compare failed")
103+
}
104+
105+
if fips140.Enabled() {
106+
s.log.Warn().Str("username", username).Msg("htpasswd bcrypt failed since fips140 is enabled")
107+
108+
err = zerr.ErrInvalidCred // bcrypt is not a fips140 approved algo
109+
}
110+
} else {
111+
ep, err := htpasswd.AcceptCryptSha(passphraseHash) // sha256 or sha512
112+
if err != nil {
113+
// Log that user's hash has unsupported format. Better than silently return 401.
114+
s.log.Warn().Err(err).Str("username", username).Msg("htpasswd crypt parsing failed")
115+
}
116+
117+
ok = ep.MatchesPassword(passphrase)
96118
}
97119

120+
ok = err == nil
121+
98122
return
99123
}
100124

pkg/api/htpasswd_test.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestHTPasswdWatcherOriginal(t *testing.T) {
1919
username, _ := test.GenerateRandomString()
2020
password1, _ := test.GenerateRandomString()
2121
password2, _ := test.GenerateRandomString()
22-
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password1))
22+
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username, password1))
2323

2424
defer os.Remove(htpasswdPath)
2525

@@ -49,7 +49,7 @@ func TestHTPasswdWatcherOriginal(t *testing.T) {
4949
So(present, ShouldBeTrue)
5050

5151
// 2. Change file
52-
err = os.WriteFile(htpasswdPath, []byte(test.GetCredString(username, password2)), 0o600)
52+
err = os.WriteFile(htpasswdPath, []byte(test.GetBcryptCredString(username, password2)), 0o600)
5353
So(err, ShouldBeNil)
5454

5555
// 3. Give some time for the background task
@@ -98,8 +98,8 @@ func TestHTPasswdWatcher(t *testing.T) {
9898
username2, _ := test.GenerateRandomString()
9999
password2, _ := test.GenerateRandomString()
100100

101-
htpasswdPath1 := test.MakeHtpasswdFileFromString(test.GetCredString(username1, password1))
102-
htpasswdPath2 := test.MakeHtpasswdFileFromString(test.GetCredString(username2, password2))
101+
htpasswdPath1 := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username1, password1))
102+
htpasswdPath2 := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username2, password2))
103103

104104
defer os.Remove(htpasswdPath1)
105105
defer os.Remove(htpasswdPath2)
@@ -150,15 +150,15 @@ func TestHTPasswdWatcher(t *testing.T) {
150150
So(present, ShouldBeTrue)
151151

152152
// Change file content and verify automatic reload
153-
err = os.WriteFile(htpasswdPath1, []byte(test.GetCredString(username1, password2)), 0o600)
153+
err = os.WriteFile(htpasswdPath1, []byte(test.GetBcryptCredString(username1, password2)), 0o600)
154154
So(err, ShouldBeNil)
155155
time.Sleep(100 * time.Millisecond)
156156
ok, present = htp.Authenticate(username1, password2)
157157
So(ok, ShouldBeTrue)
158158
So(present, ShouldBeTrue)
159159

160160
// Test multiple users
161-
multiUserContent := test.GetCredString(username1, password1) + "\n" + test.GetCredString(username2, password2)
161+
multiUserContent := test.GetBcryptCredString(username1, password1) + "\n" + test.GetBcryptCredString(username2, password2)
162162
err = os.WriteFile(htpasswdPath1, []byte(multiUserContent), 0o600)
163163
So(err, ShouldBeNil)
164164
time.Sleep(100 * time.Millisecond)
@@ -194,8 +194,8 @@ func TestHTPasswdWatcher(t *testing.T) {
194194
username2, _ := test.GenerateRandomString()
195195
password2, _ := test.GenerateRandomString()
196196

197-
htpasswdPath1 := test.MakeHtpasswdFileFromString(test.GetCredString(username1, password1))
198-
htpasswdPath2 := test.MakeHtpasswdFileFromString(test.GetCredString(username2, password2))
197+
htpasswdPath1 := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username1, password1))
198+
htpasswdPath2 := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username2, password2))
199199

200200
defer os.Remove(htpasswdPath1)
201201
defer os.Remove(htpasswdPath2)
@@ -236,7 +236,7 @@ func TestHTPasswdWatcher(t *testing.T) {
236236
So(present, ShouldBeTrue)
237237

238238
// Test file rename (should not trigger reload)
239-
htpasswdPath3 := test.MakeHtpasswdFileFromString(test.GetCredString(username1, password1))
239+
htpasswdPath3 := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username1, password1))
240240
defer os.Remove(htpasswdPath3)
241241
err = htw.ChangeFile(htpasswdPath3)
242242
So(err, ShouldBeNil)
@@ -302,8 +302,8 @@ func TestHTPasswdWatcher(t *testing.T) {
302302
username2, _ := test.GenerateRandomString()
303303
password2, _ := test.GenerateRandomString()
304304

305-
htpasswdPath1 := test.MakeHtpasswdFileFromString(test.GetCredString(username1, password1))
306-
htpasswdPath2 := test.MakeHtpasswdFileFromString(test.GetCredString(username2, password2))
305+
htpasswdPath1 := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username1, password1))
306+
htpasswdPath2 := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username2, password2))
307307

308308
defer os.Remove(htpasswdPath1)
309309
defer os.Remove(htpasswdPath2)
@@ -408,7 +408,7 @@ func TestHTPasswdWatcher(t *testing.T) {
408408
// Test 2: File watching with fsnotify resources cleanup
409409
username, _ := test.GenerateRandomString()
410410
password, _ := test.GenerateRandomString()
411-
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password))
411+
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username, password))
412412

413413
defer os.Remove(htpasswdPath)
414414

@@ -490,7 +490,7 @@ func TestHTPasswdWatcher(t *testing.T) {
490490
So(test.WaitForLogMessages(logBuffer, "htpasswd watcher terminating...", 1, 5*time.Second), ShouldBeTrue)
491491

492492
// Test file with empty lines and comments
493-
content := "\n\n" + test.GetCredString(username, password) + "\n# comment\n"
493+
content := "\n\n" + test.GetBcryptCredString(username, password) + "\n# comment\n"
494494
commentedPath := test.MakeHtpasswdFileFromString(content)
495495

496496
defer os.Remove(commentedPath)
@@ -520,7 +520,7 @@ func TestHTPasswdWatcher(t *testing.T) {
520520
So(err, ShouldBeNil)
521521

522522
// Load some initial data
523-
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password))
523+
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetBcryptCredString(username, password))
524524
defer os.Remove(htpasswdPath)
525525

526526
// Load initial file (this will populate the store)

pkg/test/common/fs.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"strings"
1313
"time"
1414

15+
"github.com/GehirnInc/crypt"
1516
"golang.org/x/crypto/bcrypt"
1617
)
1718

@@ -213,7 +214,7 @@ func ReadLogFileAndCountStringOccurence(logPath string, stringToMatch string,
213214
}
214215
}
215216

216-
func GetCredString(username, password string) string {
217+
func GetBcryptCredString(username, password string) string {
217218
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
218219
if err != nil {
219220
panic(err)
@@ -224,6 +225,51 @@ func GetCredString(username, password string) string {
224225
return usernameAndHash
225226
}
226227

228+
// Prefixes
229+
const PrefixCryptSha256 = "$5$"
230+
const PrefixCryptSha512 = "$6$"
231+
const Separator = "$"
232+
233+
func shaCrypt(password string, rounds string, salt string, prefix string) string {
234+
235+
var ret string
236+
var sb strings.Builder
237+
sb.WriteString(prefix)
238+
if len(rounds) > 0 {
239+
sb.WriteString(rounds)
240+
sb.WriteString(Separator)
241+
}
242+
sb.WriteString(salt)
243+
totalSalt := sb.String()
244+
245+
if prefix == PrefixCryptSha512 {
246+
crypt := crypt.SHA512.New()
247+
ret, _ = crypt.Generate([]byte(password), []byte(totalSalt))
248+
249+
} else if prefix == PrefixCryptSha256 {
250+
crypt := crypt.SHA256.New()
251+
ret, _ = crypt.Generate([]byte(password), []byte(totalSalt))
252+
}
253+
254+
return ret[len(totalSalt)+1:]
255+
}
256+
257+
func GetSHA256CredString(username, password string) string {
258+
hash := shaCrypt(password, "rounds=5000", "saltstring", PrefixCryptSha256)
259+
260+
usernameAndHash := fmt.Sprintf("%s:%s\n", username, string(hash))
261+
262+
return usernameAndHash
263+
}
264+
265+
func GetSHA512CredString(username, password string) string {
266+
hash := shaCrypt(password, "rounds=5000", "saltstring", PrefixCryptSha512)
267+
268+
usernameAndHash := fmt.Sprintf("%s:%s\n", username, string(hash))
269+
270+
return usernameAndHash
271+
}
272+
227273
func MakeHtpasswdFileFromString(fileContent string) string {
228274
htpasswdFile, err := os.CreateTemp("", "htpasswd-")
229275
if err != nil {

pkg/test/common/fs_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ func TestGetCredString(t *testing.T) {
277277
pass[i] = 'Y'
278278
}
279279

280-
f := func() { tcommon.GetCredString("testUser", string(pass)) }
280+
f := func() { tcommon.GetBcryptCredString("testUser", string(pass)) }
281281
So(f, ShouldPanicWith, bcrypt.ErrPasswordTooLong)
282282
})
283283
}

pkg/test/image-utils/upload_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ func TestUploadImage(t *testing.T) {
178178

179179
user1 := "test"
180180
password1 := "test"
181-
testString1 := tcommon.GetCredString(user1, password1)
181+
testString1 := tcommon.GetBcryptCredString(user1, password1)
182182

183183
htpasswdPath := tcommon.MakeHtpasswdFileFromString(testString1)
184184
defer os.Remove(htpasswdPath)
@@ -495,7 +495,7 @@ func TestInjectUploadImageWithBasicAuth(t *testing.T) {
495495

496496
user := "user"
497497
password := "password"
498-
testString := tcommon.GetCredString(user, password)
498+
testString := tcommon.GetBcryptCredString(user, password)
499499

500500
htpasswdPath := tcommon.MakeHtpasswdFileFromString(testString)
501501
defer os.Remove(htpasswdPath)

0 commit comments

Comments
 (0)