Skip to content

Commit d3b105a

Browse files
committed
Replace SHA256 with BCrypt for hashed passwords
1 parent 5c04e37 commit d3b105a

File tree

6 files changed

+77
-44
lines changed

6 files changed

+77
-44
lines changed

ROAD_TO_WS4SQL.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ The version in this branch is a work in progress to add features and (unfortunat
88

99
- SQLite is embedded via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) and CGO. Should be way faster.
1010
- Support for DuckDB (see below).
11-
- Target platforms (because of CGO) are now 4 (`win/amd64`, `macos/arm64`, `linux/amd64`, `linux/arm64`).
12-
- For Docker, `linux/amd64` and `linux/arm64`.
1311
- [**BREAKING CHANGE**] When running the app, the config files must be specified on the command line, the file paths cannot be used anymore (there). This is described in the "Migration" section below. The file path is in the config file.
14-
- The only exception is a "simple case" to serve a file path without any config. This can be done with the new `--quick-db` parameter.
12+
- The only exception is a "simple case" to serve a file path without any config. This can be done with the new `--quick-db` parameter.
13+
- [**BREAKING CHANGE**] Hashed passwords in auth config must now be hashed with BCrypt, not SHA256.
1514
- Fail fast if the request is empty, don't even attempt to authenticate.
15+
- Target platforms (because of CGO) are now 4 (`win/amd64`, `macos/arm64`, `linux/amd64`, `linux/arm64`).
16+
- For Docker, `linux/amd64` and `linux/arm64`.
1617
- Docker images are now based on `distroless/static-debian12`.
1718
- Docker images are now hosted on Github's Container Registry.
1819

