// Package authn owns browser-operator identities and credential verification. // Authorization policy remains in authz; this package only proves who signed // in. Operator records live in a small embedded bbolt database so a deployment // never needs to carry a reusable password hash in its environment. package authn import ( "crypto/subtle" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" "unicode" "unicode/utf8" bolt "go.etcd.io/bbolt" "golang.org/x/crypto/bcrypt" ) const ( DatabaseFile = "auth.db" MinimumPassword = 10 maximumPassword = 72 // bcrypt rejects passwords longer than 72 bytes. maximumUsername = 128 databaseOpenWait = 2 * time.Second databaseFileMode = 0600 databaseDirectory = 0700 ) var ( usersBucket = []byte("operator_users") ErrInvalidCredentials = errors.New("invalid username or password") ErrUsernameExists = errors.New("username already exists") // Unknown users still take a bcrypt comparison. The hash is generated once // at process start with the same cost used for real records so the login // response does not disclose whether an account exists. dummyPasswordHash = func() []byte { hash, err := bcrypt.GenerateFromPassword([]byte("orchestra-invalid-login-sentinel"), bcrypt.DefaultCost) if err != nil { panic(err) } return hash }() ) type User struct { Username string `json:"username"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type storedUser struct { User PasswordHash string `json:"password_hash"` } type Store struct { db *bolt.DB } func Path(dataDir string) string { return filepath.Join(dataDir, DatabaseFile) } func Open(path string) (*Store, error) { if strings.TrimSpace(path) == "" { return nil, errors.New("auth database path is required") } if err := os.MkdirAll(filepath.Dir(path), databaseDirectory); err != nil { return nil, fmt.Errorf("create auth database directory: %w", err) } db, err := bolt.Open(path, databaseFileMode, &bolt.Options{Timeout: databaseOpenWait}) if err != nil { return nil, fmt.Errorf("open auth database: %w", err) } s := &Store{db: db} if err := db.Update(func(tx *bolt.Tx) error { _, err := tx.CreateBucketIfNotExists(usersBucket) return err }); err != nil { _ = db.Close() return nil, fmt.Errorf("initialize auth database: %w", err) } return s, nil } func (s *Store) Close() error { if s == nil || s.db == nil { return nil } return s.db.Close() } func normalizedUsername(username string) string { return strings.ToLower(strings.TrimSpace(username)) } func ValidateUsername(username string) error { username = strings.TrimSpace(username) if username == "" { return errors.New("username is required") } if len(username) > maximumUsername { return fmt.Errorf("username must be at most %d bytes", maximumUsername) } if !utf8.ValidString(username) { return errors.New("username must be valid UTF-8") } for _, r := range username { if unicode.IsControl(r) { return errors.New("username must not contain control characters") } } return nil } func ValidatePassword(password string) error { if len(password) < MinimumPassword { return fmt.Errorf("password must be at least %d characters", MinimumPassword) } if len([]byte(password)) > maximumPassword { return fmt.Errorf("password must be at most %d bytes", maximumPassword) } return nil } func decodeUser(raw []byte) (storedUser, error) { var user storedUser if err := json.Unmarshal(raw, &user); err != nil { return storedUser{}, err } return user, nil } func (s *Store) Count() (int, error) { count := 0 err := s.db.View(func(tx *bolt.Tx) error { count = tx.Bucket(usersBucket).Stats().KeyN return nil }) return count, err } func (s *Store) Users() ([]User, error) { users := []User{} err := s.db.View(func(tx *bolt.Tx) error { return tx.Bucket(usersBucket).ForEach(func(_, raw []byte) error { stored, err := decodeUser(raw) if err != nil { return err } users = append(users, stored.User) return nil }) }) return users, err } func (s *Store) User(username string) (User, error) { var out User err := s.db.View(func(tx *bolt.Tx) error { raw := tx.Bucket(usersBucket).Get([]byte(normalizedUsername(username))) if raw == nil { return ErrInvalidCredentials } stored, err := decodeUser(raw) if err == nil { out = stored.User } return err }) return out, err } // SetPassword creates an operator or replaces that operator's password. It is // intended for the local orchestra-user command; browser changes use Update, // which also proves the current password. func (s *Store) SetPassword(username, password string) (User, bool, error) { username = strings.TrimSpace(username) if err := ValidateUsername(username); err != nil { return User{}, false, err } if err := ValidatePassword(password); err != nil { return User{}, false, err } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return User{}, false, err } return s.putHash(username, string(hash), false) } // ImportBcrypt is the one-time compatibility bridge from the old environment // credential. It only creates the named user when the database is empty. func (s *Store) ImportBcrypt(username, passwordHash string) (bool, error) { username = strings.TrimSpace(username) if err := ValidateUsername(username); err != nil { return false, err } if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil { return false, fmt.Errorf("legacy web password hash must be bcrypt: %w", err) } _, created, err := s.putHash(username, passwordHash, true) return created, err } func (s *Store) putHash(username, passwordHash string, onlyIfEmpty bool) (User, bool, error) { now := time.Now().UTC() key := []byte(normalizedUsername(username)) var out User created := false err := s.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(usersBucket) if onlyIfEmpty && bucket.Stats().KeyN != 0 { return nil } stored := storedUser{User: User{Username: username, CreatedAt: now, UpdatedAt: now}, PasswordHash: passwordHash} if raw := bucket.Get(key); raw != nil { current, err := decodeUser(raw) if err != nil { return err } stored.CreatedAt = current.CreatedAt } else { created = true } encoded, err := json.Marshal(stored) if err != nil { return err } out = stored.User return bucket.Put(key, encoded) }) return out, created, err } func (s *Store) Authenticate(username, password string) (User, error) { var stored storedUser found := false err := s.db.View(func(tx *bolt.Tx) error { raw := tx.Bucket(usersBucket).Get([]byte(normalizedUsername(username))) if raw == nil { return nil } var err error stored, err = decodeUser(raw) found = err == nil return err }) if err != nil { return User{}, err } hash := dummyPasswordHash if found { hash = []byte(stored.PasswordHash) } passwordOK := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil usernameOK := found && subtle.ConstantTimeCompare( []byte(normalizedUsername(username)), []byte(normalizedUsername(stored.Username)), ) == 1 if !passwordOK || !usernameOK { return User{}, ErrInvalidCredentials } return stored.User, nil } // Update changes the authenticated operator's username and/or password. The // current password is required even though the endpoint also requires a live // browser session, protecting an unattended unlocked browser. func (s *Store) Update(currentUsername, currentPassword, newUsername, newPassword string) (User, error) { current, err := s.Authenticate(currentUsername, currentPassword) if err != nil { return User{}, err } newUsername = strings.TrimSpace(newUsername) if newUsername == "" { newUsername = current.Username } if err := ValidateUsername(newUsername); err != nil { return User{}, err } var newHash string if newPassword != "" { if err := ValidatePassword(newPassword); err != nil { return User{}, err } hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return User{}, err } newHash = string(hash) } oldKey := []byte(normalizedUsername(current.Username)) newKey := []byte(normalizedUsername(newUsername)) var out User err = s.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(usersBucket) raw := bucket.Get(oldKey) if raw == nil { return ErrInvalidCredentials } stored, err := decodeUser(raw) if err != nil { return err } // Refuse a stale credential update if another password change landed // between Authenticate and this write transaction. if bcrypt.CompareHashAndPassword([]byte(stored.PasswordHash), []byte(currentPassword)) != nil { return ErrInvalidCredentials } if !bytesEqual(oldKey, newKey) && bucket.Get(newKey) != nil { return ErrUsernameExists } stored.Username = newUsername stored.UpdatedAt = time.Now().UTC() if newHash != "" { stored.PasswordHash = newHash } encoded, err := json.Marshal(stored) if err != nil { return err } if !bytesEqual(oldKey, newKey) { if err := bucket.Delete(oldKey); err != nil { return err } } if err := bucket.Put(newKey, encoded); err != nil { return err } out = stored.User return nil }) return out, err } func bytesEqual(a, b []byte) bool { return len(a) == len(b) && subtle.ConstantTimeCompare(a, b) == 1 }