1
0
Fork 1
mirror of https://github.com/0x2E/fusion.git synced 2025-06-08 05:27:15 +09:00

Check passwords based on hashes rather than plaintext

fusion's current password mechanism is vulnerable to a timing attack:

https://en.wikipedia.org/wiki/Timing_attack

Because fusion checks passwords using simple character-by-character string comparison, a password attempt that begins with the correct characters will take longer to evaluate than one that starts with incorrect characters. For example, if the correct password is 'platypus123' then a password attempt of 'plates' will take longer to evaluate than 'spoons' because 'plates' and 'platypus' share a common prefix. An attacker who attempts the password 'plates' will know that they likely have the correct prefix.

To prevent the timing attack, this change hashes the user's password using PBKDF2 and compares hashes using subtle.ConstantTimeCompare, which is specifically designed to prevent timing attacks.
This commit is contained in:
Michael Lynch 2025-01-12 11:31:01 -05:00
parent 6cc04c07d5
commit bfd4e8c66b
6 changed files with 156 additions and 17 deletions

View file

@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/0x2e/fusion/auth"
"github.com/0x2e/fusion/conf" "github.com/0x2e/fusion/conf"
"github.com/0x2e/fusion/frontend" "github.com/0x2e/fusion/frontend"
"github.com/0x2e/fusion/pkg/logx" "github.com/0x2e/fusion/pkg/logx"
@ -26,7 +27,7 @@ import (
type Params struct { type Params struct {
Host string Host string
Port int Port int
Password string PasswordHash auth.HashedPassword
UseSecureCookie bool UseSecureCookie bool
TLSCert string TLSCert string
TLSKey string TLSKey string
@ -70,7 +71,7 @@ func Run(params Params) {
r.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{ r.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
})) }))
r.Use(session.Middleware(sessions.NewCookieStore([]byte(params.Password)))) r.Use(session.Middleware(sessions.NewCookieStore(params.PasswordHash.Bytes())))
r.Pre(middleware.RemoveTrailingSlash()) r.Pre(middleware.RemoveTrailingSlash())
r.Use(func(next echo.HandlerFunc) echo.HandlerFunc { r.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@ -88,7 +89,7 @@ func Run(params Params) {
})) }))
loginAPI := Session{ loginAPI := Session{
Password: params.Password, PasswordHash: params.PasswordHash,
UseSecureCookie: params.UseSecureCookie, UseSecureCookie: params.UseSecureCookie,
} }
r.POST("/api/sessions", loginAPI.Create) r.POST("/api/sessions", loginAPI.Create)

View file

@ -3,12 +3,13 @@ package api
import ( import (
"net/http" "net/http"
"github.com/0x2e/fusion/auth"
"github.com/labstack/echo-contrib/session" "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
type Session struct { type Session struct {
Password string PasswordHash auth.HashedPassword
UseSecureCookie bool UseSecureCookie bool
} }
@ -25,7 +26,12 @@ func (s Session) Create(c echo.Context) error {
return err return err
} }
if req.Password != s.Password { attemptedPasswordHash, err := auth.HashPassword(req.Password)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid password")
}
if correctPasswordHash := s.PasswordHash; !attemptedPasswordHash.Equals(correctPasswordHash) {
return echo.NewHTTPError(http.StatusUnauthorized, "Wrong password") return echo.NewHTTPError(http.StatusUnauthorized, "Wrong password")
} }

43
auth/password.go Normal file
View file

@ -0,0 +1,43 @@
package auth
import (
"crypto/sha256"
"crypto/subtle"
"errors"
"golang.org/x/crypto/pbkdf2"
)
var ErrPasswordTooShort = errors.New("password must be non-empty")
type HashedPassword struct {
hash []byte
}
func (hp HashedPassword) Bytes() []byte {
return hp.hash
}
func (hp HashedPassword) Equals(other HashedPassword) bool {
return subtle.ConstantTimeCompare(hp.hash, other.hash) != 0
}
func HashPassword(password string) (HashedPassword, error) {
if len(password) == 0 {
return HashedPassword{}, ErrPasswordTooShort
}
// These bytes are chosen at random. It's insecure to use a static salt to
// hash a set of passwords, but since we're only ever hashing a single
// password, using a static salt is fine. The salt prevents an attacker from
// using a rainbow table to retrieve the plaintext password from the hashed
// version, and that's all that's necessary for fusion's needs.
staticSalt := []byte{36, 129, 1, 54}
iter := 100
keyLen := 32
hash := pbkdf2.Key([]byte(password), staticSalt, iter, keyLen, sha256.New)
return HashedPassword{
hash: hash,
}, nil
}

