fix(sftpd): replace plaintext password storage with bcrypt hashing

SFTP user passwords were stored as plaintext in the JSON user store.
This replaces plaintext storage with bcrypt hashing:

- Add HashedPassword field to User struct for bcrypt hashes
- SetPassword() now hashes with bcrypt.DefaultCost
- CheckPassword() verifies against bcrypt hash with transparent
  legacy migration: plaintext passwords auto-upgrade on successful login
- CreateUser() uses SetPassword() instead of direct assignment
- ValidatePassword() delegates to CheckPassword() and persists
  migrated hashes

Existing users with plaintext passwords are transparently migrated
to bcrypt on their next successful authentication.
This commit is contained in:
Chris Lu
2026-04-01 21:28:24 -07:00
parent 4287b7b12a
commit 9b077d6f1f
2 changed files with 42 additions and 13 deletions
+10 -4
View File
@@ -154,8 +154,14 @@ func (s *FileStore) ValidatePassword(username string, password []byte) bool {
return false
}
// Compare plaintext password using constant time comparison for security
return subtle.ConstantTimeCompare([]byte(user.Password), password) == 1
if user.CheckPassword(string(password)) {
// If legacy password was migrated to bcrypt, persist the change
if user.HashedPassword != "" && user.Password == "" {
_ = s.saveUsers()
}
return true
}
return false
}
// ValidatePublicKey checks if the public key is valid for the user
@@ -250,8 +256,8 @@ func (s *FileStore) CreateUser(username, password string) (*User, error) {
// Create new user
user := NewUser(username)
// Store plaintext password
user.Password = password
// Hash password with bcrypt
user.SetPassword(password)
// Add default permissions
user.Permissions[user.HomeDir] = []string{"all"}
+32 -9
View File
@@ -4,17 +4,20 @@ package user
import (
"math/rand/v2"
"path/filepath"
"golang.org/x/crypto/bcrypt"
)
// User represents an SFTP user with authentication and permission details
type User struct {
Username string // Username for authentication
Password string // Plaintext password
PublicKeys []string // Authorized public keys
HomeDir string // User's home directory
Permissions map[string][]string // path -> permissions (read, write, list, etc.)
Uid uint32 // User ID for file ownership
Gid uint32 // Group ID for file ownership
Username string `json:"Username"`
HashedPassword string `json:"HashedPassword"` // bcrypt hash
Password string `json:"Password,omitempty"` // deprecated: plaintext, migrated on next save
PublicKeys []string `json:"PublicKeys,omitempty"`
HomeDir string `json:"HomeDir"`
Permissions map[string][]string `json:"Permissions,omitempty"`
Uid uint32 `json:"Uid"`
Gid uint32 `json:"Gid"`
}
// NewUser creates a new user with default settings
@@ -33,9 +36,29 @@ func NewUser(username string) *User {
}
}
// SetPassword sets a plaintext password for the user
// SetPassword hashes and stores the password using bcrypt
func (u *User) SetPassword(password string) {
u.Password = password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
// bcrypt only errors on passwords > 72 bytes; truncate if needed
hash, _ = bcrypt.GenerateFromPassword([]byte(password[:72]), bcrypt.DefaultCost)
}
u.HashedPassword = string(hash)
u.Password = "" // clear any legacy plaintext
}
// CheckPassword verifies a password against the stored hash.
// It transparently handles legacy plaintext passwords by upgrading them on match.
func (u *User) CheckPassword(password string) bool {
if u.HashedPassword != "" {
return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password)) == nil
}
// Legacy plaintext migration path
if u.Password != "" && u.Password == password {
u.SetPassword(password) // upgrade to bcrypt
return true
}
return false
}
// AddPublicKey adds a public key to the user