| 1 | // CLI identity, storage half: the approval requests `carlos auth login` |
| 2 | // opens, and the bearer tokens the console issues when a human approves one. |
| 3 | // |
| 4 | // Shape borrowed from the OAuth 2.1 device authorization grant (RFC 8628), |
| 5 | // because the CLI's real constraint is the same one that grant exists for: |
| 6 | // the terminal asking for the credential frequently has no browser on it — |
| 7 | // it is an SSH session on a box, or a headless CI shell. So the console |
| 8 | // prints a SHORT code the human carries to any browser they do have, and the |
| 9 | // CLI polls with a long code it never shows anyone. No local callback |
| 10 | // listener, no assumption that localhost:port is reachable from the browser. |
| 11 | // |
| 12 | // Two secrets, deliberately different: |
| 13 | // |
| 14 | // - the user code is short (39 bits) because a human types it. It is stored |
| 15 | // in the clear — it must be looked up BY value when the human types it — |
| 16 | // and is only safe because it lives ten minutes, is rate-limited at the |
| 17 | // door, and grants nothing on its own: approving it hands the token to |
| 18 | // whoever holds the DEVICE code, not to whoever typed the user code. |
| 19 | // - the device code is 256 bits and is the CLI's bearer secret for the |
| 20 | // length of the flow. Only its sha256 is stored, like sessions. |
| 21 | package console |
| 22 | |
| 23 | import ( |
| 24 | "context" |
| 25 | "crypto/rand" |
| 26 | "crypto/subtle" |
| 27 | "database/sql" |
| 28 | "encoding/hex" |
| 29 | "errors" |
| 30 | "fmt" |
| 31 | "strings" |
| 32 | "sync/atomic" |
| 33 | "time" |
| 34 | |
| 35 | "github.com/carlosframework/platform/internal/runs" |
| 36 | "github.com/carlosframework/platform/internal/svcrole" |
| 37 | ) |
| 38 | |
| 39 | const ( |
| 40 | // CLIAuthTTL bounds one login attempt — the same ten minutes the browser |
| 41 | // OAuth flow gets. It bounds the APPROVED state too: an approval nobody |
| 42 | // collected is a CLI that walked away, and it must not mint a token later. |
| 43 | CLIAuthTTL = 10 * time.Minute |
| 44 | |
| 45 | // CLITokenTTL is how long an issued CLI token lives: 90 days. |
| 46 | // |
| 47 | // Longer than the 30-day browser session on purpose. Re-authenticating a |
| 48 | // CLI is not a click — it is "open a browser, find the code, approve" — |
| 49 | // and a credential that expires monthly trains people to paste the token |
| 50 | // into a shell profile or a CI secret and never think about it again, |
| 51 | // which is strictly worse than a bounded, revocable, listed token. 90 days |
| 52 | // is a quarter: long enough that nobody routes around it, short enough |
| 53 | // that a laptop that fell out of a bag stops being an identity by itself. |
| 54 | // Every token is revocable the moment it is suspect (see CLITokens). |
| 55 | CLITokenTTL = 90 * 24 * time.Hour |
| 56 | |
| 57 | // userCodeLen is the length of the typed code, before the grouping dash. |
| 58 | // Eight characters of the 30-symbol alphabet below is ~39 bits |
| 59 | // (8·log2(30) = 39.3), which with a ten-minute life and a rate-limited |
| 60 | // approval endpoint is far more than guessing survives. |
| 61 | userCodeLen = 8 |
| 62 | |
| 63 | // userCodeAlphabet has no 0/O, 1/I/L or U (which turns into V in some |
| 64 | // hands and is the usual source of accidental profanity in code |
| 65 | // generators). What is left is 30 symbols, unambiguous read aloud down a |
| 66 | // phone. |
| 67 | userCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ" |
| 68 | ) |
| 69 | |
| 70 | // Poll statuses. Ours, not RFC 8628's error codes: this is the console's own |
| 71 | // endpoint and a plain word is what the CLI prints to a human. |
| 72 | const ( |
| 73 | CLIAuthPending = "pending" |
| 74 | CLIAuthApproved = "approved" |
| 75 | CLIAuthDenied = "denied" |
| 76 | CLIAuthExpired = "expired" |
| 77 | // CLIAuthUnknown covers "never existed" and "already collected" with one |
| 78 | // answer, so a device code cannot be used to probe for live flows. |
| 79 | CLIAuthUnknown = "unknown" |
| 80 | ) |
| 81 | |
| 82 | // CLIAuthRequest is one pending login, as the approval page sees it. It |
| 83 | // deliberately carries no secret: the page renders the label and where the |
| 84 | // request came from so a human can recognise their own terminal. |
| 85 | type CLIAuthRequest struct { |
| 86 | UserCode string |
| 87 | Label string |
| 88 | RemoteAddr string |
| 89 | CreatedAt time.Time |
| 90 | ExpiresAt time.Time |
| 91 | } |
| 92 | |
| 93 | // CLIToken is one issued credential as its owner sees it on the console. ID |
| 94 | // is the token's sha256, which is safe to put in a page and in a form: it |
| 95 | // names the row without being the bearer secret. |
| 96 | // |
| 97 | // Kind/AccountID/App carry what an app token IS, because a listing that |
| 98 | // showed only Label and ExpiresAt would render a never-expiring app token |
| 99 | // (expires_at 0) as expiring on 1 Jan 1970 — a credential that reads as |
| 100 | // long dead is one nobody thinks to rotate, and one whose "Revoke" button |
| 101 | // gets pressed by somebody who thinks it does nothing. |
| 102 | type CLIToken struct { |
| 103 | ID string |
| 104 | Address string |
| 105 | Label string |
| 106 | RemoteAddr string |
| 107 | Kind string // "" = a person's token, "app" = app-held (see NewAppToken), "service" = a service principal |
| 108 | AccountID int64 // app/service tokens only: the account it is bound to |
| 109 | App string // app/service tokens only: the app it is bound to ("" = account-scoped) |
| 110 | // Role is a svcrole catalogue name, service principals only. Plain |
| 111 | // string, not svcrole.Role: this crosses into an html/template, which |
| 112 | // cannot call svcrole.Parse. |
| 113 | Role string |
| 114 | Name string // service principals only: the stable handle rotate/revoke address by |
| 115 | CreatedAt time.Time |
| 116 | ExpiresAt time.Time |
| 117 | LastUsed time.Time |
| 118 | RevokedAt time.Time // zero unless the credential has been revoked |
| 119 | // RotatedAt is when this credential's CURRENT secret began: stamped at |
| 120 | // mint and again at every RotateServicePrincipal, left alone by |
| 121 | // everything else. CreatedAt is when the credential was granted and |
| 122 | // never changes; RotatedAt is how a rotation policy answers "how old is |
| 123 | // the secret in use right now" without that question destroying the |
| 124 | // answer to "how long has this credential existed". Zero for every row |
| 125 | // minted before this column existed, including every legacy kind='app' |
| 126 | // row NewAppToken mints — those never rotate through this path. |
| 127 | RotatedAt time.Time |
| 128 | // Rungs is a deploy credential's scope in its stored, canonical form |
| 129 | // ("canary,edge"); empty for every other kind and role. |
| 130 | Rungs string |
| 131 | // Commands is a run credential's granted command prefixes in their |
| 132 | // stored form, canonical JSON; empty for every other kind and role. |
| 133 | Commands string |
| 134 | } |
| 135 | |
| 136 | // RungList is Rungs for a person to read: "canary, edge". |
| 137 | func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", ") } |
| 138 | |
| 139 | // CommandPrefixes is Commands parsed: nil when there are none, or when the |
| 140 | // stored form cannot be read (a listing shows nothing rather than guessing; |
| 141 | // CLIPrincipal refuses such a row outright). |
| 142 | func (t CLIToken) CommandPrefixes() [][]string { |
| 143 | p, ok := parseStoredCommands(t.Commands) |
| 144 | if !ok { |
| 145 | return nil |
| 146 | } |
| 147 | return p |
| 148 | } |
| 149 | |
| 150 | // CommandsUnreadable reports a run credential whose stored command list |
| 151 | // cannot be read. CLIPrincipal refuses such a row, so a listing must say |
| 152 | // so rather than show it as having no commands. |
| 153 | func (t CLIToken) CommandsUnreadable() bool { |
| 154 | if t.Kind != cliKindService || t.Role != string(svcrole.Run) { |
| 155 | return false |
| 156 | } |
| 157 | _, ok := parseStoredCommands(t.Commands) |
| 158 | return !ok |
| 159 | } |
| 160 | |
| 161 | // UnreadableCommands is what a listing shows for CommandsUnreadable. |
| 162 | const UnreadableCommands = runs.UnreadableCommands |
| 163 | |
| 164 | // CommandList is Commands for a person to read: "import classic, import |
| 165 | // sync", or UnreadableCommands for a run row whose list cannot be read. |
| 166 | func (t CLIToken) CommandList() string { |
| 167 | if t.CommandsUnreadable() { |
| 168 | return UnreadableCommands |
| 169 | } |
| 170 | return runs.FormatCommandList(t.CommandPrefixes()) |
| 171 | } |
| 172 | |
| 173 | // NeverExpires reports whether this credential has no expiry — true only |
| 174 | // for app and service tokens, whose expires_at is 0 by design (spec §8). The |
| 175 | // page asks this rather than testing ExpiresAt against the epoch itself, so |
| 176 | // the "0 means never" convention lives in exactly one place. |
| 177 | func (t CLIToken) NeverExpires() bool { |
| 178 | return (t.IsApp() || t.IsService()) && (t.ExpiresAt.IsZero() || t.ExpiresAt.Unix() == 0) |
| 179 | } |
| 180 | |
| 181 | // IsApp reports whether this row is an app-held token, for the page's |
| 182 | // label. A template cannot see cliKindApp, so it asks here instead of |
| 183 | // comparing against a string literal of its own. |
| 184 | func (t CLIToken) IsApp() bool { return t.Kind == cliKindApp } |
| 185 | |
| 186 | // IsService reports whether this row is a service principal, for the same |
| 187 | // reason IsApp exists: a template cannot see cliKindService. |
| 188 | func (t CLIToken) IsService() bool { return t.Kind == cliKindService } |
| 189 | |
| 190 | // Handle is how this credential is ADDRESSED by the account pane's rotate |
| 191 | // and revoke forms and by `carlos services revoke`: the NAME its owner chose |
| 192 | // when it has one, and its listing id otherwise. |
| 193 | // |
| 194 | // A legacy kind='app' row has no name — every one of them carries the empty |
| 195 | // string — so the id is the only handle it will ever have, and an owner who |
| 196 | // could see such a row without being able to address it could not kill it. |
| 197 | // The two grammars are disjoint by length (a name is at most 32 characters, |
| 198 | // a listing id is exactly 64 hex), which is what lets serviceHandleOf read |
| 199 | // either from one field without a second one saying which it is. |
| 200 | // |
| 201 | // It lives here rather than in the template so the addressing rule is |
| 202 | // written down once, next to IsApp and IsService and for the same reason. |
| 203 | func (t CLIToken) Handle() string { |
| 204 | if t.Name != "" { |
| 205 | return t.Name |
| 206 | } |
| 207 | return t.ID |
| 208 | } |
| 209 | |
| 210 | // migrateCLIAuth creates the CLI-identity tables. Additive and idempotent, |
| 211 | // called from Store.migrate. |
| 212 | func (s *Store) migrateCLIAuth() error { |
| 213 | for _, stmt := range []string{ |
| 214 | // One in-flight login. code_hash is the sha256 of the device code; |
| 215 | // user_code is stored in the clear because the human types it and it |
| 216 | // has to be looked up by value. |
| 217 | `CREATE TABLE IF NOT EXISTS cli_auth_requests ( |
| 218 | code_hash TEXT PRIMARY KEY, |
| 219 | user_code TEXT NOT NULL UNIQUE, |
| 220 | label TEXT NOT NULL DEFAULT '', |
| 221 | remote_addr TEXT NOT NULL DEFAULT '', |
| 222 | address TEXT NOT NULL DEFAULT '', |
| 223 | state TEXT NOT NULL, |
| 224 | created_at INTEGER NOT NULL, |
| 225 | expires_at INTEGER NOT NULL |
| 226 | );`, |
| 227 | // The issued credential. Only the hash is stored — a console database |
| 228 | // that leaks must not hand out working CLI tokens. |
| 229 | `CREATE TABLE IF NOT EXISTS cli_tokens ( |
| 230 | token_hash TEXT PRIMARY KEY, |
| 231 | address TEXT NOT NULL, |
| 232 | label TEXT NOT NULL DEFAULT '', |
| 233 | remote_addr TEXT NOT NULL DEFAULT '', |
| 234 | created_at INTEGER NOT NULL, |
| 235 | expires_at INTEGER NOT NULL, |
| 236 | last_used INTEGER NOT NULL DEFAULT 0 |
| 237 | );`, |
| 238 | `CREATE INDEX IF NOT EXISTS cli_tokens_address ON cli_tokens (address);`, |
| 239 | // Expiry is swept on the write path, and a sweep without these is a |
| 240 | // full table scan on a database with one writer that every console |
| 241 | // page also queues behind. |
| 242 | `CREATE INDEX IF NOT EXISTS cli_tokens_expires ON cli_tokens (expires_at);`, |
| 243 | `CREATE INDEX IF NOT EXISTS cli_auth_requests_expires ON cli_auth_requests (expires_at);`, |
| 244 | } { |
| 245 | if _, err := s.db.Exec(stmt); err != nil { |
| 246 | return err |
| 247 | } |
| 248 | } |
| 249 | // New columns go in this ALTER list — the "duplicate column" error is |
| 250 | // swallowed so the list stays idempotent across reopens, the same pattern |
| 251 | // the registry migrate uses. Every one defaults to the USER-token value, |
| 252 | // so every row written before this change reads back as exactly what it |
| 253 | // was: kind '', bound to no account and no app, no role, no name, not |
| 254 | // revoked. |
| 255 | for _, stmt := range []string{ |
| 256 | `ALTER TABLE cli_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT ''`, |
| 257 | `ALTER TABLE cli_tokens ADD COLUMN account_id INTEGER NOT NULL DEFAULT 0`, |
| 258 | `ALTER TABLE cli_tokens ADD COLUMN app TEXT NOT NULL DEFAULT ''`, |
| 259 | `ALTER TABLE cli_tokens ADD COLUMN role TEXT NOT NULL DEFAULT ''`, |
| 260 | `ALTER TABLE cli_tokens ADD COLUMN name TEXT NOT NULL DEFAULT ''`, |
| 261 | `ALTER TABLE cli_tokens ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0`, |
| 262 | `ALTER TABLE cli_tokens ADD COLUMN rotated_at INTEGER NOT NULL DEFAULT 0`, |
| 263 | `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`, |
| 264 | // credential_id is a service credential's stable identity: random, |
| 265 | // minted with the row, kept by rotation, never derived from the name |
| 266 | // (a revoked name can be minted again and must not inherit what the |
| 267 | // old credential started). commands is a run credential's granted |
| 268 | // command prefixes, canonical JSON. Both default to '', a person's |
| 269 | // token's value; backfillCredentialIDs gives older service rows an id. |
| 270 | `ALTER TABLE cli_tokens ADD COLUMN credential_id TEXT NOT NULL DEFAULT ''`, |
| 271 | `ALTER TABLE cli_tokens ADD COLUMN commands TEXT NOT NULL DEFAULT ''`, |
| 272 | } { |
| 273 | if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| 274 | return err |
| 275 | } |
| 276 | } |
| 277 | // credential_id is unique wherever it is set. It is 128 random bits, so |
| 278 | // this never fires on a real mint; it makes "one id, one credential" |
| 279 | // structural, which is what a run's ownership check (Task 3) leans on. |
| 280 | // A person's token keeps '' and any number of those coexist. Like the |
| 281 | // index below, it must follow the ALTER loop that adds the column. |
| 282 | if _, err := s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS cli_tokens_credential_id |
| 283 | ON cli_tokens (credential_id) WHERE credential_id != '';`); err != nil { |
| 284 | return err |
| 285 | } |
| 286 | // This index needs `name`, which the ALTER loop above just added (or |
| 287 | // already had, on a reopened database). It cannot live in the |
| 288 | // CREATE INDEX IF NOT EXISTS block above: that block runs before the |
| 289 | // ALTER loop, so on a fresh database it would fail with "no such column: |
| 290 | // name" and take Open down with it. |
| 291 | if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS cli_tokens_account_name ON cli_tokens (account_id, name);`); err != nil { |
| 292 | return err |
| 293 | } |
| 294 | // Name uniqueness among live service principals is enforced by |
| 295 | // NewServicePrincipal's own COUNT-then-INSERT inside a transaction, which |
| 296 | // is race-free only because SetMaxOpenConns(1) (store.go) gives that |
| 297 | // transaction the sole connection — a fact this file cannot see. This |
| 298 | // UNIQUE partial index makes the invariant structural rather than |
| 299 | // resting on a pool setting two files away: it is scoped to |
| 300 | // kind='service' AND revoked_at=0 so it composes with the soft revoke — |
| 301 | // a revoked `hub` does not block a fresh `hub` of the same name. The |
| 302 | // transaction stays too, belt and braces, and is what turns a would-be |
| 303 | // constraint violation into the friendly ErrServiceNameTaken. |
| 304 | if _, err := s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS cli_tokens_service_name |
| 305 | ON cli_tokens (account_id, name) |
| 306 | WHERE kind = 'service' AND revoked_at = 0;`); err != nil { |
| 307 | return err |
| 308 | } |
| 309 | return s.backfillCredentialIDs() |
| 310 | } |
| 311 | |
| 312 | // backfillCredentialIDs gives every service row (kind 'service', and the |
| 313 | // legacy kind 'app', which reads back as a service principal) that has no |
| 314 | // credential_id a fresh random one. It runs at every Open and writes only |
| 315 | // rows whose id is still empty, so a second run changes nothing: an id, |
| 316 | // once given, is never replaced. A person's row keeps ”. |
| 317 | // |
| 318 | // The ids come from crypto/rand here rather than SQLite's randomblob, so |
| 319 | // the backfill's ids are drawn exactly as a mint's are. |
| 320 | func (s *Store) backfillCredentialIDs() error { |
| 321 | rows, err := s.db.Query(`SELECT token_hash FROM cli_tokens WHERE kind IN (?, ?) AND credential_id = ''`, |
| 322 | cliKindService, cliKindApp) |
| 323 | if err != nil { |
| 324 | return err |
| 325 | } |
| 326 | var hashes []string |
| 327 | for rows.Next() { |
| 328 | var h string |
| 329 | if err := rows.Scan(&h); err != nil { |
| 330 | rows.Close() |
| 331 | return err |
| 332 | } |
| 333 | hashes = append(hashes, h) |
| 334 | } |
| 335 | rows.Close() |
| 336 | if err := rows.Err(); err != nil { |
| 337 | return err |
| 338 | } |
| 339 | for _, h := range hashes { |
| 340 | id, err := newCredentialID() |
| 341 | if err != nil { |
| 342 | return err |
| 343 | } |
| 344 | if _, err := s.db.Exec(`UPDATE cli_tokens SET credential_id = ? WHERE token_hash = ? AND credential_id = ''`, id, h); err != nil { |
| 345 | return err |
| 346 | } |
| 347 | } |
| 348 | return nil |
| 349 | } |
| 350 | |
| 351 | // newCredentialID mints a credential's stable id: 128 random bits, hex. It |
| 352 | // is an identity, not a secret, and it is never derived from the name. |
| 353 | func newCredentialID() (string, error) { |
| 354 | b := make([]byte, 16) |
| 355 | if _, err := rand.Read(b); err != nil { |
| 356 | return "", err |
| 357 | } |
| 358 | return hex.EncodeToString(b), nil |
| 359 | } |
| 360 | |
| 361 | // The three token kinds cli_tokens holds. The user kind is the empty string |
| 362 | // on purpose: it is what every row written before the app kind existed |
| 363 | // already says, so the default IS the safe answer. cliKindService is the |
| 364 | // principal that is not an address (see svcprincipal.go). |
| 365 | const ( |
| 366 | cliKindUser = "" |
| 367 | cliKindApp = "app" |
| 368 | cliKindService = "service" |
| 369 | ) |
| 370 | |
| 371 | // newUserCode mints the typed code, grouped XXXX-XXXX so it can be read out |
| 372 | // and typed back without losing your place. |
| 373 | func newUserCode() (string, error) { |
| 374 | b := make([]byte, userCodeLen) |
| 375 | if _, err := rand.Read(b); err != nil { |
| 376 | return "", err |
| 377 | } |
| 378 | out := make([]byte, 0, userCodeLen+1) |
| 379 | for i, v := range b { |
| 380 | if i == userCodeLen/2 { |
| 381 | out = append(out, '-') |
| 382 | } |
| 383 | // Modulo bias over a 30-symbol alphabet is at most 0.4 bits across the |
| 384 | // whole code; irrelevant against a rate-limited ten-minute window, and |
| 385 | // rejection sampling here would only add a loop to read wrong. |
| 386 | out = append(out, userCodeAlphabet[int(v)%len(userCodeAlphabet)]) |
| 387 | } |
| 388 | return string(out), nil |
| 389 | } |
| 390 | |
| 391 | // NormalizeUserCode is what the approval form does to whatever a human typed: |
| 392 | // upper-case, dashes and spaces dropped, then re-grouped. "wxyz 4823", |
| 393 | // "wxyz-4823" and "WXYZ4823" are the same code. |
| 394 | func NormalizeUserCode(in string) string { |
| 395 | var b strings.Builder |
| 396 | for _, r := range strings.ToUpper(strings.TrimSpace(in)) { |
| 397 | if r == '-' || r == ' ' || r == '\t' { |
| 398 | continue |
| 399 | } |
| 400 | b.WriteRune(r) |
| 401 | } |
| 402 | s := b.String() |
| 403 | if len(s) != userCodeLen { |
| 404 | return s // wrong length: hand it back and let the lookup miss |
| 405 | } |
| 406 | return s[:userCodeLen/2] + "-" + s[userCodeLen/2:] |
| 407 | } |
| 408 | |
| 409 | // NewCLIAuth opens a login attempt: a typed user code for the human, a device |
| 410 | // code for the CLI. label is what the CLI calls itself (user@host) and |
| 411 | // remoteAddr where it dialled from — both are shown on the approval page so a |
| 412 | // human can tell their own terminal from somebody else's. |
| 413 | // |
| 414 | // Housekeeping on the write path, the same idiom handleContinue uses: expired |
| 415 | // requests and tokens are dropped here rather than by a sweeper nobody runs. |
| 416 | func (s *Store) NewCLIAuth(label, remoteAddr string, now time.Time) (userCode, deviceCode string, err error) { |
| 417 | s.PurgeCLIAuth(now) |
| 418 | deviceCode, err = randomToken() |
| 419 | if err != nil { |
| 420 | return "", "", err |
| 421 | } |
| 422 | // One retry is enough: a collision on 39 bits of live codes means the |
| 423 | // second draw is astronomically unlikely to collide too. |
| 424 | for attempt := 0; attempt < 2; attempt++ { |
| 425 | userCode, err = newUserCode() |
| 426 | if err != nil { |
| 427 | return "", "", err |
| 428 | } |
| 429 | _, err = s.db.Exec(`INSERT INTO cli_auth_requests |
| 430 | (code_hash, user_code, label, remote_addr, state, created_at, expires_at) |
| 431 | VALUES (?, ?, ?, ?, ?, ?, ?)`, |
| 432 | hashToken(deviceCode), userCode, label, remoteAddr, CLIAuthPending, |
| 433 | now.Unix(), now.Add(CLIAuthTTL).Unix()) |
| 434 | if err == nil { |
| 435 | return userCode, deviceCode, nil |
| 436 | } |
| 437 | if !strings.Contains(err.Error(), "UNIQUE") { |
| 438 | return "", "", err |
| 439 | } |
| 440 | } |
| 441 | return "", "", fmt.Errorf("console: could not mint a free login code") |
| 442 | } |
| 443 | |
| 444 | // lastCLIPurge throttles the housekeeping sweep. Process-global rather than |
| 445 | // per-Store because a process runs one console; the only cost of a skipped |
| 446 | // sweep is rows living a little longer, since every read already checks |
| 447 | // expiry itself. Without the throttle a flood of login starts turns into a |
| 448 | // delete-per-request against a database with SetMaxOpenConns(1) — which is |
| 449 | // every console page's queue too. |
| 450 | var lastCLIPurge atomic.Int64 |
| 451 | |
| 452 | // cliPurgeEvery bounds how often the sweep runs. |
| 453 | const cliPurgeEvery = 60 * time.Second |
| 454 | |
| 455 | // PurgeCLIAuth drops what has aged out, at most once every cliPurgeEvery. |
| 456 | // Best-effort: losing a sweep must never fail the login it is housekeeping |
| 457 | // for. Exported so PurgeExpired — the console's general housekeeping — sweeps |
| 458 | // these tables too. |
| 459 | func (s *Store) PurgeCLIAuth(now time.Time) { |
| 460 | last := lastCLIPurge.Load() |
| 461 | if now.Unix()-last < int64(cliPurgeEvery/time.Second) { |
| 462 | return |
| 463 | } |
| 464 | if !lastCLIPurge.CompareAndSwap(last, now.Unix()) { |
| 465 | return // another request is doing it |
| 466 | } |
| 467 | s.db.Exec(`DELETE FROM cli_auth_requests WHERE expires_at < ?`, now.Unix()) |
| 468 | // expires_at = 0 means "never" (app tokens, see NewAppToken) and must be |
| 469 | // excluded from a sweep that compares against the clock — 0 is less than |
| 470 | // every now, so an unguarded sweep would delete exactly the credentials |
| 471 | // that are supposed to outlive it. |
| 472 | s.db.Exec(`DELETE FROM cli_tokens WHERE expires_at != 0 AND expires_at < ?`, now.Unix()) |
| 473 | } |
| 474 | |
| 475 | // CLIAuthByUserCode resolves a typed code to the request it names, for the |
| 476 | // approval page. Expired and already-decided requests are "no such code" — |
| 477 | // the page must never offer to approve something that cannot be approved. |
| 478 | func (s *Store) CLIAuthByUserCode(userCode string, now time.Time) (*CLIAuthRequest, error) { |
| 479 | var r CLIAuthRequest |
| 480 | var created, expires int64 |
| 481 | var state string |
| 482 | err := s.db.QueryRow(`SELECT user_code, label, remote_addr, state, created_at, expires_at |
| 483 | FROM cli_auth_requests WHERE user_code = ?`, NormalizeUserCode(userCode)). |
| 484 | Scan(&r.UserCode, &r.Label, &r.RemoteAddr, &state, &created, &expires) |
| 485 | if err == sql.ErrNoRows { |
| 486 | return nil, errors.New("console: no such login code") |
| 487 | } |
| 488 | if err != nil { |
| 489 | return nil, err |
| 490 | } |
| 491 | if state != CLIAuthPending || now.Unix() > expires { |
| 492 | return nil, errors.New("console: that login code has expired or was already used") |
| 493 | } |
| 494 | r.CreatedAt = time.Unix(created, 0).UTC() |
| 495 | r.ExpiresAt = time.Unix(expires, 0).UTC() |
| 496 | return &r, nil |
| 497 | } |
| 498 | |
| 499 | // ApproveCLIAuth records that address approved the request. The token is NOT |
| 500 | // minted here: it is minted when the CLI collects it (PollCLIAuth), so no |
| 501 | // bearer credential is ever at rest waiting to be picked up. |
| 502 | func (s *Store) ApproveCLIAuth(userCode, address string, now time.Time) error { |
| 503 | return s.decideCLIAuth(userCode, address, CLIAuthApproved, now) |
| 504 | } |
| 505 | |
| 506 | // DenyCLIAuth is the "that wasn't me" button. A denied request is final: |
| 507 | // approving it afterwards is refused, so a mis-click cannot be walked back |
| 508 | // into a credential by whoever is holding the terminal. |
| 509 | func (s *Store) DenyCLIAuth(userCode string, now time.Time) error { |
| 510 | return s.decideCLIAuth(userCode, "", CLIAuthDenied, now) |
| 511 | } |
| 512 | |
| 513 | func (s *Store) decideCLIAuth(userCode, address, state string, now time.Time) error { |
| 514 | res, err := s.db.Exec(`UPDATE cli_auth_requests SET state = ?, address = ? |
| 515 | WHERE user_code = ? AND state = ? AND expires_at >= ?`, |
| 516 | state, address, NormalizeUserCode(userCode), CLIAuthPending, now.Unix()) |
| 517 | if err != nil { |
| 518 | return err |
| 519 | } |
| 520 | n, err := res.RowsAffected() |
| 521 | if err != nil { |
| 522 | return err |
| 523 | } |
| 524 | if n == 0 { |
| 525 | return errors.New("console: that login code has expired or was already used") |
| 526 | } |
| 527 | return nil |
| 528 | } |
| 529 | |
| 530 | // PollCLIAuth is what `carlos auth login` calls in a loop. On approval it |
| 531 | // mints the token, records it, deletes the request, and returns the raw token |
| 532 | // exactly once — the caller never gets a second copy, so a stolen device code |
| 533 | // replayed later collects nothing. |
| 534 | // The approving address comes back with the token so the caller never has to |
| 535 | // re-read the row to learn it — reading it back through CLITokenAddress would |
| 536 | // stamp "last used" on a credential at the moment it was issued, and "never |
| 537 | // used since it was issued" is exactly the thing the tokens page exists to |
| 538 | // show. |
| 539 | func (s *Store) PollCLIAuth(deviceCode string, now time.Time) (status, rawToken, address string, err error) { |
| 540 | if deviceCode == "" { |
| 541 | return CLIAuthUnknown, "", "", nil |
| 542 | } |
| 543 | tx, err := s.db.Begin() |
| 544 | if err != nil { |
| 545 | return "", "", "", err |
| 546 | } |
| 547 | defer tx.Rollback() |
| 548 | hash := hashToken(deviceCode) |
| 549 | var storedHash, state, label, remote string |
| 550 | var expires int64 |
| 551 | err = tx.QueryRow(`SELECT code_hash, state, address, label, remote_addr, expires_at |
| 552 | FROM cli_auth_requests WHERE code_hash = ?`, hash). |
| 553 | Scan(&storedHash, &state, &address, &label, &remote, &expires) |
| 554 | if err == sql.ErrNoRows { |
| 555 | return CLIAuthUnknown, "", "", nil |
| 556 | } |
| 557 | if err != nil { |
| 558 | return "", "", "", err |
| 559 | } |
| 560 | // Belt and braces over the indexed lookup: the comparison that decides |
| 561 | // whether this caller holds the secret is constant time. |
| 562 | if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 { |
| 563 | return CLIAuthUnknown, "", "", nil |
| 564 | } |
| 565 | if now.Unix() > expires { |
| 566 | return CLIAuthExpired, "", "", nil |
| 567 | } |
| 568 | switch state { |
| 569 | case CLIAuthPending: |
| 570 | return CLIAuthPending, "", "", nil |
| 571 | case CLIAuthDenied: |
| 572 | return CLIAuthDenied, "", "", nil |
| 573 | case CLIAuthApproved: |
| 574 | default: |
| 575 | return CLIAuthUnknown, "", "", nil |
| 576 | } |
| 577 | raw, err := randomToken() |
| 578 | if err != nil { |
| 579 | return "", "", "", err |
| 580 | } |
| 581 | if _, err := tx.Exec(`INSERT INTO cli_tokens |
| 582 | (token_hash, address, label, remote_addr, created_at, expires_at) |
| 583 | VALUES (?, ?, ?, ?, ?, ?)`, |
| 584 | hashToken(raw), address, label, remote, now.Unix(), now.Add(CLITokenTTL).Unix()); err != nil { |
| 585 | return "", "", "", err |
| 586 | } |
| 587 | // Burn the request in the same transaction that mints the token: the two |
| 588 | // must never be separable, or a crash could hand out two tokens for one |
| 589 | // approval. |
| 590 | if _, err := tx.Exec(`DELETE FROM cli_auth_requests WHERE code_hash = ?`, hash); err != nil { |
| 591 | return "", "", "", err |
| 592 | } |
| 593 | if err := tx.Commit(); err != nil { |
| 594 | return "", "", "", err |
| 595 | } |
| 596 | return CLIAuthApproved, raw, address, nil |
| 597 | } |
| 598 | |
| 599 | // NewAppToken mints a credential an APP holds, bound to one account and one |
| 600 | // app rather than to a person. mintedBy is the member who minted it: it goes |
| 601 | // in `address`, which is what the audit trail, the owner's tokens listing and |
| 602 | // revocation all key on — the token speaks for an app, but a human stays |
| 603 | // answerable for it. |
| 604 | // |
| 605 | // The token never expires (`expires_at = 0`), and that is a deliberate trade |
| 606 | // against the 90-day user-token TTL, recorded in spec §8: a production signup |
| 607 | // path that silently dies on day 91 is a worse failure than a long-lived |
| 608 | // credential whose rotation is one `carlos env set` + bounce away. Revocation |
| 609 | // (the minting member's tokens page, where it lists under their address) is |
| 610 | // the control. |
| 611 | func (s *Store) claimRawNewAppToken(accountID int64, app, mintedBy, label string, now time.Time) (string, error) { |
| 612 | raw, err := randomToken() |
| 613 | if err != nil { |
| 614 | return "", err |
| 615 | } |
| 616 | credID, err := newCredentialID() |
| 617 | if err != nil { |
| 618 | return "", err |
| 619 | } |
| 620 | if _, err := s.db.Exec(`INSERT INTO cli_tokens |
| 621 | (token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app, credential_id) |
| 622 | VALUES (?, ?, ?, '', ?, 0, ?, ?, ?, ?)`, |
| 623 | hashToken(raw), mintedBy, label, now.Unix(), cliKindApp, accountID, app, credID); err != nil { |
| 624 | return "", err |
| 625 | } |
| 626 | return raw, nil |
| 627 | } |
| 628 | |
| 629 | // CLITokenAddress resolves a bearer token to the address that approved it; ok |
| 630 | // is false for unknown, revoked and expired tokens. This is the whole |
| 631 | // authorization decision for every CLI-facing endpoint. |
| 632 | func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) { |
| 633 | if rawToken == "" { |
| 634 | return "", false |
| 635 | } |
| 636 | hash := hashToken(rawToken) |
| 637 | var storedHash, addr, kind string |
| 638 | var expires, revoked int64 |
| 639 | err := s.db.QueryRow(`SELECT token_hash, address, kind, expires_at, revoked_at FROM cli_tokens WHERE token_hash = ?`, |
| 640 | hash).Scan(&storedHash, &addr, &kind, &expires, &revoked) |
| 641 | if err != nil { |
| 642 | return "", false |
| 643 | } |
| 644 | if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 { |
| 645 | return "", false |
| 646 | } |
| 647 | // An app or service token presented to a user door must look exactly |
| 648 | // like a token that does not exist. |
| 649 | if kind != cliKindUser { |
| 650 | return "", false |
| 651 | } |
| 652 | // A revoked credential must look exactly like a token that does not |
| 653 | // exist — a service principal killed through RevokeServicePrincipal |
| 654 | // leaves a row behind (soft revoke, see ServicePrincipals), and this door |
| 655 | // must not answer for it just because it once could. |
| 656 | if revoked != 0 { |
| 657 | return "", false |
| 658 | } |
| 659 | if now.Unix() > expires { |
| 660 | return "", false |
| 661 | } |
| 662 | // Best-effort: "last used" is for the owner's benefit on the tokens page, |
| 663 | // and a failed write must not fail the request it is annotating. |
| 664 | s.db.Exec(`UPDATE cli_tokens SET last_used = ? WHERE token_hash = ?`, now.Unix(), hash) |
| 665 | return addr, true |
| 666 | } |
| 667 | |
| 668 | // scanCLITokenRows drains a `cli_tokens` listing query into CLIToken values. |
| 669 | // CLITokens (by address) and ServicePrincipals (by account) select the same |
| 670 | // sixteen columns in the same order and need the same legacy-row |
| 671 | // normalization — a kind='app' row's `role` column is empty in the database but |
| 672 | // IS the instance role (spec §5). This is the one place that normalization |
| 673 | // happens, so neither caller, nor any reader downstream of them, has to |
| 674 | // re-derive it. |
| 675 | func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) { |
| 676 | defer rows.Close() |
| 677 | var out []CLIToken |
| 678 | for rows.Next() { |
| 679 | var t CLIToken |
| 680 | var created, expires, used, revoked, rotated int64 |
| 681 | if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &t.Kind, &t.AccountID, &t.App, |
| 682 | &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated, &t.Rungs, &t.Commands); err != nil { |
| 683 | return nil, err |
| 684 | } |
| 685 | t.CreatedAt = time.Unix(created, 0).UTC() |
| 686 | t.ExpiresAt = time.Unix(expires, 0).UTC() |
| 687 | if used != 0 { |
| 688 | t.LastUsed = time.Unix(used, 0).UTC() |
| 689 | } |
| 690 | if revoked != 0 { |
| 691 | t.RevokedAt = time.Unix(revoked, 0).UTC() |
| 692 | } |
| 693 | if rotated != 0 { |
| 694 | t.RotatedAt = time.Unix(rotated, 0).UTC() |
| 695 | } |
| 696 | if t.Kind == cliKindApp { |
| 697 | t.Role = string(svcrole.Instance) |
| 698 | } |
| 699 | out = append(out, t) |
| 700 | } |
| 701 | return out, rows.Err() |
| 702 | } |
| 703 | |
| 704 | // CLITokens lists one address's live CLI credentials, newest first — the |
| 705 | // listing the tokens page renders so an owner can see, and kill, every |
| 706 | // terminal that speaks as them. revoked_at = 0: a soft-revoked service |
| 707 | // principal minted under this address (see RevokeServicePrincipal) must not |
| 708 | // render here as an unbadged, still-live, still-revocable row — the comment |
| 709 | // above already promised "live", and until this filter the query did not |
| 710 | // keep that promise. |
| 711 | func (s *Store) CLITokens(address string) ([]CLIToken, error) { |
| 712 | rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app, |
| 713 | role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, commands |
| 714 | FROM cli_tokens WHERE address = ? AND revoked_at = 0 ORDER BY created_at DESC, token_hash`, address) |
| 715 | if err != nil { |
| 716 | return nil, err |
| 717 | } |
| 718 | return scanCLITokenRows(rows) |
| 719 | } |
| 720 | |
| 721 | // RevokeCLIToken kills one token by its listing id, scoped to its owner: an |
| 722 | // id is not a secret, so the address is what makes this safe. |
| 723 | func (s *Store) RevokeCLIToken(address, id string) error { |
| 724 | return s.RevokeCLITokenContext(context.Background(), address, id) |
| 725 | } |
| 726 | |
| 727 | // errNoSuchCLIToken is RevokeCLIToken's "nothing matched": the row is gone, or |
| 728 | // was never this address's. Every other error is a failure to act. |
| 729 | var errNoSuchCLIToken = errors.New("console: no such token") |
| 730 | |
| 731 | // RevokeCLITokenContext is RevokeCLIToken under a caller context. A person's |
| 732 | // terminal token (app ”) is deployment-free SQL and deletes directly. A |
| 733 | // legacy kind='app' (or service) row names an app incarnation, so its DELETE |
| 734 | // is admitted for that app first, exactly as NewAppToken's INSERT is; on a |
| 735 | // console without the host claim there is no gate and this is the same single |
| 736 | // DELETE as before. |
| 737 | func (s *Store) RevokeCLITokenContext(ctx context.Context, address, id string) error { |
| 738 | if s.gate == nil { |
| 739 | return s.claimRawRevokeCLIToken(address, id) |
| 740 | } |
| 741 | var accountID int64 |
| 742 | var app string |
| 743 | err := s.db.QueryRowContext(ctx, `SELECT account_id, app FROM cli_tokens WHERE token_hash = ? AND address = ?`, |
| 744 | id, address).Scan(&accountID, &app) |
| 745 | if errors.Is(err, sql.ErrNoRows) { |
| 746 | return errNoSuchCLIToken |
| 747 | } |
| 748 | if err != nil { |
| 749 | return err |
| 750 | } |
| 751 | return s.withAutoAppSQL(ctx, accountID, app, "sql.RevokeCLIToken", func(view *Store) error { |
| 752 | return view.claimRawRevokeCLIToken(address, id) |
| 753 | }) |
| 754 | } |
| 755 | |
| 756 | func (s *Store) claimRawRevokeCLIToken(address, id string) error { |
| 757 | res, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ? AND address = ?`, id, address) |
| 758 | if err != nil { |
| 759 | return err |
| 760 | } |
| 761 | n, err := res.RowsAffected() |
| 762 | if err != nil { |
| 763 | return err |
| 764 | } |
| 765 | if n == 0 { |
| 766 | return errNoSuchCLIToken |
| 767 | } |
| 768 | return nil |
| 769 | } |
| 770 | |
| 771 | // RevokeCLITokenValue kills a token by the token itself — what `carlos auth |
| 772 | // logout` sends, so signing out of a terminal actually retires the credential |
| 773 | // rather than just forgetting it locally. |
| 774 | func (s *Store) RevokeCLITokenValue(rawToken string) error { |
| 775 | return s.RevokeCLITokenValueContext(context.Background(), rawToken) |
| 776 | } |
| 777 | |
| 778 | // RevokeCLITokenValueContext is RevokeCLITokenValue under a caller context. The |
| 779 | // DELETE matches on the hash alone, so it removes a legacy kind='app' or a |
| 780 | // service row exactly as it removes a person's: the only route that calls it |
| 781 | // sits behind cliUser, which admits person tokens (app ”) alone, but the method |
| 782 | // does not rely on that. A row naming an app incarnation is admitted for that |
| 783 | // app first, as RevokeCLITokenContext and NewAppToken are; a token that matches |
| 784 | // nothing is not an error, as before. |
| 785 | func (s *Store) RevokeCLITokenValueContext(ctx context.Context, rawToken string) error { |
| 786 | hash := hashToken(rawToken) |
| 787 | if s.gate == nil { |
| 788 | return s.claimRawRevokeCLITokenValue(hash) |
| 789 | } |
| 790 | var accountID int64 |
| 791 | var app string |
| 792 | err := s.db.QueryRowContext(ctx, `SELECT account_id, app FROM cli_tokens WHERE token_hash = ?`, hash).Scan(&accountID, &app) |
| 793 | if errors.Is(err, sql.ErrNoRows) { |
| 794 | return nil |
| 795 | } |
| 796 | if err != nil { |
| 797 | return err |
| 798 | } |
| 799 | return s.withAutoAppSQL(ctx, accountID, app, "sql.RevokeCLITokenValue", func(view *Store) error { |
| 800 | return view.claimRawRevokeCLITokenValue(hash) |
| 801 | }) |
| 802 | } |
| 803 | |
| 804 | func (s *Store) claimRawRevokeCLITokenValue(hash string) error { |
| 805 | _, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ?`, hash) |
| 806 | return err |
| 807 | } |
| 808 | func (s *Store) NewAppToken(accountID int64, app, mintedBy, label string, now time.Time) (claimResult0 string, claimResult1 error) { |
| 809 | return s.NewAppTokenContext(context.Background(), accountID, app, mintedBy, label, now) |
| 810 | } |
| 811 | func (s *Store) NewAppTokenContext(claimContext context.Context, accountID int64, app, mintedBy, label string, now time.Time) (claimResult0 string, claimResult1 error) { |
| 812 | claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.NewAppToken", func(claimView *Store) error { |
| 813 | claimResult0, claimResult1 = claimView.claimRawNewAppToken(accountID, app, mintedBy, label, now) |
| 814 | return claimResult1 |
| 815 | }) |
| 816 | return |
| 817 | } |
| 818 | |