71
auth/password_test.go Normal file
View file

@ -0,0 +1,71 @@
package auth_test
import (
"testing"
"github.com/0x2e/fusion/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHashPassword(t *testing.T) {
for _, tt := range []struct {
explanation string
input string
wantErr error
}{
{
explanation: "valid password succeeds",
input: "mypassword",
wantErr: nil,
},
{
explanation: "empty password returns ErrPasswordTooShort",
input: "",
wantErr: auth.ErrPasswordTooShort,
},
} {
t.Run(tt.explanation, func(t *testing.T) {
got, err := auth.HashPassword(tt.input)
require.Equal(t, tt.wantErr, err)
if tt.wantErr == nil {
assert.NotEmpty(t, got.Bytes())
}
})
}
}
func TestHashedPasswordEquals(t *testing.T) {
for _, tt := range []struct {
explanation string
hashedPassword1 auth.HashedPassword
hashedPassword2 auth.HashedPassword
want bool
}{
{
explanation: "same passwords match",
hashedPassword1: mustHashPassword("password1"),
hashedPassword2: mustHashPassword("password1"),
want: true,
},
{
explanation: "different passwords don't match",
hashedPassword1: mustHashPassword("password1"),
hashedPassword2: mustHashPassword("password2"),
want: false,
},
} {
t.Run(tt.explanation, func(t *testing.T) {
assert.Equal(t, tt.want, tt.hashedPassword1.Equals(tt.hashedPassword2))
})
}
}
func mustHashPassword(password string) auth.HashedPassword {
hashedPassword, err := auth.HashPassword(password)
if err != nil {
panic(err)
}
return hashedPassword
}

View file

@ -32,7 +32,7 @@ func main() {
api.Run(api.Params{ api.Run(api.Params{
Host: config.Host, Host: config.Host,
Port: config.Port, Port: config.Port,
Password: config.Password, PasswordHash: config.PasswordHash,
UseSecureCookie: config.SecureCookie, UseSecureCookie: config.SecureCookie,
TLSCert: config.TLSCert, TLSCert: config.TLSCert,
TLSKey: config.TLSKey, TLSKey: config.TLSKey,

View file

@ -6,6 +6,7 @@ import (
"log" "log"
"os" "os"
"github.com/0x2e/fusion/auth"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@ -17,13 +18,13 @@ const (
) )
type Conf struct { type Conf struct {
Host string `env:"HOST" envDefault:"0.0.0.0"` Host string
Port int `env:"PORT" envDefault:"8080"` Port int
Password string `env:"PASSWORD"` PasswordHash auth.HashedPassword
DB string `env:"DB" envDefault:"fusion.db"` DB string
SecureCookie bool `env:"SECURE_COOKIE" envDefault:"false"` SecureCookie bool
TLSCert string `env:"TLS_CERT"` TLSCert string
TLSKey string `env:"TLS_KEY"` TLSKey string
} }
func Load() (Conf, error) { func Load() (Conf, error) {
@ -35,7 +36,15 @@ func Load() (Conf, error) {
} else { } else {
log.Printf("read configuration from %s", dotEnvFilename) log.Printf("read configuration from %s", dotEnvFilename)
} }
var conf Conf var conf struct {
Host string `env:"HOST" envDefault:"0.0.0.0"`
Port int `env:"PORT" envDefault:"8080"`
Password string `env:"PASSWORD"`
DB string `env:"DB" envDefault:"fusion.db"`
SecureCookie bool `env:"SECURE_COOKIE" envDefault:"false"`
TLSCert string `env:"TLS_CERT"`
TLSKey string `env:"TLS_KEY"`
}
if err := env.Parse(&conf); err != nil { if err := env.Parse(&conf); err != nil {
panic(err) panic(err)
} }
@ -43,8 +52,9 @@ func Load() (Conf, error) {
fmt.Println(conf) fmt.Println(conf)
} }
if conf.Password == "" { pwHash, err := auth.HashPassword(conf.Password)
return Conf{}, errors.New("password is required") if err != nil {
return Conf{}, err
} }
if (conf.TLSCert == "") != (conf.TLSKey == "") { if (conf.TLSCert == "") != (conf.TLSKey == "") {
@ -54,5 +64,13 @@ func Load() (Conf, error) {
conf.SecureCookie = true conf.SecureCookie = true
} }
return conf, nil return Conf{
Host: conf.Host,
Port: conf.Port,
PasswordHash: pwHash,
DB: conf.DB,
SecureCookie: conf.SecureCookie,
TLSCert: conf.TLSCert,
TLSKey: conf.TLSKey,
}, nil
} }