fix(tfstated): hash passwords instead of relying on the database encryption key

This commit is contained in:
Julien Dessaux 2024-11-16 00:36:17 +01:00
parent 7c96e1b780
commit 5b6da56089
Signed by: adyxax
GPG key ID: F92E51B86E07177E
7 changed files with 66 additions and 40 deletions

View file

@ -19,7 +19,7 @@ var baseURI = url.URL{
Scheme: "http",
}
var db *database.DB
var password string
var adminPassword string
func TestMain(m *testing.M) {
ctx := context.Background()
@ -46,6 +46,9 @@ func TestMain(m *testing.M) {
}
}
database.AdvertiseAdminPassword = func(password string) {
adminPassword = password
}
go run(
ctx,
&config,
@ -59,13 +62,6 @@ func TestMain(m *testing.M) {
os.Exit(1)
}
admin, err := db.LoadAccountByUsername("admin")
if err != nil {
fmt.Fprintf(os.Stderr, "%+v\n", err)
os.Exit(1)
}
password = admin.Password
ret := m.Run()
cancel()
@ -88,7 +84,7 @@ func runHTTPRequest(method string, auth bool, uriRef *url.URL, body io.Reader, t
return
}
if auth {
req.SetBasicAuth("admin", password)
req.SetBasicAuth("admin", adminPassword)
}
resp, err := client.Do(req)
if err != nil {

5
go.mod
View file

@ -4,4 +4,7 @@ go 1.23.3
require github.com/mattn/go-sqlite3 v1.14.24
require go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad // indirect
require (
go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad // indirect
golang.org/x/crypto v0.29.0 // indirect
)

2
go.sum
View file

@ -2,3 +2,5 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad h1:QYbHaaFqx6hMor1L6iMSmyhMFvXQXhKaNk9nefug07M=
go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad/go.mod h1:hvPEWZmyP50in1DH72o5vUvoXFFyfRU6oL+p2tAcbgU=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=

View file

@ -27,7 +27,7 @@ func Middleware(db *database.DB) func(http.Handler) http.Handler {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if password != account.Password {
if !account.CheckPassword(password) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -11,22 +11,27 @@ import (
"go.n16f.net/uuid"
)
// Overriden by tests
var AdvertiseAdminPassword = func(password string) {
slog.Info("Generated an initial admin password, please change it or delete the admin account after your first login", "password", password)
}
func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
account := model.Account{
Username: username,
}
var (
encryptedPassword []byte
created int64
lastLogin int64
)
err := db.QueryRow(
`SELECT id, password, is_admin, created, last_login, settings
`SELECT id, salt, password_hash, is_admin, created, last_login, settings
FROM accounts
WHERE username = ?;`,
username,
).Scan(&account.Id,
&encryptedPassword,
&account.Salt,
&account.PasswordHash,
&account.IsAdmin,
&created,
&lastLogin,
@ -38,11 +43,6 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
}
return nil, err
}
password, err := db.dataEncryptionKey.DecryptAES256(encryptedPassword)
if err != nil {
return nil, err
}
account.Password = string(password)
account.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0)
return &account, nil
@ -69,23 +69,21 @@ func (db *DB) InitAdminAccount() error {
if err = password.Generate(uuid.V4); err != nil {
return fmt.Errorf("failed to generate initial admin password: %w", err)
}
var encryptedPassword []byte
encryptedPassword, err = db.dataEncryptionKey.EncryptAES256([]byte(password.String()))
if err != nil {
return fmt.Errorf("failed to encrypt initial admin password: %w", err)
}
salt := model.GenerateSalt()
hash := model.HashPassword(password.String(), salt)
if _, err = tx.ExecContext(db.ctx,
`INSERT INTO accounts(username, password, is_admin)
VALUES ("admin", :password, TRUE)
ON CONFLICT DO UPDATE SET password = :password
`INSERT INTO accounts(username, salt, password_hash, is_admin)
VALUES ("admin", :salt, :hash, TRUE)
ON CONFLICT DO UPDATE SET password_hash = :hash
WHERE username = "admin";`,
sql.Named("password", encryptedPassword),
sql.Named("salt", salt),
sql.Named("hash", hash),
); err != nil {
return fmt.Errorf("failed to set initial admin password: %w", err)
}
err = tx.Commit()
if err == nil {
slog.Info("Generated an initial admin password, please change it or delete the admin account after your first login", "password", password.String())
AdvertiseAdminPassword(password.String())
}
}
return err

View file

@ -5,7 +5,8 @@ CREATE TABLE schema_version (
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
password BLOB NOT NULL,
salt BLOB NOT NULL,
password_hash BLOB NOT NULL,
is_admin INTEGER NOT NULL DEFAULT FALSE,
created INTEGER NOT NULL DEFAULT (unixepoch()),
last_login INTEGER NOT NULL DEFAULT (unixepoch()),

View file

@ -1,15 +1,41 @@
package model
import "time"
import (
"crypto/sha256"
"crypto/subtle"
"time"
"git.adyxax.org/adyxax/tfstated/pkg/scrypto"
"golang.org/x/crypto/pbkdf2"
)
const (
PBKDF2Iterations = 600000
SaltSize = 32
)
type AccountContextKey struct{}
type Account struct {
Id int
Username string
Password string
Salt []byte
PasswordHash []byte
IsAdmin bool
Created time.Time
LastLogin time.Time
Settings any
}
func (account *Account) CheckPassword(password string) bool {
hash := HashPassword(password, account.Salt)
return subtle.ConstantTimeCompare(hash, account.PasswordHash) == 1
}
func GenerateSalt() []byte {
return scrypto.RandomBytes(SaltSize)
}
func HashPassword(password string, salt []byte) []byte {
return pbkdf2.Key([]byte(password), salt, PBKDF2Iterations, 32, sha256.New)
}