@@ -30,6 +31,8 @@ database:
3031
readOnly: false # Same as before, but moved here.
3132
```
3233
34+
- For any hashed password previously specified in an `auth` block, the hash must be BCrypt, not SHA256.
35+
3336
## Specific to DuckDB
3437

3538
- `noFail` is not supported.

src/authentication.go

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,33 @@ package main
1919
import (
2020
"bytes"
2121
"context"
22-
"crypto/sha256"
2322
"database/sql"
24-
"encoding/hex"
2523
"errors"
2624
"fmt"
2725
"strings"
2826

2927
mllog "github.com/proofrock/go-mylittlelogger"
3028
"github.com/proofrock/ws4sql/structs"
3129
"github.com/proofrock/ws4sql/utils"
30+
"golang.org/x/crypto/bcrypt"
3231
)
3332

3433
const (
3534
authModeInline = "INLINE"
3635
authModeHttp = "HTTP"
3736
)
3837

38+
// Finds the user in the credentials
39+
func findCred(db *structs.Db, user string) *structs.CredentialsCfg {
40+
for i := range db.Auth.ByCredentials {
41+
cred := &db.Auth.ByCredentials[i] // don't want to copy the struct
42+
if cred.User == user {
43+
return cred
44+
}
45+
}
46+
return nil
47+
}
48+
3949
// Checks auth. If auth is granted, returns nil, if not an error.
4050
// Version with explicit credentials, called by the authentication
4151
// middleware and by the "other" auth function, that accepts
@@ -55,13 +65,25 @@ func applyAuthCreds(db *structs.Db, user, password string) error {
5565
return nil
5666
}
5767
} else {
58-
passedSHA := sha256.Sum256([]byte(password))
59-
expectedSHA, ok := db.Auth.HashedCreds[user]
60-
if !ok || !bytes.Equal(expectedSHA, passedSHA[:]) {
68+
cred := findCred(db, user) // O(n) but n is small
69+
if cred == nil {
6170
return errors.New("wrong credentials")
6271
}
72+
cachedPwd := cred.ClearTextPassword.Load()
73+
pwdBytes := []byte(password)
74+
if cachedPwd != nil {
75+
if bytes.Equal(pwdBytes, cachedPwd.([]byte)) {
76+
return nil
77+
} else {
78+
return errors.New("wrong credentials")
79+
}
80+
}
81+
if bcrypt.CompareHashAndPassword([]byte(cred.HashedPassword), []byte(password)) == nil {
82+
cred.ClearTextPassword.Store(pwdBytes)
83+
return nil
84+
}
85+
return errors.New("wrong credentials")
6386
}
64-
return nil
6587
}
6688

6789
// Checks auth. If auth is granted, returns nil, if not an error.
@@ -92,30 +114,23 @@ func parseAuth(db *structs.Db) {
92114
}
93115
mllog.StdOut(" + Authentication enabled, with query")
94116
} else {
95-
(*db).Auth.HashedCreds = make(map[string][]byte)
96117
for i := range auth.ByCredentials {
97-
if auth.ByCredentials[i].User == "" {
118+
cred := &auth.ByCredentials[i] // don't want to copy the struct
119+
if cred.User == "" {
98120
mllog.Fatal("no user for credential")
99121
}
100-
var b []byte
101-
if (auth.ByCredentials[i].HashedPassword == "") == (auth.ByCredentials[i].Password == "") {
122+
if (cred.HashedPassword == "") == (cred.Password == "") {
102123
mllog.Fatal("one and only one of 'password' and 'hashedPassword' must be specified")
103124
}
104-
// Converts all the password to hashes, if they weren't passed as hashes in the
105-
// first place. For uniformity and (vaguely) security.
106-
if auth.ByCredentials[i].HashedPassword != "" {
107-
var err error
108-
b, err = hex.DecodeString(auth.ByCredentials[i].HashedPassword)
109-
if err != nil || len(b) != 32 {
110-
mllog.Fatalf("for db '%s', hashedPassword doesn't seem to be SHA256/hex.", *db.DatabaseDef.Id)
111-
}
112-
} else {
113-
bytes32 := sha256.Sum256([]byte(auth.ByCredentials[i].Password))
114-
b = bytes32[:]
125+
// Populates the cleartext password cache, so that there is only one
126+
// point where the password is stored in clear text.
127+
// If the password is specified as a BCrypt hash, it will be cached
128+
// when the BCrypt "puzzle" is solved for the first time.
129+
if cred.Password != "" {
130+
cred.ClearTextPassword.Store([]byte(cred.Password))
115131
}
116-
(*db).Auth.HashedCreds[auth.ByCredentials[i].User] = b
117132
}
118-
mllog.StdOutf(" + Authentication enabled, with %d credentials", len((*db).Auth.HashedCreds))
133+
mllog.StdOutf(" + Authentication enabled, with %d credentials", len(auth.ByCredentials))
119134
}
120135

121136
if auth.CustomErrorCode != nil {

src/authentication_test.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func TestSetupAuthCreds(t *testing.T) {
5151
Path: utils.Ptr("../test/test1.db"),
5252
DisableWALMode: utils.Ptr(true),
5353
},
54-
Auth: &structs.Authr{
54+
Auth: &structs.Auth{
5555
Mode: "INLINE",
5656
ByCredentials: []structs.CredentialsCfg{
5757
{
@@ -60,7 +60,7 @@ func TestSetupAuthCreds(t *testing.T) {
6060
},
6161
{
6262
User: "paolo",
63-
HashedPassword: "b133a0c0e9bee3be20163d2ad31d6248db292aa6dcb1ee087a2aa50e0fc75ae2", // "ciao"
63+
HashedPassword: "$2a$10$r4UdAVQ9SroqwTEX3tGCDO3coSFQSEp7QINiSwYb5ARhD7wkTPYtS", // "ciao"
6464
},
6565
},
6666
},
@@ -75,7 +75,7 @@ func TestSetupAuthCreds(t *testing.T) {
7575
"CREATE TABLE AUTH (USER TEXT PRIMARY KEY, PASS TEXT)",
7676
"INSERT INTO AUTH VALUES ('_pietro', 'hey'), ('_paolo', 'ciao')",
7777
},
78-
Auth: &structs.Authr{
78+
Auth: &structs.Auth{
7979
Mode: "inline", // check if case insensitive
8080
ByQuery: "SELECT 1 FROM AUTH WHERE USER = :user AND PASS = :password",
8181
},
@@ -224,6 +224,14 @@ func TestAuthWithCreds2(t *testing.T) {
224224
t.Errorf("did not succeed, but should have: %s", body)
225225
return
226226
}
227+
228+
// Second time it should use the cached password
229+
code, body, _ = call("test1", req, t)
230+
231+
if code != 200 {
232+
t.Errorf("did not succeed, but should have: %s", body)
233+
return
234+
}
227235
}
228236

229237
func TestNoAuthWithQuery1(t *testing.T) {
@@ -336,7 +344,7 @@ func TestBASetupAuthCreds(t *testing.T) {
336344
Path: utils.Ptr("../test/test1.db"),
337345
DisableWALMode: utils.Ptr(true),
338346
},
339-
Auth: &structs.Authr{
347+
Auth: &structs.Auth{
340348
Mode: "HTTP",
341349
ByCredentials: []structs.CredentialsCfg{
342350
{
@@ -345,7 +353,7 @@ func TestBASetupAuthCreds(t *testing.T) {
345353
},
346354
{
347355
User: "paolo",
348-
HashedPassword: "b133a0c0e9bee3be20163d2ad31d6248db292aa6dcb1ee087a2aa50e0fc75ae2", // "ciao"
356+
HashedPassword: "$2a$10$r4UdAVQ9SroqwTEX3tGCDO3coSFQSEp7QINiSwYb5ARhD7wkTPYtS", // "ciao"
349357
},
350358
},
351359
},
@@ -360,7 +368,7 @@ func TestBASetupAuthCreds(t *testing.T) {
360368
"CREATE TABLE AUTH (USER TEXT PRIMARY KEY, PASS TEXT)",
361369
"INSERT INTO AUTH VALUES ('_pietro', 'hey'), ('_paolo', 'ciao')",
362370
},
363-
Auth: &structs.Authr{
371+
Auth: &structs.Auth{
364372
Mode: "http", // check if case insensitive
365373
ByQuery: "SELECT 1 FROM AUTH WHERE USER = :user AND PASS = :password",
366374
},
@@ -567,7 +575,7 @@ func TestCustomCodeSetup(t *testing.T) {
567575
Path: utils.Ptr("../test/test1.db"),
568576
DisableWALMode: utils.Ptr(true),
569577
},
570-
Auth: &structs.Authr{
578+
Auth: &structs.Auth{
571579
Mode: "HTTP",
572580
CustomErrorCode: &errCode,
573581
ByCredentials: []structs.CredentialsCfg{
@@ -577,7 +585,7 @@ func TestCustomCodeSetup(t *testing.T) {
577585
},
578586
{
579587
User: "paolo",
580-
HashedPassword: "b133a0c0e9bee3be20163d2ad31d6248db292aa6dcb1ee087a2aa50e0fc75ae2", // "ciao"
588+
HashedPassword: "$2a$10$r4UdAVQ9SroqwTEX3tGCDO3coSFQSEp7QINiSwYb5ARhD7wkTPYtS", // "ciao"
581589
},
582590
},
583591
},
@@ -592,7 +600,7 @@ func TestCustomCodeSetup(t *testing.T) {
592600
"CREATE TABLE AUTH (USER TEXT PRIMARY KEY, PASS TEXT)",
593601
"INSERT INTO AUTH VALUES ('_pietro', 'hey'), ('_paolo', 'ciao')",
594602
},
595-
Auth: &structs.Authr{
603+
Auth: &structs.Auth{
596604
Mode: "inline", // check if case insensitive
597605
CustomErrorCode: &errCode,
598606
ByQuery: "SELECT 1 FROM AUTH WHERE USER = :user AND PASS = :password",

src/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/proofrock/go-mylittlelogger v0.4.0
1515
github.com/robfig/cron/v3 v3.0.1
1616
github.com/wI2L/jettison v0.7.4
17+
golang.org/x/crypto v0.33.0
1718
gopkg.in/yaml.v2 v2.4.0
1819
)
1920

src/go.sum

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,14 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
8989
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
9090
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
9191
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
92-
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
93-
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
94-
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
95-
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
96-
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
97-
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
92+
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
93+
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
94+
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4=
95+
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
96+
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
97+
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
98+
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
99+
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
98100
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
99101
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
100102
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

src/structs/configFile.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package structs
1919
import (
2020
"database/sql"
2121
"sync"
22+
"sync/atomic"
2223
)
2324

2425
// These are for parsing the config file (from YAML)
@@ -39,14 +40,17 @@ type CredentialsCfg struct {
3940
User string `yaml:"user"`
4041
Password string `yaml:"password"`
4142
HashedPassword string `yaml:"hashedPassword"`
43+
// This is a cache: it's the Password if specified in cleartext, or
44+
// gets populated with the cleartext password when the hashed one is
45+
// checked.
46+
ClearTextPassword atomic.Value
4247
}
4348

44-
type Authr struct {
49+
type Auth struct {
4550
Mode string `yaml:"mode"` // 'INLINE' or 'HTTP'
4651
CustomErrorCode *int `yaml:"customErrorCode"`
4752
ByQuery string `yaml:"byQuery"`
4853
ByCredentials []CredentialsCfg `yaml:"byCredentials"`
49-
HashedCreds map[string][]byte
5054
}
5155

5256
type StoredStatement struct {
@@ -66,7 +70,7 @@ type DatabaseDef struct {
6670
type Db struct {
6771
ConfigFilePath string
6872
DatabaseDef DatabaseDef `yaml:"database"`
69-
Auth *Authr `yaml:"auth"`
73+
Auth *Auth `yaml:"auth"`
7074
CORSOrigin string `yaml:"corsOrigin"`
7175
UseOnlyStoredStatements bool `yaml:"useOnlyStoredStatements"`
7276
Maintenance *ScheduledTask `yaml:"maintenance"`

0 commit comments

Comments
 (0)