// token.go is RFC 0021's credential table: named, scoped, expiring
// tokens, any number per account, each revocable on its own.
//
// It replaces sync_token, which held one push credential per account
// and replaced it on every mint, so two sessions sharing an account
// broke each other's pushes whenever either minted. A token here is
// one thing a machine holds; minting adds a row and revoking ends one.
//
// The value is qat_<id>_<secret>. The prefix lets a secret scanner
// find one in a log; the id finds the row without trying every token.
// A token with sync scope is sealed rather than hashed, for the reason
// synctoken.go gives: the login card proves knowledge of the §6.3
// secret, and the server can only check that by deriving it from the
// token it holds.
package oltp
import (
"crypto/rand"
"crypto/subtle"
"database/sql"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
"craftdesign.group/quarry/internal/xfer"
)
const tokenSchema = `
CREATE TABLE IF NOT EXISTS token (
id TEXT PRIMARY KEY,
account TEXT NOT NULL DEFAULT '',
run TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
verifier TEXT NOT NULL, -- sha256 hex, or the sealed value when sealed = 1
sealed INTEGER NOT NULL,
sync_secret TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL, -- words, comma-separated: sync,api,web
repos TEXT NOT NULL DEFAULT '', -- '' = every repository; else comma-separated names
ceiling TEXT NOT NULL DEFAULT '', -- '' = no ceiling; else a cap string
created TEXT NOT NULL,
expires TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT '',
revoked TEXT NOT NULL DEFAULT '',
migrated INTEGER NOT NULL DEFAULT 0 -- 1: copied from sync_token, whose row it mirrors
);
CREATE INDEX IF NOT EXISTS token_by_account ON token (account);
CREATE UNIQUE INDEX IF NOT EXISTS token_by_verifier ON token (account, verifier);
CREATE INDEX IF NOT EXISTS token_by_run ON token (run);`
// The scope words. Words rather than letters, which would collide with
// the capability letters.
const (
ScopeSync = "sync"
ScopeAPI = "api"
ScopeWeb = "web"
)
const (
tokenPrefix = "qat_"
// TokensPerAccount bounds the live tokens one account holds. It is
// what bounds the login card's loop over sync tokens (Secrets), and
// a machine reaching it is minting per session instead of per role.
TokensPerAccount = 25
TokenDefaultLife = 90 * 24 * time.Hour
TokenMaxLife = 365 * 24 * time.Hour
// lastUsedEvery bounds the last_used write, as sessionSeenEvery
// bounds a session's last_seen.
lastUsedEvery = 5 * time.Minute
)
// TokenSpec is what a mint asks for. A zero Expires means the default
// life.
type TokenSpec struct {
Name string
Scopes []string
Repos []string
Ceiling string
Expires time.Time
}
// Token is a row, never its value.
type Token struct {
ID string `json:"id"`
Account string `json:"account"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
Repos []string `json:"repos"`
Ceiling string `json:"ceiling"`
Sealed bool `json:"-"`
Created string `json:"created"`
Expires string `json:"expires"`
LastUsed string `json:"last_used"`
Revoked string `json:"revoked"`
verifier string
}
// Has reports whether the token carries scope.
func (t Token) Has(scope string) bool { return slices.Contains(t.Scopes, scope) }
// Live is neither revoked nor expired at now.
func (t Token) Live(now time.Time) bool {
if t.Revoked != "" {
return false
}
exp, err := time.Parse(time.RFC3339, t.Expires)
return err == nil && now.Before(exp)
}
// Reaches reports whether the token may act on repo. An empty list is
// every repository; a name in the list grants nothing, it narrows.
//
// A token with a list does not reach forge scope (repo ""): a list of
// repositories would mean nothing if the repository-less acts stayed
// open to it. The one exception, reading its own identity, is the
// API router's (ops.TokenAuth.Limited).
func (t Token) Reaches(repo string) bool {
return len(t.Repos) == 0 || (repo != "" && slices.Contains(t.Repos, repo))
}
// LooksLikeToken says whether a presented password has a token's
// shape. A password without it takes the path it always took.
func LooksLikeToken(s string) bool {
id, secret, ok := splitToken(s)
return ok && id != "" && secret != ""
}
func splitToken(s string) (id, secret string, ok bool) {
rest, ok := strings.CutPrefix(s, tokenPrefix)
if !ok {
return "", "", false
}
return strings.Cut(rest, "_")
}
// newTokenID is a short public id: 50 bits in the sync token alphabet,
// enough that ids do not collide in one forge's lifetime.
func newTokenID() (string, error) {
raw := make([]byte, 7)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return syncTokenAlphabet.EncodeToString(raw)[:10], nil
}
func newTokenSecret() (string, error) {
raw := make([]byte, 20)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return syncTokenAlphabet.EncodeToString(raw), nil
}
// MintToken adds a token to account and returns its value the ONLY
// time it is readable.
func (d *DB) MintToken(account string, spec TokenSpec) (string, Token, error) {
if account == "" {
return "", Token{}, errors.New("oltp: empty account name")
}
name := strings.TrimSpace(spec.Name)
if name == "" {
return "", Token{}, Refusal{"a token needs a name"}
}
if len(spec.Scopes) == 0 {
return "", Token{}, Refusal{"a token needs a scope: sync, api or web"}
}
for _, s := range spec.Scopes {
if s != ScopeSync && s != ScopeAPI && s != ScopeWeb {
return "", Token{}, Refusal{fmt.Sprintf("unknown token scope %q: use sync, api or web", s)}
}
}
now := time.Now().UTC()
expires := spec.Expires
if expires.IsZero() {
expires = now.Add(TokenDefaultLife)
}
if !expires.After(now) || expires.Sub(now) > TokenMaxLife+time.Minute {
return "", Token{}, Refusal{"a token expires within a year"}
}
if strings.ContainsRune(spec.Ceiling, 's') {
return "", Token{}, Refusal{"a token never carries s"}
}
live, err := d.liveTokenCount(account, now)
if err != nil {
return "", Token{}, err
}
if live >= TokensPerAccount {
return "", Token{}, Refusal{fmt.Sprintf("%s already holds %d live tokens; revoke one first", account, TokensPerAccount)}
}
id, err := newTokenID()
if err != nil {
return "", Token{}, err
}
secret, err := newTokenSecret()
if err != nil {
return "", Token{}, err
}
value := tokenPrefix + id + "_" + secret
tok := Token{ID: id, Account: account, Name: name, Scopes: dedupe(spec.Scopes), Repos: dedupe(spec.Repos),
Ceiling: spec.Ceiling, Created: now.Format(time.RFC3339), Expires: expires.UTC().Format(time.RFC3339)}
verifier := hashToken(value)
if tok.Has(ScopeSync) {
tok.Sealed = true
if verifier, err = d.seal.seal(value); err != nil {
return "", Token{}, err
}
}
if _, err := d.w.Exec(`INSERT INTO token (id, account, name, verifier, sealed, scope, repos, ceiling, created, expires)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tok.ID, account, tok.Name, verifier, boolInt(tok.Sealed), strings.Join(tok.Scopes, ","),
strings.Join(tok.Repos, ","), tok.Ceiling, tok.Created, tok.Expires); err != nil {
return "", Token{}, err
}
return value, tok, nil
}
func dedupe(in []string) []string {
var out []string
for _, s := range in {
if s = strings.TrimSpace(s); s != "" && !slices.Contains(out, s) {
out = append(out, s)
}
}
return out
}
func (d *DB) liveTokenCount(account string, now time.Time) (int, error) {
toks, err := d.Tokens(account)
if err != nil {
return 0, err
}
n := 0
for _, t := range toks {
if t.Live(now) {
n++
}
}
return n, nil
}
const tokenColumns = `id, account, name, verifier, sealed, scope, repos, ceiling, created, expires, last_used, revoked`
func scanToken(row interface{ Scan(...any) error }) (Token, error) {
var t Token
var sealed int
var scope, repos string
if err := row.Scan(&t.ID, &t.Account, &t.Name, &t.verifier, &sealed, &scope, &repos, &t.Ceiling,
&t.Created, &t.Expires, &t.LastUsed, &t.Revoked); err != nil {
return Token{}, err
}
t.Sealed = sealed == 1
t.Scopes = dedupe(strings.Split(scope, ","))
t.Repos = dedupe(strings.Split(repos, ","))
return t, nil
}
// Tokens lists every row of account's, revoked and expired included,
// newest first. Never a value.
func (d *DB) Tokens(account string) ([]Token, error) {
rows, err := d.db.Query(`SELECT `+tokenColumns+` FROM token WHERE account = ? ORDER BY created DESC, id`, account)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Token
for rows.Next() {
t, err := scanToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeToken ends one of account's tokens. The row stays, marked, for
// the list and the audit. Nothing caches a token, so every door refuses
// it from the next request.
//
// A token copied from sync_token takes its old row with it, so that a
// rollback to a build that reads sync_token cannot bring it back.
func (d *DB) RevokeToken(account, id string) error {
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var verifier string
var migrated int
if err := tx.QueryRow(`SELECT verifier, migrated FROM token WHERE id = ? AND account = ? AND revoked = ''`,
id, account).Scan(&verifier, &migrated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("oltp: %s holds no live token %q: %w", account, id, sql.ErrNoRows)
}
return err
}
if _, err := tx.Exec(`UPDATE token SET revoked = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id); err != nil {
return err
}
if migrated == 1 && syncTokenTableIn(tx) {
if _, err := tx.Exec(`DELETE FROM sync_token WHERE user = ? AND sealed = ?`, account, verifier); err != nil {
return err
}
}
return tx.Commit()
}
// syncTokenTableIn reports whether the pre-RFC 0021 table is present.
func syncTokenTableIn(q interface {
QueryRow(string, ...any) *sql.Row
}) bool {
var n int
err := q.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_token'`).Scan(&n)
return err == nil && n > 0
}
// ResolveToken is the one resolver behind every door: it answers the
// live token of account's that value is, provided it carries scope.
//
// A value with a token's shape is found by its id and compared by hash,
// or unsealed and compared in constant time. A value without the shape
// is tried, for the sync door only, against the account's sealed sync
// tokens: the push credentials minted before tokens had ids are still
// the values their holders have, and the migration kept them as they
// were.
func (d *DB) ResolveToken(account, value, scope string) (Token, bool) {
if account == "" || value == "" {
return Token{}, false
}
now := time.Now()
if id, _, ok := splitToken(value); ok {
t, err := scanToken(d.db.QueryRow(`SELECT `+tokenColumns+` FROM token WHERE id = ?`, id))
if err != nil {
// Same work as a real check, so an unknown id is not told
// apart from a wrong secret by the time it takes.
subtle.ConstantTimeCompare([]byte(hashToken(value)), []byte(decoyTokenHash))
return Token{}, false
}
if !d.tokenMatches(t, value) || t.Account != account || !t.Has(scope) || !t.Live(now) || !d.mayUseTokens(account) {
return Token{}, false
}
d.touchToken(t, now)
return t, true
}
if scope != ScopeSync || !d.mayUseTokens(account) {
return Token{}, false
}
for _, t := range d.liveSyncTokens(account, now) {
if d.tokenMatches(t, value) {
d.touchToken(t, now)
return t, true
}
}
return Token{}, false
}
// mayUseTokens is the account behind a token still standing: a disabled
// or deleted account's tokens are refused as its password is.
func (d *DB) mayUseTokens(account string) bool {
acct, ok := d.AccountByName(account)
return ok && acct.Disabled == ""
}
// LiveToken is account's live token with id, for a door that has
// already proved the holder some other way: the xfer front, whose login
// card verified against the secret this token derives.
func (d *DB) LiveToken(account, id string) (Token, bool) {
t, err := scanToken(d.db.QueryRow(`SELECT `+tokenColumns+` FROM token WHERE id = ? AND account = ?`, id, account))
if err != nil || !t.Live(time.Now()) || !d.mayUseTokens(account) {
return Token{}, false
}
return t, true
}
// decoyTokenHash is compared against when no row answers, for timing.
var decoyTokenHash = hashToken("qat_decoy_decoy")
func (d *DB) tokenMatches(t Token, value string) bool {
if !t.Sealed {
return subtle.ConstantTimeCompare([]byte(t.verifier), []byte(hashToken(value))) == 1
}
plain, err := d.seal.open(t.verifier)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(plain), []byte(value)) == 1
}
// touchToken records use, at most once per lastUsedEvery. Best effort:
// a request is never refused because its bookkeeping failed.
func (d *DB) touchToken(t Token, now time.Time) {
if last, err := time.Parse(time.RFC3339, t.LastUsed); err == nil && now.Sub(last) < lastUsedEvery {
return
}
d.w.Exec(`UPDATE token SET last_used = ? WHERE id = ?`, now.UTC().Format(time.RFC3339), t.ID)
}
// liveSyncTokens is the account's live sealed sync tokens, bounded by
// the per-account cap.
func (d *DB) liveSyncTokens(account string, now time.Time) []Token {
// Expiry is in the WHERE, not after the LIMIT: expired rows that
// nobody revoked must not fill the window and hide live ones.
// RFC3339 in UTC compares as text.
rows, err := d.db.Query(`SELECT `+tokenColumns+` FROM token
WHERE account = ? AND sealed = 1 AND revoked = '' AND expires > ? ORDER BY created, id LIMIT ?`,
account, now.UTC().Format(time.RFC3339), TokensPerAccount)
if err != nil {
return nil
}
defer rows.Close()
var out []Token
for rows.Next() {
t, err := scanToken(rows)
if err != nil {
return nil
}
if t.Has(ScopeSync) && t.Live(now) {
out = append(out, t)
}
}
return out
}
// TokenCaps is the letters token t resolves to on repo (RFC 0021, The
// letters a token resolves to): the public set, unioned with the
// account's effective set cut to the ceiling. A repository the token
// does not name is reached as the public reaches it. s never flows.
//
// Nothing is stored: it runs on every call, so a letter the account
// loses is gone from every token at once.
func (d *DB) TokenCaps(t Token, repo string) CapSet {
public := CapSet(categoryFor(d.db, repo, "nobody"))
if !t.Reaches(repo) {
return public
}
eff := d.EffectiveCaps(t.Account, repo)
if t.Ceiling != "" {
eff = capIntersect(eff, d.expandCeiling(repo, t.Ceiling))
}
return CapSet(strings.ReplaceAll(capUnion(string(public), string(eff)), "s", ""))
}
// expandCeiling resolves a ceiling's category letters the way
// effectiveIn resolves an account's, so a ceiling of u means what u
// means on that repository.
func (d *DB) expandCeiling(repo, ceiling string) CapSet {
c := noSetup(ceiling)
if strings.ContainsRune(c, 'u') {
c = capUnion(c, categoryFor(d.db, repo, "reader"))
}
if strings.ContainsRune(c, 'v') {
c = capUnion(c, categoryFor(d.db, repo, "developer"))
}
return CapSet(c)
}
// capLetters is every letter a set can answer for.
const capLetters = "abcdefghijklmnopqrtuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// capIntersect is the letters both sets answer for, spelled out one by
// one: the umbrella a and the implies table make a set's string a poor
// guide to what it holds, so the intersection asks HasAny per letter.
// a survives only when both hold it, and then every letter it implies
// is held by both anyway.
func capIntersect(a, b CapSet) CapSet {
var out strings.Builder
for _, l := range capLetters {
if a.HasAny(string(l)) && b.HasAny(string(l)) {
out.WriteRune(l)
}
}
return CapSet(out.String())
}
// Secrets is every credential a login card for user on repo may be
// signed with, in the order they are tried.
//
// A hand-issued sync_user row comes first, as it always has: somebody
// is relying on one. Then each live sync token that reaches repo and
// resolves there to i or y, its §6.3 secret derived on the spot so a
// repository made after the mint needs no re-issue. A token resolving
// to y without i signs only unversioned pushes, and says so.
func (d *DB) Secrets(repo, user string) []xfer.Credential {
// A disabled account signs nothing, hand-issued rows included. A
// sync_user row with no account behind it (a forge without the
// accounts plane) still answers, as it always has.
if acct, ok := d.AccountByName(user); ok && acct.Disabled != "" {
return nil
}
var out []xfer.Credential
var s string
if err := d.db.QueryRow(`SELECT secret FROM sync_user WHERE repo = ? AND user = ?`,
repo, user).Scan(&s); err == nil {
out = append(out, xfer.Credential{Secret: s, CheckIn: true, Publish: true})
}
if d.SyncProjectCode == nil || user == "" {
return out
}
toks := d.liveSyncTokens(user, time.Now())
if len(toks) == 0 {
return out
}
code, ok := d.SyncProjectCode(repo)
if !ok || code == "" {
return out
}
for _, t := range toks {
caps := d.TokenCaps(t, repo)
checkIn, publish := caps.HasAny("i"), caps.HasAny("y")
if !t.Reaches(repo) || (!checkIn && !publish) {
continue
}
value, err := d.seal.open(t.verifier)
if err != nil {
continue
}
out = append(out, xfer.Credential{Secret: syncSecret(code, user, value), Token: t.ID, CheckIn: checkIn, Publish: publish})
}
return out
}
// LegacySyncUsers lists the repositories where account holds a
// hand-issued sync_user row, for the Push credential list.
func (d *DB) LegacySyncUsers(account string) ([]string, error) {
rows, err := d.db.Query(`SELECT repo FROM sync_user WHERE user = ? ORDER BY repo`, account)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// RevokeLegacySyncUser drops one hand-issued row of account's.
func (d *DB) RevokeLegacySyncUser(account, repo string) error {
res, err := d.w.Exec(`DELETE FROM sync_user WHERE user = ? AND repo = ?`, account, repo)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("oltp: %s holds no legacy push credential on %q: %w", account, repo, sql.ErrNoRows)
}
return nil
}
// runPrefix is reserved for run tokens' logins (RFC 0021, While the run
// lives): no account or team may take it, so the resolver can tell a
// run from a person by the name alone.
const runPrefix = "run-"
func reservedRunName(name string) bool {
return strings.HasPrefix(strings.ToLower(name), runPrefix)
}
func runNameRefusal(name string) error {
return Refusal{fmt.Sprintf("%q: names starting with %q are reserved for workflow runs", name, runPrefix)}
}
// ReservedRunNames lists the accounts and teams that took a run- name
// before it was reserved. They are reported, never renamed.
func (d *DB) ReservedRunNames() []string {
var out []string
for _, q := range []string{
`SELECT name FROM account WHERE lower(name) LIKE 'run-%'`,
`SELECT name FROM team WHERE lower(name) LIKE 'run-%'`,
} {
rows, err := d.db.Query(q)
if err != nil {
continue
}
var names []string
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
names = append(names, n)
}
}
rows.Close()
sort.Strings(names)
out = append(out, names...)
}
return out
}
// migrateSyncTokens copies every sync_token row into a token row named
// push: sync scope, every repository, no ceiling, sealed as it was,
// created as it was, expiring a year from the copy so a credential
// minted before expiry existed is not the one that never expires.
//
// sync_token is kept, not dropped: OLTP migrations are additive so an
// older build rolled back onto this database (deploy.sh --rollback, RFC
// 0020) still finds the push credentials it knows. It can be dropped
// once no release older than this one can be rolled back to. Until then
// the two are kept in step from this side, on every open, in one
// transaction:
//
// - a copy whose sync_token row changed or vanished (an older build,
// rolled back to, minted again and replaced it) is retired, since
// the value it holds is one that build already retired;
// - a row not yet copied is copied, once: (account, verifier) is
// unique, and a revoked copy still counts as copied.
//
// Revoking a copy deletes its sync_token row (RevokeToken), so a
// rollback cannot revive it either.
func (d *DB) migrateSyncTokens() error {
if !syncTokenTableIn(d.w) {
return nil
}
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now().UTC()
if _, err := tx.Exec(`UPDATE token SET revoked = ? WHERE migrated = 1 AND revoked = ''
AND NOT EXISTS (SELECT 1 FROM sync_token s WHERE s.user = token.account AND s.sealed = token.verifier)`,
now.Format(time.RFC3339)); err != nil {
return err
}
type old struct{ user, sealed, created string }
rows, err := tx.Query(`SELECT s.user, s.sealed, s.created FROM sync_token s
WHERE NOT EXISTS (SELECT 1 FROM token t WHERE t.account = s.user AND t.verifier = s.sealed)`)
if err != nil {
return err
}
var olds []old
for rows.Next() {
var o old
if err := rows.Scan(&o.user, &o.sealed, &o.created); err != nil {
rows.Close()
return err
}
olds = append(olds, o)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
expires := now.Add(TokenMaxLife).Format(time.RFC3339)
for _, o := range olds {
id, err := newTokenID()
if err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO token (id, account, name, verifier, sealed, scope, created, expires, migrated)
VALUES (?, ?, 'push', ?, 1, ?, ?, ?, 1)`, id, o.user, o.sealed, ScopeSync, o.created, expires); err != nil {
return err
}
}
return tx.Commit()
}