Artifact 6e76056d3b2cc81af0090877f4719e7e3fcf06b423c0e6752f8959fdb0a209ac:
- File
internal/oltp/account.go
— part of check-in
[41b085ae25]
at
2026-08-23 00:21:30
on branch trunk
— caps phase 4: imported fossil users sign in, and the store path joins the wire path
Authenticate accepts an imported §6.3 credential once and upgrades the account to argon2, dropping the imported secret; a wrong guess never consumes it. quarry users pull without --url now reads the store's own table (zit#124/#125) — no credential needed for a repo we already serve. Proven end to end against a real fossil: a user who existed only there signed into quarry with their original password. (user: dmestas, size: 8968) [annotate] [blame] [check-ins using]
// account.go is 0001 §8's v2 growth: accounts and per-repo roles. §8 // pinned the architecture in v1 ("role granularity is v2 and adds tables, // not architecture") — this is those tables. // // Accounts are the web/API identity. They are NOT the sync protocol's // credential, which stays in sync_user because RFC §6.3 dictates its // derivation (see password.go). One account may own a sync_user row per // repo, which is what makes a push attributable to a person. package oltp import ( "crypto/sha1" "crypto/subtle" "database/sql" "encoding/hex" "errors" "fmt" "sort" "strings" "time" ) // Role is a repo-scoped capability. Ordered: each contains the ones // before it, so a comparison answers "is this enough?". type Role int const ( NoRole Role = iota Read Write RepoAdmin ) func (r Role) String() string { switch r { case Read: return "read" case Write: return "write" case RepoAdmin: return "admin" } return "none" } // ParseRole is the inverse, for CLI and storage. func ParseRole(s string) (Role, error) { switch s { case "read": return Read, nil case "write": return Write, nil case "admin": return RepoAdmin, nil } return NoRole, fmt.Errorf("oltp: unknown role %q (want read, write or admin)", s) } // Account is one login. The verifier never leaves the database. type Account struct { Name string Admin bool // site admin: every repo, every capability Created string } const accountSchema = ` CREATE TABLE IF NOT EXISTS account ( name TEXT PRIMARY KEY, pwhash TEXT NOT NULL, -- argon2id PHC string; plaintext never stored admin INTEGER NOT NULL DEFAULT 0, -- site admin created TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS repo_role ( account TEXT NOT NULL, repo TEXT NOT NULL, role TEXT NOT NULL, -- read | write | admin PRIMARY KEY (account, repo) );` // AddAccount creates an account, hashing the password. Fails if the name // is taken — silently replacing a login would be an account takeover. func (d *DB) AddAccount(name, password string, admin bool) error { if name == "" { return errors.New("oltp: empty account name") } hash, err := HashPassword(password) if err != nil { return err } _, err = d.db.Exec(`INSERT INTO account (name, pwhash, admin, created) VALUES (?, ?, ?, ?)`, name, hash, boolInt(admin), time.Now().UTC().Format(time.RFC3339)) if err != nil && strings.Contains(err.Error(), "UNIQUE") { return fmt.Errorf("oltp: account %q already exists", name) } return err } // SetPassword replaces an account's verifier. func (d *DB) SetPassword(name, password string) error { hash, err := HashPassword(password) if err != nil { return err } res, err := d.db.Exec(`UPDATE account SET pwhash = ? WHERE name = ?`, hash, name) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("oltp: no such account %q", name) } // Changing a password is how someone responds to a credential they // think is compromised. Leaving existing sessions valid would make // that response do nothing about the browser already holding one. return d.EndAccountSessions(name) } // Authenticate reports whether the password matches, and returns the // account. A missing account still pays for a hash comparison, so // probing for valid names by timing gets nothing. func (d *DB) Authenticate(name, password string) (Account, bool) { var a Account var hash string var admin int err := d.db.QueryRow(`SELECT name, pwhash, admin, created FROM account WHERE name = ?`, name).Scan(&a.Name, &hash, &admin, &a.Created) if errors.Is(err, sql.ErrNoRows) { // Same work as a real verification, so a nonexistent account is // not distinguishable from a wrong password by response time. VerifyPassword(decoyHash, password) return Account{}, false } if err != nil { return Account{}, false } if !VerifyPassword(hash, password) { // An imported fossil user has no native verifier yet — only the // credential their old server held (RFC 0004 §6). Accept it // once, then upgrade: the account gets a real argon2 verifier // and the imported secret is dropped, so the fossil hash never // outlives its usefulness. if legacy, code, ok := d.LegacyCredential(name); ok && subtle.ConstantTimeCompare([]byte(legacy), []byte(legacySecret(code, name, password))) == 1 { if err := d.SetPassword(name, password); err != nil { return Account{}, false } if err := d.ClearLegacyCredential(name); err != nil { return Account{}, false } a.Admin = admin != 0 return a, true } return Account{}, false } a.Admin = admin != 0 return a, true } // legacySecret is the sync protocol's shared-secret derivation // (RFC §6.3): SHA1(project-code/login/password), 40 lowercase hex. It // is spelled here rather than imported so storage does not depend on // transport; xfer.SharedSecret is its twin and a test pins the two // together so they cannot drift apart. func legacySecret(projectCode, login, password string) string { sum := sha1.Sum([]byte(projectCode + "/" + login + "/" + password)) return hex.EncodeToString(sum[:]) } // decoyHash is a real argon2id verifier for a password nobody has; it // exists only to give Authenticate constant work on a missing account. var decoyHash = func() string { h, _ := HashPassword("decoy: no account has this password") return h }() // HasAccounts is the policy switch, mirroring Gated for sync users: with // no accounts the web/API gate stays open (single-user local serving), // and the first account closes it. func (d *DB) HasAccounts() bool { var n int if err := d.db.QueryRow(`SELECT count(*) FROM account`).Scan(&n); err != nil { return false } return n > 0 } // Accounts lists every account, name-ordered. func (d *DB) Accounts() ([]Account, error) { rows, err := d.db.Query(`SELECT name, admin, created FROM account ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []Account for rows.Next() { var a Account var admin int if err := rows.Scan(&a.Name, &admin, &a.Created); err != nil { return nil, err } a.Admin = admin != 0 out = append(out, a) } return out, rows.Err() } // DeleteAccount removes the account and every role it held. func (d *DB) DeleteAccount(name string) error { if _, err := d.db.Exec(`DELETE FROM repo_role WHERE account = ?`, name); err != nil { return err } _, err := d.db.Exec(`DELETE FROM account WHERE name = ?`, name) return err } // Grant sets one account's role on one repo, replacing any current role. func (d *DB) Grant(account, repo string, role Role) error { if role == NoRole { return d.Revoke(account, repo) } var n int if err := d.db.QueryRow(`SELECT count(*) FROM account WHERE name = ?`, account).Scan(&n); err != nil { return err } if n == 0 { return fmt.Errorf("oltp: no such account %q", account) } _, err := d.db.Exec(`INSERT OR REPLACE INTO repo_role (account, repo, role) VALUES (?, ?, ?)`, account, repo, role.String()) return err } // Revoke drops an account's role on a repo. func (d *DB) Revoke(account, repo string) error { _, err := d.db.Exec(`DELETE FROM repo_role WHERE account = ? AND repo = ?`, account, repo) return err } // RoleOf returns the account's effective role on a repo. A site admin // holds RepoAdmin everywhere, which is what "site admin" means. func (d *DB) RoleOf(account Account, repo string) Role { if account.Admin { return RepoAdmin } var s string if err := d.db.QueryRow(`SELECT role FROM repo_role WHERE account = ? AND repo = ?`, account.Name, repo).Scan(&s); err != nil { return NoRole } r, err := ParseRole(s) if err != nil { return NoRole } return r } // GrantsFor lists an account's per-repo roles, repo-ordered. func (d *DB) GrantsFor(account string) (map[string]Role, error) { rows, err := d.db.Query(`SELECT repo, role FROM repo_role WHERE account = ?`, account) if err != nil { return nil, err } defer rows.Close() out := map[string]Role{} for rows.Next() { var repo, role string if err := rows.Scan(&repo, &role); err != nil { return nil, err } r, err := ParseRole(role) if err != nil { continue } out[repo] = r } return out, rows.Err() } // SortedRepos is a small helper so callers render grants deterministically. func SortedRepos(m map[string]Role) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } func boolInt(b bool) int { if b { return 1 } return 0 } // AccountByName looks an account up without a password — for a caller // that has already authenticated by another means (a session cookie). func (d *DB) AccountByName(name string) (Account, bool) { var a Account var admin int if err := d.db.QueryRow(`SELECT name, admin, created FROM account WHERE name = ?`, name).Scan(&a.Name, &admin, &a.Created); err != nil { return Account{}, false } a.Admin = admin != 0 return a, true }