carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
21package console
22
23import (
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
39const (
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.
72const (
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.
85type 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.
102type 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".
137func (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).
142func (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.
153func (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.
162const 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.
166func (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.
177func (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.
184func (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.
188func (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.
203func (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.
212func (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
283ON 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
305ON cli_tokens (account_id, name)
306WHERE 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.
320func (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.
353func 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).
365const (
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.
373func 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.
394func 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.
416func (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)
431VALUES (?, ?, ?, ?, ?, ?, ?)`,
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.
450var lastCLIPurge atomic.Int64
451
452// cliPurgeEvery bounds how often the sweep runs.
453const 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.
459func (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.
478func (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
483FROM 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.
502func (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.
509func (s *Store) DenyCLIAuth(userCode string, now time.Time) error {
510 return s.decideCLIAuth(userCode, "", CLIAuthDenied, now)
511}
512
513func (s *Store) decideCLIAuth(userCode, address, state string, now time.Time) error {
514 res, err := s.db.Exec(`UPDATE cli_auth_requests SET state = ?, address = ?
515WHERE 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.
539func (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
552FROM 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)
583VALUES (?, ?, ?, ?, ?, ?)`,
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.
611func (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)
622VALUES (?, ?, ?, '', ?, 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.
632func (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.
675func 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.
711func (s *Store) CLITokens(address string) ([]CLIToken, error) {
712 rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app,
713role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, commands
714FROM 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.
723func (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.
729var 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.
737func (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
756func (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.
774func (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.
785func (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
804func (s *Store) claimRawRevokeCLITokenValue(hash string) error {
805 _, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ?`, hash)
806 return err
807}
808func (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}
811func (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