Compare commits

..
Author SHA1 Message Date
Chris Lu e3e087f18e fix(sftpd): harden bcrypt migration and auth path
- SetPassword now returns an error instead of silently truncating passwords
  >72 bytes (the prior fallback could also panic for short passwords when
  bcrypt returned any non-ErrPasswordTooLong error).
- CheckPassword is now pure and reports (ok, legacy); migration moves to
  FileStore.migrateLegacyPassword under a write lock to fix a data race on
  concurrent logins and a double-check avoids redundant re-hashing.
- ValidatePassword only rewrites the user store when a migration actually
  happened, eliminating a full file rewrite on every successful login.
- Legacy plaintext comparison uses subtle.ConstantTimeCompare again,
  restoring the pre-PR constant-time property.
- CreateUser propagates SetPassword errors and releases the write lock
  before calling saveUsers to avoid recursive RWMutex locking.
- Add unit tests covering new-user hashing, legacy migration, no-rewrite
  on normal login, wrong/empty passwords, oversize password rejection,
  and concurrent migration under -race.
2026-04-22 20:09:33 -07:00
Chris Lu 9b077d6f1f 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.
2026-04-01 21:28:24 -07:00
11 changed files with 286 additions and 127 deletions
+2 -4
View File
@@ -65,6 +65,8 @@ reed-solomon-erasure = "6"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
pprof = { version = "0.15", features = ["prost-codec"] }
# Config
toml = "0.8"
serde = { version = "1", features = ["derive"] }
@@ -125,10 +127,6 @@ aws-sdk-s3 = { version = "1.125.0", default-features = false, features = ["sigv4
aws-credential-types = "1"
aws-types = "1"
# pprof is Unix-only (requires libc/nix APIs not available on Windows)
[target.'cfg(unix)'.dependencies]
pprof = { version = "0.15", features = ["prost-codec"] }
[dev-dependencies]
tempfile = "3"
+1 -17
View File
@@ -10,11 +10,9 @@ use seaweed_volume::security::tls::{
GrpcClientAuthPolicy, TlsPolicy,
};
use seaweed_volume::security::{Guard, SigningKey};
#[cfg(unix)]
use seaweed_volume::server::debug::build_debug_router;
use seaweed_volume::server::grpc_client::load_outgoing_grpc_tls;
use seaweed_volume::server::grpc_server::VolumeGrpcService;
#[cfg(unix)]
use seaweed_volume::server::profiling::CpuProfileSession;
use seaweed_volume::server::request_id::GrpcRequestIdLayer;
use seaweed_volume::server::volume_server::{
@@ -26,11 +24,6 @@ use seaweed_volume::storage::types::DiskType;
use tokio_rustls::TlsAcceptor;
#[cfg(unix)]
type CpuProfileParam = Option<CpuProfileSession>;
#[cfg(not(unix))]
type CpuProfileParam = Option<()>;
const GRPC_MAX_MESSAGE_SIZE: usize = 1 << 30;
const GRPC_KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
const GRPC_KEEPALIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
@@ -49,7 +42,6 @@ fn main() {
let config = config::parse_cli();
seaweed_volume::server::server_stats::init_process_start();
#[cfg(unix)]
let cpu_profile = match CpuProfileSession::start(&config) {
Ok(session) => session,
Err(e) => {
@@ -57,8 +49,6 @@ fn main() {
std::process::exit(1);
}
};
#[cfg(not(unix))]
let cpu_profile: Option<()> = None;
info!(
"SeaweedFS Volume Server (Rust) v{}",
seaweed_volume::version::full_version()
@@ -267,7 +257,7 @@ where
async fn run(
config: VolumeServerConfig,
#[allow(unused_variables)] cpu_profile: CpuProfileParam,
cpu_profile: Option<CpuProfileSession>,
) -> Result<(), Box<dyn std::error::Error>> {
// Initialize the store
let mut store = Store::new(config.index_type);
@@ -441,12 +431,10 @@ async fn run(
}
// Build HTTP routers
#[allow(unused_mut)]
let mut admin_router = seaweed_volume::server::volume_server::build_admin_router_with_ui(
state.clone(),
config.ui_enabled,
);
#[cfg(unix)]
if config.pprof {
admin_router = admin_router.merge(build_debug_router());
}
@@ -733,7 +721,6 @@ async fn run(
None
};
#[cfg(unix)]
let debug_handle = if config.debug {
let debug_addr = format!("0.0.0.0:{}", config.debug_port);
info!("Debug pprof server listening on {}", debug_addr);
@@ -755,8 +742,6 @@ async fn run(
} else {
None
};
#[cfg(not(unix))]
let debug_handle: Option<tokio::task::JoinHandle<()>> = None;
let metrics_push_handle = {
let push_state = state.clone();
@@ -789,7 +774,6 @@ async fn run(
// Close all volumes (flush and release file handles) matching Go's Shutdown()
state.store.write().unwrap().close();
#[cfg(unix)]
if let Some(cpu_profile) = cpu_profile {
cpu_profile.finish().map_err(std::io::Error::other)?;
}
-2
View File
@@ -1,11 +1,9 @@
#[cfg(unix)]
pub mod debug;
pub mod grpc_client;
pub mod grpc_server;
pub mod handlers;
pub mod heartbeat;
pub mod memory_status;
#[cfg(unix)]
pub mod profiling;
pub mod request_id;
pub mod server_stats;
@@ -6,7 +6,7 @@
use std::fs::File;
use std::io;
#[cfg(not(unix))]
use std::io::{Read, Seek, SeekFrom};
use std::io::{Seek, SeekFrom};
use reed_solomon_erasure::galois_8::ReedSolomon;
-13
View File
@@ -359,7 +359,6 @@ func doSubscribeFilerMetaChanges(clientId int32, clientEpoch int32, sourceGrpcDi
}
var lastLogTsNs = time.Now().UnixNano()
var lastProgressedTsNs int64
var clientName = fmt.Sprintf("syncFrom_%s_To_%s", string(sourceFiler), string(targetFiler))
processEventFnWithOffset := pb.AddOffsetFunc(func(resp *filer_pb.SubscribeMetadataResponse) error {
processor.AddSyncJob(resp)
@@ -373,18 +372,6 @@ func doSubscribeFilerMetaChanges(clientId int32, clientEpoch int32, sourceGrpcDi
now := time.Now().UnixNano()
glog.V(0).Infof("sync %s to %s progressed to %v %0.2f/sec", sourceFiler, targetFiler, time.Unix(0, offsetTsNs), float64(counter)/(float64(now-lastLogTsNs)/1e9))
lastLogTsNs = now
if offsetTsNs == lastProgressedTsNs {
for _, t := range filerSink.ActiveTransfers() {
if t.LastErr != "" {
glog.V(0).Infof(" %s %s: %d bytes received, %s, last error: %s",
t.ChunkFileId, t.Path, t.BytesReceived, t.Status, t.LastErr)
} else {
glog.V(0).Infof(" %s %s: %d bytes received, %s",
t.ChunkFileId, t.Path, t.BytesReceived, t.Status)
}
}
}
lastProgressedTsNs = offsetTsNs
// collect synchronous offset
statsCollect.FilerSyncOffsetGauge.WithLabelValues(sourceFiler.String(), targetFiler.String(), clientName, sourcePath).Set(float64(offsetTsNs))
return setOffset(targetGrpcDialOption, targetFiler, getSignaturePrefixByPath(sourcePath), sourceFilerSignature, offsetTsNs)
@@ -241,14 +241,6 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
return "", fmt.Errorf("upload data: %w", err)
}
transferStatus := &ChunkTransferStatus{
ChunkFileId: sourceChunk.GetFileIdString(),
Path: path,
Status: "downloading",
}
fs.activeTransfers.Store(sourceChunk.GetFileIdString(), transferStatus)
defer fs.activeTransfers.Delete(sourceChunk.GetFileIdString())
eofBackoff := time.Duration(0)
var partialData []byte
var savedFilename string
@@ -290,11 +282,6 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
fullData = data
}
transferStatus.mu.Lock()
transferStatus.BytesReceived = int64(len(fullData))
transferStatus.Status = "uploading"
transferStatus.mu.Unlock()
currentFileId, uploadResult, uploadErr, _ := uploader.UploadWithRetry(
fs,
&filer_pb.AssignVolumeRequest{
@@ -337,21 +324,11 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string,
glog.V(1).Infof("skip retrying stale source %s for %s: %v", sourceChunk.GetFileIdString(), path, retryErr)
return false
}
transferStatus.mu.Lock()
transferStatus.LastErr = retryErr.Error()
transferStatus.mu.Unlock()
if isEofError(retryErr) {
eofBackoff = nextEofBackoff(eofBackoff)
transferStatus.mu.Lock()
transferStatus.BytesReceived = int64(len(partialData))
transferStatus.Status = fmt.Sprintf("waiting %v", eofBackoff)
transferStatus.mu.Unlock()
glog.V(0).Infof("source connection interrupted while replicating %s for %s (%d bytes received so far), backing off %v: %v",
sourceChunk.GetFileIdString(), path, len(partialData), eofBackoff, retryErr)
time.Sleep(eofBackoff)
transferStatus.mu.Lock()
transferStatus.Status = "downloading"
transferStatus.mu.Unlock()
} else {
glog.V(0).Infof("replicate %s for %s: %v", sourceChunk.GetFileIdString(), path, retryErr)
}
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"math"
"sync"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
@@ -21,19 +20,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/util"
)
// ChunkTransferStatus tracks the progress of a single chunk being replicated.
// Fields are guarded by mu: ChunkFileId and Path are immutable after creation,
// while BytesReceived, Status, and LastErr are updated by fetchAndWrite and
// read by ActiveTransfers.
type ChunkTransferStatus struct {
mu sync.RWMutex
ChunkFileId string
Path string
BytesReceived int64
Status string // "downloading", "uploading", or "waiting 10s" etc.
LastErr string
}
type FilerSink struct {
filerSource *source.FilerSource
grpcAddress string
@@ -49,7 +35,6 @@ type FilerSink struct {
isIncremental bool
executor *util.LimitedConcurrentExecutor
signature int32
activeTransfers sync.Map // chunkFileId -> *ChunkTransferStatus
}
func init() {
@@ -116,25 +101,6 @@ func (fs *FilerSink) SetChunkConcurrency(concurrency int) {
}
}
// ActiveTransfers returns an immutable snapshot of all in-progress chunk transfers.
func (fs *FilerSink) ActiveTransfers() []ChunkTransferStatus {
var transfers []ChunkTransferStatus
fs.activeTransfers.Range(func(key, value any) bool {
t := value.(*ChunkTransferStatus)
t.mu.RLock()
transfers = append(transfers, ChunkTransferStatus{
ChunkFileId: t.ChunkFileId,
Path: t.Path,
BytesReceived: t.BytesReceived,
Status: t.Status,
LastErr: t.LastErr,
})
t.mu.RUnlock()
return true
})
return transfers
}
func (fs *FilerSink) DeleteEntry(key string, isDirectory, deleteIncludeChunks bool, signatures []int32) error {
dir, name := util.FullPath(key).DirAndName()
+5 -5
View File
@@ -58,9 +58,9 @@ var (
// SSECustomerKey represents a customer-provided encryption key for SSE-C
type SSECustomerKey struct {
Algorithm string
Key []byte
KeyMD5 string
Algorithm string
Key []byte
KeyMD5 string
}
// IsSSECRequest checks if the request contains SSE-C headers
@@ -119,8 +119,8 @@ func validateAndParseSSECHeaders(algorithm, key, keyMD5 string) (*SSECustomerKey
sum := md5.Sum(keyBytes)
expectedMD5 := base64.StdEncoding.EncodeToString(sum[:])
// Debug logging for MD5 validation (never log key material)
glog.V(4).Infof("SSE-C MD5 validation: provided='%s', expected='%s'", keyMD5, expectedMD5)
// Debug logging for MD5 validation
glog.V(4).Infof("SSE-C MD5 validation: provided='%s', expected='%s', keyBytes=%x", keyMD5, expectedMD5, keyBytes)
if keyMD5 != expectedMD5 {
glog.Errorf("SSE-C MD5 mismatch: provided='%s', expected='%s'", keyMD5, expectedMD5)
+45 -18
View File
@@ -8,6 +8,7 @@ import (
"path"
"sync"
"github.com/seaweedfs/seaweedfs/weed/glog"
"golang.org/x/crypto/ssh"
)
@@ -147,15 +148,45 @@ func (s *FileStore) GetUser(username string) (*User, error) {
return user, nil
}
// ValidatePassword checks if the password is valid for the user
// ValidatePassword checks if the password is valid for the user. Legacy
// plaintext entries are transparently upgraded to bcrypt on successful match.
func (s *FileStore) ValidatePassword(username string, password []byte) bool {
user, err := s.GetUser(username)
if err != nil {
return false
}
// Compare plaintext password using constant time comparison for security
return subtle.ConstantTimeCompare([]byte(user.Password), password) == 1
ok, legacy := user.CheckPassword(string(password))
if !ok {
return false
}
if legacy {
s.migrateLegacyPassword(user, string(password))
}
return true
}
// migrateLegacyPassword re-hashes a legacy plaintext password under a write
// lock and persists the user store. The FileStore mutex guards both the
// re-hash (to avoid a data race with concurrent logins) and the double-check
// that another goroutine hasn't already migrated. Failures are logged but
// not surfaced — auth has already succeeded and the next login will retry.
func (s *FileStore) migrateLegacyPassword(user *User, password string) {
s.mu.Lock()
if user.HashedPassword != "" {
s.mu.Unlock()
return
}
if err := user.SetPassword(password); err != nil {
s.mu.Unlock()
glog.V(0).Infof("sftpd: failed to upgrade legacy password for %s: %v", user.Username, err)
return
}
s.mu.Unlock()
if err := s.saveUsers(); err != nil {
glog.V(0).Infof("sftpd: failed to persist upgraded password for %s: %v", user.Username, err)
}
}
// ValidatePublicKey checks if the public key is valid for the user
@@ -239,25 +270,21 @@ func (s *FileStore) ListUsers() ([]string, error) {
// CreateUser creates a new user with the given username and password
func (s *FileStore) CreateUser(username, password string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Check if user already exists
if _, exists := s.users[username]; exists {
return nil, fmt.Errorf("user already exists: %s", username)
}
// Create new user
user := NewUser(username)
// Store plaintext password
user.Password = password
// Add default permissions
if err := user.SetPassword(password); err != nil {
return nil, err
}
user.Permissions[user.HomeDir] = []string{"all"}
// Save the user
s.mu.Lock()
if _, exists := s.users[username]; exists {
s.mu.Unlock()
return nil, fmt.Errorf("user already exists: %s", username)
}
s.users[username] = user
s.mu.Unlock()
// saveUsers acquires RLock, so we must release the write lock above first.
if err := s.saveUsers(); err != nil {
return nil, err
}
+195
View File
@@ -0,0 +1,195 @@
package user
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"golang.org/x/crypto/bcrypt"
)
func newTempStore(t *testing.T) *FileStore {
t.Helper()
dir := t.TempDir()
s, err := NewFileStore(filepath.Join(dir, "users.json"))
if err != nil {
t.Fatalf("NewFileStore: %v", err)
}
return s
}
func readUsers(t *testing.T, s *FileStore) []*User {
t.Helper()
data, err := os.ReadFile(s.filePath)
if err != nil {
t.Fatalf("read store file: %v", err)
}
var users []*User
if err := json.Unmarshal(data, &users); err != nil {
t.Fatalf("unmarshal store file: %v", err)
}
return users
}
func TestCreateUserHashesPassword(t *testing.T) {
s := newTempStore(t)
u, err := s.CreateUser("alice", "s3cret")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if u.Password != "" {
t.Errorf("plaintext Password leaked: %q", u.Password)
}
if u.HashedPassword == "" {
t.Fatal("HashedPassword empty")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte("s3cret")); err != nil {
t.Fatalf("hash does not match password: %v", err)
}
stored := readUsers(t, s)
if len(stored) != 1 || stored[0].Password != "" {
t.Errorf("plaintext persisted: %+v", stored)
}
}
func TestValidatePasswordMigratesLegacyPlaintext(t *testing.T) {
s := newTempStore(t)
legacy := NewUser("bob")
legacy.Password = "hunter2"
if err := s.SaveUser(legacy); err != nil {
t.Fatalf("SaveUser: %v", err)
}
if !s.ValidatePassword("bob", []byte("hunter2")) {
t.Fatal("legacy login rejected")
}
u, err := s.GetUser("bob")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if u.Password != "" {
t.Errorf("legacy Password not cleared: %q", u.Password)
}
if u.HashedPassword == "" {
t.Fatal("HashedPassword not populated after migration")
}
stored := readUsers(t, s)
if len(stored) != 1 || stored[0].Password != "" || stored[0].HashedPassword == "" {
t.Errorf("migration not persisted: %+v", stored)
}
}
// Successful logins that don't migrate must not rewrite the store file.
// We detect a rewrite by replacing the file contents with a sentinel after
// CreateUser and verifying the sentinel is untouched after a valid login.
func TestValidatePasswordNoRewriteWithoutMigration(t *testing.T) {
s := newTempStore(t)
if _, err := s.CreateUser("carol", "pw"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
sentinel := []byte("SENTINEL_NOT_REAL_JSON")
if err := os.WriteFile(s.filePath, sentinel, 0600); err != nil {
t.Fatalf("write sentinel: %v", err)
}
if !s.ValidatePassword("carol", []byte("pw")) {
t.Fatal("login rejected")
}
got, err := os.ReadFile(s.filePath)
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, sentinel) {
t.Errorf("store file was rewritten on non-migrating login: got %q", got)
}
}
func TestValidatePasswordWrongPassword(t *testing.T) {
s := newTempStore(t)
if _, err := s.CreateUser("dave", "correct"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if s.ValidatePassword("dave", []byte("wrong")) {
t.Error("wrong password accepted for bcrypt user")
}
legacy := NewUser("eve")
legacy.Password = "plain"
if err := s.SaveUser(legacy); err != nil {
t.Fatal(err)
}
if s.ValidatePassword("eve", []byte("wrong")) {
t.Error("wrong password accepted for legacy user")
}
if s.ValidatePassword("eve", []byte("")) {
t.Error("empty password accepted for legacy user")
}
}
func TestValidatePasswordUnknownUser(t *testing.T) {
s := newTempStore(t)
if s.ValidatePassword("nobody", []byte("whatever")) {
t.Error("unknown user accepted")
}
}
func TestCheckPasswordEmptyCredentials(t *testing.T) {
u := NewUser("frank")
if ok, _ := u.CheckPassword(""); ok {
t.Error("empty password accepted on user with no credentials")
}
if ok, _ := u.CheckPassword("x"); ok {
t.Error("password accepted on user with no credentials")
}
}
func TestSetPasswordTooLong(t *testing.T) {
u := NewUser("grace")
if err := u.SetPassword(strings.Repeat("a", 73)); err == nil {
t.Error("expected error for >72 byte password")
}
if u.HashedPassword != "" {
t.Errorf("HashedPassword set despite error: %q", u.HashedPassword)
}
}
// Concurrent ValidatePassword calls on a legacy user must not race on the
// User fields and must converge to a single persisted bcrypt hash.
func TestValidatePasswordConcurrentMigration(t *testing.T) {
s := newTempStore(t)
legacy := NewUser("heidi")
legacy.Password = "p"
if err := s.SaveUser(legacy); err != nil {
t.Fatal(err)
}
const N = 16
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
if !s.ValidatePassword("heidi", []byte("p")) {
t.Error("login rejected")
}
}()
}
wg.Wait()
u, err := s.GetUser("heidi")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if u.Password != "" {
t.Errorf("plaintext not cleared after concurrent migration: %q", u.Password)
}
if err := bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte("p")); err != nil {
t.Errorf("final hash invalid: %v", err)
}
}
+37 -10
View File
@@ -2,19 +2,24 @@
package user
import (
"crypto/subtle"
"fmt"
"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 to HashedPassword on first successful login
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 +38,31 @@ func NewUser(username string) *User {
}
}
// SetPassword sets a plaintext password for the user
func (u *User) SetPassword(password string) {
u.Password = password
// SetPassword hashes and stores the password using bcrypt. It clears any
// legacy plaintext value. Returns an error if the password cannot be hashed
// (e.g. exceeds bcrypt's 72-byte limit).
func (u *User) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
u.HashedPassword = string(hash)
u.Password = ""
return nil
}
// CheckPassword verifies a password against the stored credentials without
// mutating the receiver. The legacy return is true when the match succeeded
// via the deprecated plaintext field; the caller is expected to re-hash and
// persist the password under an appropriate lock.
func (u *User) CheckPassword(password string) (ok bool, legacy bool) {
if u.HashedPassword != "" {
return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password)) == nil, false
}
if u.Password != "" && subtle.ConstantTimeCompare([]byte(u.Password), []byte(password)) == 1 {
return true, true
}
return false, false
}
// AddPublicKey adds a public key to the user