| new file mode 100644 |
| index 000000000..18341ca79 |
| --- /dev/null |
| +++ b/internal/console/authstore.go |
| @@ -0,0 +1,411 @@ |
| +// CLI identity, storage half: the approval requests `carlos auth login` |
| +// opens, and the bearer tokens the console issues when a human approves one. |
| +// |
| +// Shape borrowed from the OAuth 2.1 device authorization grant (RFC 8628), |
| +// because the CLI's real constraint is the same one that grant exists for: |
| +// the terminal asking for the credential frequently has no browser on it — |
| +// it is an SSH session on a box, or a headless CI shell. So the console |
| +// prints a SHORT code the human carries to any browser they do have, and the |
| +// CLI polls with a long code it never shows anyone. No local callback |
| +// listener, no assumption that localhost:port is reachable from the browser. |
| +// |
| +// Two secrets, deliberately different: |
| +// |
| +// - the user code is short (39 bits) because a human types it. It is stored |
| +// in the clear — it must be looked up BY value when the human types it — |
| +// and is only safe because it lives ten minutes, is rate-limited at the |
| +// door, and grants nothing on its own: approving it hands the token to |
| +// whoever holds the DEVICE code, not to whoever typed the user code. |
| +// - the device code is 256 bits and is the CLI's bearer secret for the |
| +// length of the flow. Only its sha256 is stored, like sessions. |
| +package console |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/subtle" |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const ( |
| + // CLIAuthTTL bounds one login attempt — the same ten minutes the browser |
| + // OAuth flow gets. It bounds the APPROVED state too: an approval nobody |
| + // collected is a CLI that walked away, and it must not mint a token later. |
| + CLIAuthTTL = 10 * time.Minute |
| + |
| + // CLITokenTTL is how long an issued CLI token lives: 90 days. |
| + // |
| + // Longer than the 30-day browser session on purpose. Re-authenticating a |
| + // CLI is not a click — it is "open a browser, find the code, approve" — |
| + // and a credential that expires monthly trains people to paste the token |
| + // into a shell profile or a CI secret and never think about it again, |
| + // which is strictly worse than a bounded, revocable, listed token. 90 days |
| + // is a quarter: long enough that nobody routes around it, short enough |
| + // that a laptop that fell out of a bag stops being an identity by itself. |
| + // Every token is revocable the moment it is suspect (see CLITokens). |
| + CLITokenTTL = 90 * 24 * time.Hour |
| + |
| + // userCodeLen is the length of the typed code, before the grouping dash. |
| + // Eight characters of a 31-symbol alphabet is ~39.6 bits, which with a |
| + // ten-minute life and a rate-limited approval endpoint is far more than |
| + // guessing survives. |
| + userCodeLen = 8 |
| + |
| + // userCodeAlphabet has no 0/O, 1/I/L or U (which turns into V in some |
| + // hands and is the usual source of accidental profanity in code |
| + // generators). What is left is unambiguous read aloud down a phone. |
| + userCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ" |
| +) |
| + |
| +// Poll statuses. Ours, not RFC 8628's error codes: this is the console's own |
| +// endpoint and a plain word is what the CLI prints to a human. |
| +const ( |
| + CLIAuthPending = "pending" |
| + CLIAuthApproved = "approved" |
| + CLIAuthDenied = "denied" |
| + CLIAuthExpired = "expired" |
| + // CLIAuthUnknown covers "never existed" and "already collected" with one |
| + // answer, so a device code cannot be used to probe for live flows. |
| + CLIAuthUnknown = "unknown" |
| +) |
| + |
| +// CLIAuthRequest is one pending login, as the approval page sees it. It |
| +// deliberately carries no secret: the page renders the label and where the |
| +// request came from so a human can recognise their own terminal. |
| +type CLIAuthRequest struct { |
| + UserCode string |
| + Label string |
| + RemoteAddr string |
| + CreatedAt time.Time |
| + ExpiresAt time.Time |
| +} |
| + |
| +// CLIToken is one issued credential as its owner sees it on the console. ID |
| +// is the token's sha256, which is safe to put in a page and in a form: it |
| +// names the row without being the bearer secret. |
| +type CLIToken struct { |
| + ID string |
| + Address string |
| + Label string |
| + CreatedAt time.Time |
| + ExpiresAt time.Time |
| + LastUsed time.Time |
| +} |
| + |
| +// migrateCLIAuth creates the CLI-identity tables. Additive and idempotent, |
| +// called from Store.migrate. |
| +func (s *Store) migrateCLIAuth() error { |
| + for _, stmt := range []string{ |
| + // One in-flight login. code_hash is the sha256 of the device code; |
| + // user_code is stored in the clear because the human types it and it |
| + // has to be looked up by value. |
| + `CREATE TABLE IF NOT EXISTS cli_auth_requests ( |
| + code_hash TEXT PRIMARY KEY, |
| + user_code TEXT NOT NULL UNIQUE, |
| + label TEXT NOT NULL DEFAULT '', |
| + remote_addr TEXT NOT NULL DEFAULT '', |
| + address TEXT NOT NULL DEFAULT '', |
| + state TEXT NOT NULL, |
| + created_at INTEGER NOT NULL, |
| + expires_at INTEGER NOT NULL |
| +);`, |
| + // The issued credential. Only the hash is stored — a console database |
| + // that leaks must not hand out working CLI tokens. |
| + `CREATE TABLE IF NOT EXISTS cli_tokens ( |
| + token_hash TEXT PRIMARY KEY, |
| + address TEXT NOT NULL, |
| + label TEXT NOT NULL DEFAULT '', |
| + remote_addr TEXT NOT NULL DEFAULT '', |
| + created_at INTEGER NOT NULL, |
| + expires_at INTEGER NOT NULL, |
| + last_used INTEGER NOT NULL DEFAULT 0 |
| +);`, |
| + `CREATE INDEX IF NOT EXISTS cli_tokens_address ON cli_tokens (address);`, |
| + } { |
| + if _, err := s.db.Exec(stmt); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// newUserCode mints the typed code, grouped XXXX-XXXX so it can be read out |
| +// and typed back without losing your place. |
| +func newUserCode() (string, error) { |
| + b := make([]byte, userCodeLen) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", err |
| + } |
| + out := make([]byte, 0, userCodeLen+1) |
| + for i, v := range b { |
| + if i == userCodeLen/2 { |
| + out = append(out, '-') |
| + } |
| + // Modulo bias over a 30-symbol alphabet is at most 0.4 bits across the |
| + // whole code; irrelevant against a rate-limited ten-minute window, and |
| + // rejection sampling here would only add a loop to read wrong. |
| + out = append(out, userCodeAlphabet[int(v)%len(userCodeAlphabet)]) |
| + } |
| + return string(out), nil |
| +} |
| + |
| +// NormalizeUserCode is what the approval form does to whatever a human typed: |
| +// upper-case, dashes and spaces dropped, then re-grouped. "wxyz 4823", |
| +// "wxyz-4823" and "WXYZ4823" are the same code. |
| +func NormalizeUserCode(in string) string { |
| + var b strings.Builder |
| + for _, r := range strings.ToUpper(strings.TrimSpace(in)) { |
| + if r == '-' || r == ' ' || r == '\t' { |
| + continue |
| + } |
| + b.WriteRune(r) |
| + } |
| + s := b.String() |
| + if len(s) != userCodeLen { |
| + return s // wrong length: hand it back and let the lookup miss |
| + } |
| + return s[:userCodeLen/2] + "-" + s[userCodeLen/2:] |
| +} |
| + |
| +// NewCLIAuth opens a login attempt: a typed user code for the human, a device |
| +// code for the CLI. label is what the CLI calls itself (user@host) and |
| +// remoteAddr where it dialled from — both are shown on the approval page so a |
| +// human can tell their own terminal from somebody else's. |
| +// |
| +// Housekeeping on the write path, the same idiom handleContinue uses: expired |
| +// requests and tokens are dropped here rather than by a sweeper nobody runs. |
| +func (s *Store) NewCLIAuth(label, remoteAddr string, now time.Time) (userCode, deviceCode string, err error) { |
| + s.purgeCLIAuth(now) |
| + deviceCode, err = randomToken() |
| + if err != nil { |
| + return "", "", err |
| + } |
| + // One retry is enough: a collision on 39 bits of live codes means the |
| + // second draw is astronomically unlikely to collide too. |
| + for attempt := 0; attempt < 2; attempt++ { |
| + userCode, err = newUserCode() |
| + if err != nil { |
| + return "", "", err |
| + } |
| + _, err = s.db.Exec(`INSERT INTO cli_auth_requests |
| +(code_hash, user_code, label, remote_addr, state, created_at, expires_at) |
| +VALUES (?, ?, ?, ?, ?, ?, ?)`, |
| + hashToken(deviceCode), userCode, label, remoteAddr, CLIAuthPending, |
| + now.Unix(), now.Add(CLIAuthTTL).Unix()) |
| + if err == nil { |
| + return userCode, deviceCode, nil |
| + } |
| + if !strings.Contains(err.Error(), "UNIQUE") { |
| + return "", "", err |
| + } |
| + } |
| + return "", "", fmt.Errorf("console: could not mint a free login code") |
| +} |
| + |
| +// purgeCLIAuth drops what has aged out. Best-effort: losing a sweep must |
| +// never fail the login it is housekeeping for. |
| +func (s *Store) purgeCLIAuth(now time.Time) { |
| + s.db.Exec(`DELETE FROM cli_auth_requests WHERE expires_at < ?`, now.Unix()) |
| + s.db.Exec(`DELETE FROM cli_tokens WHERE expires_at < ?`, now.Unix()) |
| +} |
| + |
| +// CLIAuthByUserCode resolves a typed code to the request it names, for the |
| +// approval page. Expired and already-decided requests are "no such code" — |
| +// the page must never offer to approve something that cannot be approved. |
| +func (s *Store) CLIAuthByUserCode(userCode string, now time.Time) (*CLIAuthRequest, error) { |
| + var r CLIAuthRequest |
| + var created, expires int64 |
| + var state string |
| + err := s.db.QueryRow(`SELECT user_code, label, remote_addr, state, created_at, expires_at |
| +FROM cli_auth_requests WHERE user_code = ?`, NormalizeUserCode(userCode)). |
| + Scan(&r.UserCode, &r.Label, &r.RemoteAddr, &state, &created, &expires) |
| + if err == sql.ErrNoRows { |
| + return nil, errors.New("console: no such login code") |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + if state != CLIAuthPending || now.Unix() > expires { |
| + return nil, errors.New("console: that login code has expired or was already used") |
| + } |
| + r.CreatedAt = time.Unix(created, 0).UTC() |
| + r.ExpiresAt = time.Unix(expires, 0).UTC() |
| + return &r, nil |
| +} |
| + |
| +// ApproveCLIAuth records that address approved the request. The token is NOT |
| +// minted here: it is minted when the CLI collects it (PollCLIAuth), so no |
| +// bearer credential is ever at rest waiting to be picked up. |
| +func (s *Store) ApproveCLIAuth(userCode, address string, now time.Time) error { |
| + return s.decideCLIAuth(userCode, address, CLIAuthApproved, now) |
| +} |
| + |
| +// DenyCLIAuth is the "that wasn't me" button. A denied request is final: |
| +// approving it afterwards is refused, so a mis-click cannot be walked back |
| +// into a credential by whoever is holding the terminal. |
| +func (s *Store) DenyCLIAuth(userCode string, now time.Time) error { |
| + return s.decideCLIAuth(userCode, "", CLIAuthDenied, now) |
| +} |
| + |
| +func (s *Store) decideCLIAuth(userCode, address, state string, now time.Time) error { |
| + res, err := s.db.Exec(`UPDATE cli_auth_requests SET state = ?, address = ? |
| +WHERE user_code = ? AND state = ? AND expires_at >= ?`, |
| + state, address, NormalizeUserCode(userCode), CLIAuthPending, now.Unix()) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return errors.New("console: that login code has expired or was already used") |
| + } |
| + return nil |
| +} |
| + |
| +// PollCLIAuth is what `carlos auth login` calls in a loop. On approval it |
| +// mints the token, records it, deletes the request, and returns the raw token |
| +// exactly once — the caller never gets a second copy, so a stolen device code |
| +// replayed later collects nothing. |
| +func (s *Store) PollCLIAuth(deviceCode string, now time.Time) (status, rawToken string, err error) { |
| + if deviceCode == "" { |
| + return CLIAuthUnknown, "", nil |
| + } |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return "", "", err |
| + } |
| + defer tx.Rollback() |
| + hash := hashToken(deviceCode) |
| + var storedHash, state, address, label, remote string |
| + var expires int64 |
| + err = tx.QueryRow(`SELECT code_hash, state, address, label, remote_addr, expires_at |
| +FROM cli_auth_requests WHERE code_hash = ?`, hash). |
| + Scan(&storedHash, &state, &address, &label, &remote, &expires) |
| + if err == sql.ErrNoRows { |
| + return CLIAuthUnknown, "", nil |
| + } |
| + if err != nil { |
| + return "", "", err |
| + } |
| + // Belt and braces over the indexed lookup: the comparison that decides |
| + // whether this caller holds the secret is constant time. |
| + if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 { |
| + return CLIAuthUnknown, "", nil |
| + } |
| + if now.Unix() > expires { |
| + return CLIAuthExpired, "", nil |
| + } |
| + switch state { |
| + case CLIAuthPending: |
| + return CLIAuthPending, "", nil |
| + case CLIAuthDenied: |
| + return CLIAuthDenied, "", nil |
| + case CLIAuthApproved: |
| + default: |
| + return CLIAuthUnknown, "", nil |
| + } |
| + raw, err := randomToken() |
| + if err != nil { |
| + return "", "", err |
| + } |
| + if _, err := tx.Exec(`INSERT INTO cli_tokens |
| +(token_hash, address, label, remote_addr, created_at, expires_at) |
| +VALUES (?, ?, ?, ?, ?, ?)`, |
| + hashToken(raw), address, label, remote, now.Unix(), now.Add(CLITokenTTL).Unix()); err != nil { |
| + return "", "", err |
| + } |
| + // Burn the request in the same transaction that mints the token: the two |
| + // must never be separable, or a crash could hand out two tokens for one |
| + // approval. |
| + if _, err := tx.Exec(`DELETE FROM cli_auth_requests WHERE code_hash = ?`, hash); err != nil { |
| + return "", "", err |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return "", "", err |
| + } |
| + return CLIAuthApproved, raw, nil |
| +} |
| + |
| +// CLITokenAddress resolves a bearer token to the address that approved it; ok |
| +// is false for unknown, revoked and expired tokens. This is the whole |
| +// authorization decision for every CLI-facing endpoint. |
| +func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) { |
| + if rawToken == "" { |
| + return "", false |
| + } |
| + hash := hashToken(rawToken) |
| + var storedHash, addr string |
| + var expires int64 |
| + err := s.db.QueryRow(`SELECT token_hash, address, expires_at FROM cli_tokens WHERE token_hash = ?`, |
| + hash).Scan(&storedHash, &addr, &expires) |
| + if err != nil { |
| + return "", false |
| + } |
| + if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 { |
| + return "", false |
| + } |
| + if now.Unix() > expires { |
| + return "", false |
| + } |
| + // Best-effort: "last used" is for the owner's benefit on the tokens page, |
| + // and a failed write must not fail the request it is annotating. |
| + s.db.Exec(`UPDATE cli_tokens SET last_used = ? WHERE token_hash = ?`, now.Unix(), hash) |
| + return addr, true |
| +} |
| + |
| +// CLITokens lists one address's live CLI credentials, newest first — the |
| +// listing the tokens page renders so an owner can see, and kill, every |
| +// terminal that speaks as them. |
| +func (s *Store) CLITokens(address string) ([]CLIToken, error) { |
| + rows, err := s.db.Query(`SELECT token_hash, address, label, created_at, expires_at, last_used |
| +FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []CLIToken |
| + for rows.Next() { |
| + var t CLIToken |
| + var created, expires, used int64 |
| + if err := rows.Scan(&t.ID, &t.Address, &t.Label, &created, &expires, &used); err != nil { |
| + return nil, err |
| + } |
| + t.CreatedAt = time.Unix(created, 0).UTC() |
| + t.ExpiresAt = time.Unix(expires, 0).UTC() |
| + if used != 0 { |
| + t.LastUsed = time.Unix(used, 0).UTC() |
| + } |
| + out = append(out, t) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// RevokeCLIToken kills one token by its listing id, scoped to its owner: an |
| +// id is not a secret, so the address is what makes this safe. |
| +func (s *Store) RevokeCLIToken(address, id string) error { |
| + res, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ? AND address = ?`, id, address) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return errors.New("console: no such token") |
| + } |
| + return nil |
| +} |
| + |
| +// RevokeCLITokenValue kills a token by the token itself — what `carlos auth |
| +// logout` sends, so signing out of a terminal actually retires the credential |
| +// rather than just forgetting it locally. |
| +func (s *Store) RevokeCLITokenValue(rawToken string) error { |
| + _, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ?`, hashToken(rawToken)) |
| + return err |
| +} |