Files
orchestra/internal/authn/store.go
T
kami 57c028f94f Seal the plan as a specification instead of four bullet lists
The plan artifact was Changes{Target,Intent} plus three string lists, every
entry capped at 500 single-line characters. That bound makes a specification
impossible: a phase cannot carry a code block, a paragraph of reasoning, or a
verification command with its own argument list. renderSealed then flattened
what little survived through collapse(), so an implement session received a
summary of a summary.

plan.md replaces it. Markdown, 128 KiB, no per-line cap, sealed through the
existing path under the existing PlanRef. The parser enforces the structure the
brief states: required sections, phases numbered from 1 with no gaps, Files,
Changes and Verification per phase, and at least one automated or manual check,
because a phase nobody can verify can never be established as done. Automated
entries are JSON argv arrays, so a pipe is a literal argument rather than an
operator. Headings inside fenced blocks are content, so a plan may show
markdown without parsing its own example.

Citations resolve at seal time against the accepted research, on the
coordinator, which is the only party holding ResearchRef. A plan resting on a
finding nobody recorded fails on the planner while its session is still alive
to be told.

The plan now renders byte for byte into the implement launch, and a rotated
successor receives the same complete document. That is the property the whole
change exists for. collapse() stays for research findings, which really are
short claims.

DecodeStoredPlan reads pre-markdown refs and renders them into the same type,
labelled, so nothing downstream branches on which era a plan came from. A
legacy plan carries no phases, which is honest: the old artifact never named an
executable unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-28 11:36:45 +04:00

344 lines
9.2 KiB
Go

// 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 utf8.RuneCountInString(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
}