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", Scheme: "http",
} }
var db *database.DB var db *database.DB
var password string var adminPassword string
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
ctx := context.Background() ctx := context.Background()
@ -46,6 +46,9 @@ func TestMain(m *testing.M) {
} }
} }
database.AdvertiseAdminPassword = func(password string) {
adminPassword = password
}
go run( go run(
ctx, ctx,
&config, &config,
@ -59,13 +62,6 @@ func TestMain(m *testing.M) {
os.Exit(1) 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() ret := m.Run()
cancel() cancel()
@ -88,7 +84,7 @@ func runHTTPRequest(method string, auth bool, uriRef *url.URL, body io.Reader, t
return return
} }
if auth { if auth {
req.SetBasicAuth("admin", password) req.SetBasicAuth("admin", adminPassword)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { 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 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= 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 h1:QYbHaaFqx6hMor1L6iMSmyhMFvXQXhKaNk9nefug07M=
go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad/go.mod h1:hvPEWZmyP50in1DH72o5vUvoXFFyfRU6oL+p2tAcbgU= 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) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
if password != account.Password { if !account.CheckPassword(password) {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }

View file

@ -11,22 +11,27 @@ import (
"go.n16f.net/uuid" "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) { func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
account := model.Account{ account := model.Account{
Username: username, Username: username,
} }
var ( var (
encryptedPassword []byte
created int64 created int64
lastLogin int64 lastLogin int64
) )
err := db.QueryRow( 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 FROM accounts
WHERE username = ?;`, WHERE username = ?;`,
username, username,
).Scan(&account.Id, ).Scan(&account.Id,
&encryptedPassword, &account.Salt,
&account.PasswordHash,
&account.IsAdmin, &account.IsAdmin,
&created, &created,
&lastLogin, &lastLogin,
@ -38,11 +43,6 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
} }
return nil, err 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.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0) account.LastLogin = time.Unix(lastLogin, 0)
return &account, nil return &account, nil
@ -69,23 +69,21 @@ func (db *DB) InitAdminAccount() error {
if err = password.Generate(uuid.V4); err != nil { if err = password.Generate(uuid.V4); err != nil {
return fmt.Errorf("failed to generate initial admin password: %w", err) return fmt.Errorf("failed to generate initial admin password: %w", err)
} }
var encryptedPassword []byte salt := model.GenerateSalt()
encryptedPassword, err = db.dataEncryptionKey.EncryptAES256([]byte(password.String())) hash := model.HashPassword(password.String(), salt)
if err != nil {
return fmt.Errorf("failed to encrypt initial admin password: %w", err)
}
if _, err = tx.ExecContext(db.ctx, if _, err = tx.ExecContext(db.ctx,
`INSERT INTO accounts(username, password, is_admin) `INSERT INTO accounts(username, salt, password_hash, is_admin)
VALUES ("admin", :password, TRUE) VALUES ("admin", :salt, :hash, TRUE)
ON CONFLICT DO UPDATE SET password = :password ON CONFLICT DO UPDATE SET password_hash = :hash
WHERE username = "admin";`, WHERE username = "admin";`,
sql.Named("password", encryptedPassword), sql.Named("salt", salt),
sql.Named("hash", hash),
); err != nil { ); err != nil {
return fmt.Errorf("failed to set initial admin password: %w", err) return fmt.Errorf("failed to set initial admin password: %w", err)
} }
err = tx.Commit() err = tx.Commit()
if err == nil { 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 return err

View file

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

View file

@ -1,15 +1,41 @@
package model 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 AccountContextKey struct{}
type Account struct { type Account struct {
Id int Id int
Username string Username string
Password string Salt []byte
PasswordHash []byte
IsAdmin bool IsAdmin bool
Created time.Time Created time.Time
LastLogin time.Time LastLogin time.Time
Settings any 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)
}