diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d755a2..418a41f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,15 @@ The first version, to be released as 0.1.0. the web UI. Networks smaller than a /24 explain that they belong in the enclosing /24's zone. - docs/operations.md: setting up a secondary server with its own jarld. +- Token rotation: `jarl token rotate`, the Rotate button in the web UI and + `POST /api/v1/tokens/{id}/rotate` give a token a new secret while it + keeps its ID, name, user and grants. The old secret stops at once or after + a grace period of up to 7 days (`--grace`), during which jarld logs who + still uses it. A token that expires gets its lifetime again. `--save` + writes the new secret where the CLI got the old one (its configuration or + `--token @FILE`), `--write FILE` to any file, and `--current` picks the + token the CLI runs with, so a restricted token can renew itself. + `jarl token list` shows when a token was last rotated. - An endpoint for lego's `httpreq` DNS provider (`/api/v1/acme/httpreq/present` and `/cleanup`), so lego and Traefik can publish DNS-01 challenges with only environment variables, without the @@ -183,6 +192,11 @@ The first version, to be released as 0.1.0. ### Fixed +- `jarl token create --save` stored the token at the top level of the + CLI configuration even when a named server was selected; it now goes into + that server's entry. +- The web UI showed future times, such as token expiry dates, as "just + now"; they now read "in 29 days". - The dashboard's "Pending changes (1 open)" counted empty changesets (and other users'), so the Changes page then showed none. It now counts your own changesets that contain changes, and the status API's diff --git a/README.md b/README.md index f9a79eb..4d9be6c 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,10 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A) diff, and apply them together. Every applied changeset can be reverted. Serials are bumped automatically. * **Users and API tokens with granular permissions**, e.g. a token that may - only write `_acme-challenge` TXT records in one zone. Passwords use - argon2id, sessions have CSRF protection, logins are rate limited. + only write `_acme-challenge` TXT records in one zone. Tokens can be + rotated (a new secret, same grants), with an optional grace period. + Passwords use argon2id, sessions have CSRF protection, logins are rate + limited. * **Audit log** of every login, change and token, with user, token, IP and outcome. * **Query statistics** (optional, off by default): queries over time, diff --git a/cmd/jarl/admin_cmds.go b/cmd/jarl/admin_cmds.go index e9923fe..c19a114 100644 --- a/cmd/jarl/admin_cmds.go +++ b/cmd/jarl/admin_cmds.go @@ -536,13 +536,17 @@ func tokenCmd() *cobra.Command { return err } return output(tokens, func(w io.Writer) { - fmt.Fprintln(w, "ID\tUSER\tNAME\tPREFIX\tGRANTS\tEXPIRES\tLAST USED\tREVOKED") + fmt.Fprintln(w, "ID\tUSER\tNAME\tPREFIX\tGRANTS\tEXPIRES\tROTATED\tLAST USED\tREVOKED") for _, t := range tokens { g := "(user's)" if t.Grants != nil { g = strings.Join(t.GrantStrings, "; ") } - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, t.PublicID, g, fmtTime(t.ExpiresAt), fmtTime(t.LastUsedAt), fmtTime(t.RevokedAt)) + rot := fmtTime(t.RotatedAt) + if t.PrevExpiresAt != nil && time.Now().Before(*t.PrevExpiresAt) { + rot += " (old secret until " + fmtTime(t.PrevExpiresAt) + ")" + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, t.PublicID, g, fmtTime(t.ExpiresAt), rot, fmtTime(t.LastUsedAt), fmtTime(t.RevokedAt)) } }) }, @@ -581,13 +585,9 @@ func tokenCmd() *cobra.Command { if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil { return err } + where := flagConfig if save { - cfg, err := loadCLIConfig() - if err != nil { - return err - } - cfg.Token = t.Secret - if err := saveCLIConfig(cfg); err != nil { + if where, err = saveTokenInConfig(t.Secret); err != nil { return err } } @@ -603,7 +603,7 @@ func tokenCmd() *cobra.Command { } fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt)) if save { - fmt.Fprintf(w, "Saved to:\t%s\n", flagConfig) + fmt.Fprintf(w, "Saved to:\t%s\n", where) } else { fmt.Fprintln(w, "\nThe token is shown only once; store it now.") } @@ -636,7 +636,7 @@ func tokenCmd() *cobra.Command { return nil }, } - cmd.AddCommand(list, create, revoke) + cmd.AddCommand(list, create, tokenRotateCmd(), revoke) return cmd } diff --git a/cmd/jarl/token_rotate.go b/cmd/jarl/token_rotate.go new file mode 100644 index 0000000..3b939a3 --- /dev/null +++ b/cmd/jarl/token_rotate.go @@ -0,0 +1,268 @@ +package main + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "jarl/internal/api" + "jarl/internal/client" +) + +// tokenRotateCmd is 'jarl token rotate'. +func tokenRotateCmd() *cobra.Command { + var user, grace, expires, write string + var current, noExpiry, save bool + cmd := &cobra.Command{ + Use: "rotate [ID|NAME]", + Short: "Give a token a new secret, keeping its name, grants and ID", + Long: `Gives a token a new secret. The token keeps its ID, name, user and grants, +so nothing has to be set up again: only the programs that use it need the +new secret, which is printed once (or written with --write/--save). + +The old secret stops working at once. With --grace it keeps working for a +while (at most 7d), so a client can be switched over without an outage; +jarld logs every use of the old secret, so you can see what still uses it. +To shut out a leaked token, rotate without --grace (or revoke it). + +A token that expires gets its lifetime again, counted from now (a 90-day +token is good for another 90 days). --expires sets a new lifetime and +--no-expiry removes the expiry. + +The token is given by ID, by name (your own tokens, or --user's), or with +--current, the token this command runs with. A restricted token may rotate +itself, which lets automation renew its own token.`, + Example: ` jarl token rotate 12 + jarl token rotate laptop --save # and update the CLI's configuration + jarl token rotate --current --save --grace 10m + jarl token rotate deploy --user ci --grace 1h -q + jarl --token @/etc/jarl/certbot.token token rotate --current --write /etc/jarl/certbot.token`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if current == (len(args) == 1) { + return fmt.Errorf("give a token ID or name, or --current") + } + // Check that --save can work before the old secret is gone. + var origin tokenOrigin + if save { + var err error + if origin, err = findTokenOrigin(); err != nil { + return err + } + } + c, err := newClient() + if err != nil { + return err + } + var id int64 + if current { + var who api.Whoami + if err := c.Get(ctx(), "/auth/whoami", nil, &who); err != nil { + return err + } + if who.Token == nil { + return fmt.Errorf("--current: this command isn't using a token") + } + id = who.Token.ID + } else if id, err = tokenID(c, args[0], user); err != nil { + return err + } + in := api.TokenRotation{Grace: grace, ExpiresIn: expires, NoExpiry: noExpiry} + var t api.Token + if err := c.Do(ctx(), "POST", fmt.Sprintf("/tokens/%d/rotate", id), nil, in, &t); err != nil { + return err + } + var saved []string + if write != "" { + if err := writeTokenFile(write, t.Secret); err != nil { + return fmt.Errorf("the token was rotated, but writing %s failed: %w\nnew token: %s", write, err, t.Secret) + } + saved = append(saved, write) + } + if save { + where, err := origin.store(t.Secret) + if err != nil { + return fmt.Errorf("the token was rotated, but saving it failed: %w\nnew token: %s", err, t.Secret) + } + saved = append(saved, where) + } + if flagQuiet { + if len(saved) == 0 { + fmt.Println(t.Secret) + } + return nil + } + return output(t, func(w io.Writer) { + if len(saved) == 0 { + fmt.Fprintf(w, "Token:\t%s\n", t.Secret) + } + fmt.Fprintf(w, "Name:\t%s (id %d, user %s)\n", t.Name, t.ID, t.Username) + if t.Grants != nil { + fmt.Fprintf(w, "Grants:\t%s\n", strings.Join(t.GrantStrings, "; ")) + } + fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt)) + if t.PrevExpiresAt != nil { + fmt.Fprintf(w, "Old secret:\tworks until %s\n", fmtTime(t.PrevExpiresAt)) + } else { + fmt.Fprintf(w, "Old secret:\tno longer works\n") + } + if len(saved) > 0 { + fmt.Fprintf(w, "Saved to:\t%s\n", strings.Join(saved, ", ")) + } else { + fmt.Fprintln(w, "\nThe new token is shown only once; store it now.") + if current && t.PrevExpiresAt == nil { + fmt.Fprintln(w, "This was the token this command ran with, so the CLI needs the new one (next time: --save).") + } + } + }) + }, + } + f := cmd.Flags() + f.BoolVar(¤t, "current", false, "rotate the token this command runs with") + f.StringVar(&user, "user", "", "look the NAME up among this user's tokens (admin)") + f.StringVar(&grace, "grace", "", "keep the old secret working this long, e.g. 10m, 24h or 2d (at most 7d)") + f.StringVar(&expires, "expires", "", "new lifetime from now, e.g. 90d (default: the token's current lifetime again)") + f.BoolVar(&noExpiry, "no-expiry", false, "the token no longer expires") + f.StringVar(&write, "write", "", "write the new token to this file (mode 0600 if new) instead of printing it") + f.BoolVar(&save, "save", false, "store the new token where the CLI gets its token: its configuration file, or the @FILE it reads") + return cmd +} + +// tokenID finds a token by ID or by name. +func tokenID(c *client.Client, arg, user string) (int64, error) { + if id, err := strconv.ParseInt(arg, 10, 64); err == nil { + return id, nil + } + path := "/me/tokens" + if user != "" { + uid, err := userID(c, user) + if err != nil { + return 0, err + } + path = fmt.Sprintf("/users/%d/tokens", uid) + } + var tokens []api.Token + if err := c.Get(ctx(), path, nil, &tokens); err != nil { + return 0, err + } + var ids []string + var found int64 + for _, t := range tokens { + if t.Name == arg && t.RevokedAt == nil { + found = t.ID + ids = append(ids, strconv.FormatInt(t.ID, 10)) + } + } + switch len(ids) { + case 0: + if user == "" { + return 0, fmt.Errorf("you have no token named %q (another user's: --user)", arg) + } + return 0, fmt.Errorf("user %s has no token named %q", user, arg) + case 1: + return found, nil + } + return 0, fmt.Errorf("several tokens are named %q (IDs %s); give the ID", arg, strings.Join(ids, ", ")) +} + +// tokenOrigin is where the CLI's token comes from, for --save. +type tokenOrigin struct { + file string // the token is read from this file (@FILE) + server string // else it's in the configuration file: this server's entry... + top bool // ...or the top-level token +} + +// findTokenOrigin follows the same rules as newClient. +func findTokenOrigin() (tokenOrigin, error) { + cfg, err := loadCLIConfig() + if err != nil { + return tokenOrigin{}, err + } + ts, err := resolveTargets(cfg, targetSpec(cfg)) + if err != nil { + return tokenOrigin{}, err + } + if len(ts) != 1 { + return tokenOrigin{}, fmt.Errorf("--save needs a single server") + } + t := ts[0] + var o tokenOrigin + var token string + switch { + case t.Name != "" && t.Token != "": + token, o.server = t.Token, t.Name + case flagToken != "": + token = flagToken + case os.Getenv("JARL_TOKEN") != "": + token = os.Getenv("JARL_TOKEN") + case t.Name != "": + token, o.server = t.Token, t.Name + default: + token, o.top = cfg.Token, true + } + if f, ok := strings.CutPrefix(token, "@"); ok { + return tokenOrigin{file: f}, nil + } + if !o.top && o.server == "" { + return tokenOrigin{}, fmt.Errorf("--save: the token comes from --token or JARL_TOKEN, so there's nowhere to save it; use --write FILE (or put the token in a file and pass --token @FILE)") + } + return o, nil +} + +// store saves a new token where the old one came from, and says where. +func (o tokenOrigin) store(secret string) (string, error) { + if o.file != "" { + return o.file, writeTokenFile(o.file, secret) + } + cfg, err := loadCLIConfig() + if err != nil { + return "", err + } + if o.server != "" { + e := cfg.Servers[o.server] + if e == nil { + return "", fmt.Errorf("server %s is no longer in %s", o.server, flagConfig) + } + e.Token = secret + } else { + cfg.Token = secret + } + if err := saveCLIConfig(cfg); err != nil { + return "", err + } + if o.server != "" { + return flagConfig + " (server " + o.server + ")", nil + } + return flagConfig, nil +} + +// saveTokenInConfig stores a token in the CLI configuration file, for the +// selected server (its named entry, or the top-level token). +func saveTokenInConfig(secret string) (string, error) { + cfg, err := loadCLIConfig() + if err != nil { + return "", err + } + ts, err := resolveTargets(cfg, targetSpec(cfg)) + if err != nil { + return "", err + } + if len(ts) == 1 && ts[0].Name != "" { + if e := cfg.Servers[ts[0].Name]; e != nil { + e.Token = secret + return flagConfig + " (server " + ts[0].Name + ")", saveCLIConfig(cfg) + } + } + cfg.Token = secret + return flagConfig, saveCLIConfig(cfg) +} + +// writeTokenFile replaces a token file's content. An existing file keeps +// its owner and mode; a new one is created with mode 0600. +func writeTokenFile(path, secret string) error { + return os.WriteFile(path, []byte(secret+"\n"), 0o600) +} diff --git a/docs/api.md b/docs/api.md index 5340cf9..b71aebe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -315,6 +315,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`. | GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once | | GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens | | DELETE | `/tokens/{id}` | revoke | +| POST | `/tokens/{id}/rotate` | New secret, same token. Optional body `{"grace": "1h", "expires_in": "90d", "expires_at": "...", "no_expiry": false}`. The response's `token` field is the new secret, shown once; `previous_secret_valid_until` says when the old one stops. A restricted token may rotate itself and tokens no wider than itself | | POST | `/me/password` | `{"current_password": "...", "password": "..."}` (sessions only) | User and token changes take effect immediately. They are not changesets, diff --git a/docs/cli.md b/docs/cli.md index cce4400..7397265 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -272,6 +272,8 @@ jarl token list [--user alice | --all] [--revoked] jarl token create laptop --expires 365d jarl token create backup-script --grant "*:read" jarl token create acme --user certbot --role acme +jarl token rotate laptop --save # new secret, same token and grants +jarl token rotate --current --save --grace 10m # the token this CLI uses jarl token revoke 7 jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100] @@ -281,7 +283,7 @@ jarl check # validation + named-checkconf of the current configuration jarl resync [--force] [--dry-run] # rewrite every file from the database ``` -Grant syntax and roles are in [permissions.md](permissions.md). +Grant syntax, roles and token rotation are in [permissions.md](permissions.md). ## Query statistics diff --git a/docs/permissions.md b/docs/permissions.md index 9fa5e0b..83648ee 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -113,6 +113,54 @@ jarl token revoke 12 In the web UI: Account → API tokens (your own) and Users → *user* → Tokens (admin). +### Rotating a token + +Rotating gives a token a **new secret** and changes nothing else: it keeps +its ID, name, user, grants and audit history, so there is nothing to set +up again. Only the programs that use the token need the new secret. Use it +on a schedule, when someone who knew the token leaves, or when it may have +leaked. Revoking is for a token that is no longer needed. + +```sh +jarl token rotate 12 # by ID (see 'jarl token list') +jarl token rotate laptop --save # by name, and update the CLI's configuration +jarl token rotate deploy --user ci --grace 1h # another user's token (admin), old secret works 1 more hour +jarl token rotate --current --save # the token this CLI runs with +``` + +* **The old secret** stops working at once. With `--grace` (at most 7d) + it keeps working for that long, so clients can be switched over without + an outage. While it does, jarld logs a warning each time the old secret + is used (at most once a minute per token and address), with the client's + IP, so you can find what still needs updating: + `journalctl -u jarl | grep "old secret"`. `jarl token list` and the web + UI show when the old secret stops. Rotating again ends the previous grace + period. If the token may have leaked, rotate without `--grace`. +* **Expiry**: a token that expires gets its lifetime again, counted from + now, so a 90-day token is good for another 90 days. `--expires 30d` sets + a new lifetime and `--no-expiry` removes the expiry. A token without an + expiry keeps having none. +* **Where the new secret goes**: it is printed once, like a new token. + `--write FILE` writes it to a file instead (an existing file keeps its + owner and mode; a new one gets mode 0600). `--save` puts it where the CLI + got its token from: the configuration file (the selected server's entry) + or the file named in `--token @FILE`. `--save` refuses before rotating + when the token came from a literal `--token` or `JARL_TOKEN`. +* **Who may rotate**: administrators any token; users their own tokens. A + restricted token may rotate itself and tokens no wider than itself, but + not a wider token of its user, since it would learn that token's secret. + So automation can renew its own token, for example a monthly timer: + + ```sh + jarl --token @/etc/jarl/certbot.token token rotate --current --save + ``` +* Every rotation is in the audit log (`token.rotate`), with when the old + secret stops and the new expiry. + +In the web UI, the **Rotate** button next to each token asks how long the +old secret should keep working and how long the token should last, then +shows the new secret once. + ## Recovering access On the server, as root or the `jarl` user: diff --git a/internal/api/types.go b/internal/api/types.go index 47d0372..5d993ff 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -191,6 +191,20 @@ type TokenInput struct { ExpiresAt *time.Time `json:"expires_at,omitempty"` } +// TokenRotation gives a token a new secret (POST /tokens/{id}/rotate). +// Every field is optional. +type TokenRotation struct { + // Grace keeps the old secret working this long (e.g. "1h", "2d"; at + // most 7d), so clients can be updated without an outage. Default: + // the old secret stops at once. + Grace string `json:"grace,omitempty"` + // The new expiry. Default: a token that expires gets its lifetime + // again, counted from now; one that doesn't, still doesn't. + ExpiresIn string `json:"expires_in,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + NoExpiry bool `json:"no_expiry,omitempty"` +} + // ZoneImport imports zone file text into a zone. type ZoneImport struct { Text string `json:"text"` diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b41e048..cd65900 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -105,6 +105,13 @@ func NewToken() (token, publicID, secretHash string) { return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret) } +// RotateToken makes a new token with an existing public ID (a rotated +// token keeps its ID; only the secret changes). +func RotateToken(publicID string) (token, secretHash string) { + secret := randomString(20) + return TokenPrefix + publicID + "_" + secret, HashSecret(secret) +} + // ParseToken splits a token into its public ID and secret. func ParseToken(token string) (publicID, secret string, err error) { rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix) diff --git a/internal/model/changeset.go b/internal/model/changeset.go index 77694b3..c0bd86b 100644 --- a/internal/model/changeset.go +++ b/internal/model/changeset.go @@ -89,6 +89,20 @@ type Token struct { LastUsedAt *time.Time `json:"last_used_at,omitempty"` LastUsedIP string `json:"last_used_ip,omitempty"` RevokedAt *time.Time `json:"revoked_at,omitempty"` + // RotatedAt is when the token last got a new secret (nil: never). + RotatedAt *time.Time `json:"rotated_at,omitempty"` + // After a rotation with a grace period, the previous secret keeps + // working until PrevExpiresAt. + PrevSecretHash string `json:"-"` + PrevExpiresAt *time.Time `json:"previous_secret_valid_until,omitempty"` +} + +// IssuedAt is when the token's current secret was made. +func (t *Token) IssuedAt() time.Time { + if t.RotatedAt != nil { + return *t.RotatedAt + } + return t.CreatedAt } // Session is a logged-in web session. diff --git a/internal/server/authn.go b/internal/server/authn.go index 96ae573..2298fbd 100644 --- a/internal/server/authn.go +++ b/internal/server/authn.go @@ -4,6 +4,7 @@ import ( "context" "crypto/subtle" "errors" + "fmt" "net/http" "strings" "sync" @@ -78,6 +79,31 @@ func (l *rateLimiter) cleanup() { } } +// throttle allows an event once a minute per key (for log lines that +// could otherwise repeat on every request). +type throttle struct { + mu sync.Mutex + last map[string]time.Time +} + +func (t *throttle) allow(key string, now time.Time) bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.last == nil { + t.last = map[string]time.Time{} + } + if now.Sub(t.last[key]) < time.Minute { + return false + } + for k, v := range t.last { + if now.Sub(v) > time.Hour { + delete(t.last, k) + } + } + t.last[key] = now + return true +} + var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer jarl_...'"} // authenticate identifies the caller from a bearer token or a session @@ -143,10 +169,19 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au if err != nil { return nil, err } - if !auth.CheckSecret(secret, t.SecretHash) { - return nil, errors.New("wrong token secret") - } now := time.Now() + previous := false + if !auth.CheckSecret(secret, t.SecretHash) { + // After a rotation with a grace period the old secret works until + // the grace period ends. + if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) { + return nil, errors.New("wrong token secret") + } + if now.After(*t.PrevExpiresAt) { + return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended") + } + previous = true + } if t.RevokedAt != nil { return nil, errors.New("token was revoked") } @@ -163,6 +198,9 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip { s.store.TouchToken(ctx, t.ID, ip) } + if previous && s.oldSecretWarn.allow(fmt.Sprintf("%d %s", t.ID, ip), now) { + s.log.Warn("token used with its old secret; update this client before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339)) + } return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil } diff --git a/internal/server/h_auth.go b/internal/server/h_auth.go index a804391..34a915e 100644 --- a/internal/server/h_auth.go +++ b/internal/server/h_auth.go @@ -1,7 +1,10 @@ package server import ( + "bytes" + "encoding/json" "errors" + "fmt" "net/http" "strconv" "strings" @@ -106,25 +109,28 @@ func parseExpiry(in api.TokenInput) (*time.Time, error) { if in.ExpiresIn == "" { return nil, nil } - v := strings.TrimSpace(in.ExpiresIn) - var d time.Duration - if n, ok := strings.CutSuffix(v, "d"); ok { - days, err := strconv.Atoi(n) - if err != nil || days <= 0 { - return nil, badRequest("invalid expires_in %q", v) - } - d = time.Duration(days) * 24 * time.Hour - } else { - var err error - d, err = time.ParseDuration(v) - if err != nil || d <= 0 { - return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", v) - } + d, err := parseDays(in.ExpiresIn) + if err != nil || d <= 0 { + return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn) } t := time.Now().Add(d) return &t, nil } +// parseDays parses a duration in Go syntax ("720h", "15m") or in days +// ("90d"). +func parseDays(v string) (time.Duration, error) { + v = strings.TrimSpace(v) + if n, ok := strings.CutSuffix(v, "d"); ok { + days, err := strconv.Atoi(n) + if err != nil || days < 0 { + return 0, fmt.Errorf("invalid duration %q", v) + } + return time.Duration(days) * 24 * time.Hour, nil + } + return time.ParseDuration(v) +} + func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error { var in api.TokenInput if err := decodeJSON(r, &in); err != nil { @@ -243,6 +249,109 @@ func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Pri return nil } +// maxRotationGrace is the longest an old secret may keep working. +const maxRotationGrace = 7 * 24 * time.Hour + +// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. The +// token keeps its ID, name, user and grants, so nothing needs to be set up +// again; only the clients that use it need the new secret. The old secret +// stops working at once, or after the grace period. A token that expires +// gets the same lifetime again, counted from now, unless expires_in or +// expires_at says otherwise. +func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + var in api.TokenRotation + body, err := readBody(r) + if err != nil { + return err + } + if body != nil { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(&in); err != nil { + return badRequest("invalid JSON: %v", err) + } + } + t, err := s.store.GetToken(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return notFound("token %d not found", id) + } + return err + } + if t.UserID != p.User.ID && !p.IsAdmin() { + return notFound("token %d not found", id) + } + // A restricted token may rotate itself, and tokens no wider than + // itself, but not a wider token of the same user: it would learn that + // token's new secret. + if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !narrower(t.Grants, p.Token.Grants)) { + return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants") + } + if t.RevokedAt != nil { + return conflict("token %d is revoked; create a new one", id) + } + now := time.Now() + var exp *time.Time + switch { + case in.ExpiresIn != "" || in.ExpiresAt != nil: + if in.NoExpiry { + return badRequest("no_expiry can't be combined with expires_in or expires_at") + } + if exp, err = parseExpiry(api.TokenInput{ExpiresIn: in.ExpiresIn, ExpiresAt: in.ExpiresAt}); err != nil { + return err + } + case in.NoExpiry: + case t.ExpiresAt != nil: + life := t.ExpiresAt.Sub(t.IssuedAt()) + if life <= 0 { + return badRequest("can't tell the token's lifetime; give expires_in") + } + e := now.Add(life) + exp = &e + } + var prevUntil *time.Time + if in.Grace != "" { + g, err := parseDays(in.Grace) + if err != nil || g < 0 || g > maxRotationGrace { + return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace) + } + until := now.Add(g) + if t.ExpiresAt != nil && t.ExpiresAt.Before(until) { + until = *t.ExpiresAt // the old secret never outlives its token + } + if g > 0 && until.After(now) { + prevUntil = &until + } + } + secret, hash := auth.RotateToken(t.PublicID) + if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil { + if errors.Is(err, store.ErrNotFound) { + return conflict("token %d is revoked; create a new one", id) + } + return err + } + t, err = s.store.GetToken(r.Context(), id) + if err != nil { + return err + } + detail := "old secret stops working now" + if prevUntil != nil { + detail = "old secret works until " + prevUntil.UTC().Format(time.RFC3339) + } + if t.ExpiresAt != nil { + detail += "; expires " + t.ExpiresAt.UTC().Format(time.RFC3339) + } + s.eng.Audit(r.Context(), p, "token.rotate", "token", t.ID, t.Username+"/"+t.Name, "success", detail) + out := presentToken(t) + out.Secret = secret + writeJSON(w, http.StatusOK, out) + return nil +} + func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := requireAdmin(p); err != nil { return err diff --git a/internal/server/routes.go b/internal/server/routes.go index c5955a2..4c61495 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -26,6 +26,7 @@ func (s *Server) routes() { m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens)) m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken)) m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken)) + m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken)) m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens)) // Users (admin) m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers)) diff --git a/internal/server/server.go b/internal/server/server.go index 974e669..a1cdf08 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -53,6 +53,8 @@ type Server struct { web fs.FS certs *certReloader statsQ *stats.Querier + // oldSecretWarn limits warnings about rotated tokens' old secrets. + oldSecretWarn throttle mu sync.Mutex srvs []*http.Server diff --git a/internal/server/tokens_test.go b/internal/server/tokens_test.go new file mode 100644 index 0000000..7907792 --- /dev/null +++ b/internal/server/tokens_test.go @@ -0,0 +1,98 @@ +package server + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestTokenRotation(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/users", e.token, map[string]any{"username": "ci", "password": "", "grant_strings": []string{"records:write zones=example.com"}}, 201) + var ciID int64 + for _, u := range e.list("/users", e.token) { + if u["username"] == "ci" { + ciID = int64(u["id"].(float64)) + } + } + created := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "deploy", "grant_strings": []string{"records:write zones=example.com types=TXT"}, "expires_in": "30d"}, 201) + id := itoa(int64(created["id"].(float64))) + old := created["token"].(string) + works := func(tok string) bool { + _, status := e.req("GET", "/auth/whoami", tok, nil) + return status == 200 + } + + // Rotation keeps the token and its grants; the old secret stops. + rot := e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200) + tok1 := rot["token"].(string) + if tok1 == old || rot["id"] != created["id"] || rot["name"] != "deploy" || rot["public_id"] != created["public_id"] || rot["rotated_at"] == nil { + t.Errorf("rotated = %v", rot) + } + if g := rot["grant_strings"].([]any); len(g) != 1 || g[0] != "records:write zones=example.com types=TXT" { + t.Errorf("grants after rotation = %v", g) + } + if !works(tok1) || works(old) { + t.Errorf("after rotation: new works %v, old works %v", works(tok1), works(old)) + } + // The 30-day lifetime starts again. + exp, _ := time.Parse(time.RFC3339Nano, rot["expires_at"].(string)) + if d := time.Until(exp); d < 29*24*time.Hour || d > 31*24*time.Hour { + t.Errorf("expires in %v, want about 30 days", d) + } + + // With a grace period both secrets work for a while. + rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200) + tok2 := rot["token"].(string) + if !works(tok2) || !works(tok1) || rot["previous_secret_valid_until"] == nil { + t.Errorf("grace: new %v, old %v, %v", works(tok2), works(tok1), rot) + } + // When the grace period ends, the old secret stops. + e.st.DB().ExecContext(context.Background(), `UPDATE tokens SET prev_expires_at = ? WHERE id = ?`, time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano), id) + if b, status := e.req("GET", "/auth/whoami", tok1, nil); status != 401 || !strings.Contains(string(b), "grace period has ended") { + t.Errorf("old secret after grace: %d %s", status, b) + } + // Rotating again without grace drops the previous secret at once. + e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200) + rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200) + tok3 := rot["token"].(string) + if works(tok2) || !works(tok3) || rot["previous_secret_valid_until"] != nil { + t.Errorf("rotation without grace kept an old secret: %v", rot) + } + + // A restricted token may rotate itself... + self := e.do("POST", "/tokens/"+id+"/rotate", tok3, map[string]any{"expires_in": "2d"}, 200) + tok4 := self["token"].(string) + if works(tok3) || !works(tok4) { + t.Error("self-rotation didn't replace the secret") + } + // ...but not a wider token of its user, whose secret it would learn. + wide := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "wide"}, 201) + wideID := itoa(int64(wide["id"].(float64))) + if b, status := e.req("POST", "/tokens/"+wideID+"/rotate", tok4, nil); status != 403 { + t.Errorf("restricted token rotated a wider one: %d %s", status, b) + } + // The unrestricted token can rotate the narrower one. + e.do("POST", "/tokens/"+id+"/rotate", wide["token"].(string), map[string]any{"no_expiry": true}, 200) + // Nobody else's tokens. + if _, status := e.req("POST", "/tokens/1/rotate", wide["token"].(string), nil); status != 404 { + t.Errorf("rotated the admin's token: %d", status) + } + for _, c := range []struct { + body map[string]any + code int + }{ + {map[string]any{"grace": "8d"}, 400}, + {map[string]any{"grace": "soon"}, 400}, + {map[string]any{"expires_in": "1d", "no_expiry": true}, 400}, + } { + if b, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, c.body); status != c.code { + t.Errorf("%v: %d %s", c.body, status, b) + } + } + e.do("DELETE", "/tokens/"+id, e.token, nil, 204) + if _, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, nil); status != 409 { + t.Errorf("rotated a revoked token: %d", status) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index b3fac7a..a6b0c90 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -213,6 +213,13 @@ CREATE TABLE deployed_files ( written_at TEXT NOT NULL, changeset_id INTEGER NOT NULL DEFAULT 0 ); +`, + // 2: token rotation. A rotated token gets a new secret; the previous + // one may stay valid for a grace period. + ` +ALTER TABLE tokens ADD COLUMN rotated_at TEXT; +ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT; `, } diff --git a/internal/store/users.go b/internal/store/users.go index f9a8738..b3c73c8 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -139,13 +139,13 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error { return nil } -const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at` +const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at, t.rotated_at, t.prev_secret_hash, t.prev_expires_at` func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) { t := &model.Token{} - var grants, exp, lu, rev sql.NullString + var grants, exp, lu, rev, rot, pexp sql.NullString var ca string - if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil { + if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev, &rot, &t.PrevSecretHash, &pexp); err != nil { return nil, err } if grants.Valid { @@ -157,6 +157,7 @@ func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) { } } t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev) + t.RotatedAt, t.PrevExpiresAt = parseTSPtr(rot), parseTSPtr(pexp) t.CreatedAt = parseTS(ca) return t, nil } @@ -235,6 +236,25 @@ func (s *Store) RevokeToken(ctx context.Context, id int64) error { return nil } +// RotateToken gives a token a new secret and expiry. With prevUntil set, +// the old secret keeps working until then; otherwise it stops at once. A +// revoked token can't be rotated (ErrNotFound). +func (s *Store) RotateToken(ctx context.Context, id int64, secretHash string, expiresAt, prevUntil *time.Time) error { + prevHash := `''` + if prevUntil != nil { + prevHash = `secret_hash` + } + res, err := s.db.ExecContext(ctx, `UPDATE tokens SET prev_secret_hash = `+prevHash+`, prev_expires_at = ?, secret_hash = ?, expires_at = ?, rotated_at = ? WHERE id = ? AND revoked_at IS NULL`, + tsPtr(prevUntil), secretHash, tsPtr(expiresAt), ts(time.Now()), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + // TouchToken records a token's use. It is best-effort and cheap. func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error { _, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id) diff --git a/internal/web/static/assets/pages/users.js b/internal/web/static/assets/pages/users.js index 3cd18c9..6374945 100644 --- a/internal/web/static/assets/pages/users.js +++ b/internal/web/static/assets/pages/users.js @@ -1,4 +1,4 @@ -import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, copyBox, badge } from "../ui.js"; +import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, copyBox, badge } from "../ui.js"; import { api, get, state } from "../api.js"; import { rerender } from "../app.js"; @@ -101,20 +101,69 @@ export async function tokensDialog(u, grants, own) { export function tokenTable(tokens, redraw) { return table([ - { label: "Name", render: (t) => h("strong", null, t.name) }, + { label: "Name", render: (t) => [h("strong", null, t.name), rotationNote(t)] }, { label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" }, { label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" }, { label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") }, { label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") }, - { label: "", render: (t) => h("button", { class: "small danger", onclick: async () => { - if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return; - await api("DELETE", "/tokens/" + t.id); - toast("Token revoked"); - redraw(); - } }, "Revoke"), tdClass: "actions-cell" }, + { label: "", render: (t) => [ + h("button", { class: "small", onclick: () => rotateDialog(t, redraw) }, "Rotate"), " ", + h("button", { class: "small danger", onclick: async () => { + if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return; + await api("DELETE", "/tokens/" + t.id); + toast("Token revoked"); + redraw(); + } }, "Revoke")], tdClass: "actions-cell nowrap" }, ], tokens, { empty: "No tokens." }); } +// rotationNote says when a token last got a new secret, and whether its +// old secret still works. +function rotationNote(t) { + const parts = []; + if (t.previous_secret_valid_until && new Date(t.previous_secret_valid_until) > new Date()) { + parts.push(h("div", { class: "small warn-text" }, "old secret stops ", time(t.previous_secret_valid_until))); + } else if (t.rotated_at) { + parts.push(h("div", { class: "muted small" }, "rotated ", time(t.rotated_at))); + } + return parts; +} + +// rotateDialog gives a token a new secret. The token keeps its name, +// grants and ID; only the programs using it need the new secret. +async function rotateDialog(t, redraw) { + const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], ""); + const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]]; + if (t.expires_at) expOpts.push(["never", "never expires"]); + const exp = select(expOpts, ""); + const err = h("div"); + const box = h("div"); + const form = h("div", null, + h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."), + h("div", { class: "form-grid" }, + field("Old secret", grace, "A grace period lets you update clients without an outage. If the token may have leaked, let it stop now."), + field("Expires", exp)), + err, + h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async (e) => { + mount(err); + const body = {}; + if (grace.value) body.grace = grace.value; + if (exp.value === "never") body.no_expiry = true; + else if (exp.value) body.expires_in = exp.value; + e.target.disabled = true; + try { + const r = await api("POST", "/tokens/" + t.id + "/rotate", { body }); + mount(box, h("div", { class: "msg ok" }, + h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."), + copyBox(r.token), + h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), " (", time(r.previous_secret_valid_until), ")."] : "The old secret no longer works."))); + if (redraw) redraw(); + } catch (ex) { e.target.disabled = false; mount(err, errorBox(ex)); } + } }, "Rotate"))); + mount(box, form); + await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true }); +} + export function newTokenForm(base, grants, redraw) { const err = h("div"); const out = h("div"); diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index c0088d4..120b7aa 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -78,6 +78,7 @@ h3 { font-size: 14px; margin: 14px 0 6px; } .muted { color: var(--muted); } .small { font-size: 12px; } .nowrap { white-space: nowrap; } +.warn-text { color: var(--warn); } .right { text-align: right; } .hidden { display: none !important; } diff --git a/internal/web/static/assets/ui.js b/internal/web/static/assets/ui.js index 7cb2d5d..5d852e7 100644 --- a/internal/web/static/assets/ui.js +++ b/internal/web/static/assets/ui.js @@ -49,12 +49,15 @@ export function fmtTime(iso) { export function relTime(iso) { if (!iso) return ""; - const s = (Date.now() - new Date(iso).getTime()) / 1000; + const diff = (Date.now() - new Date(iso).getTime()) / 1000; + const s = Math.abs(diff); if (s < 60) return "just now"; - if (s < 3600) return Math.floor(s / 60) + " min ago"; - if (s < 86400) return Math.floor(s / 3600) + " h ago"; - if (s < 86400 * 30) return Math.floor(s / 86400) + " days ago"; - return fmtTime(iso); + let n; + if (s < 3600) n = Math.floor(s / 60) + " min"; + else if (s < 86400) n = Math.floor(s / 3600) + " h"; + else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day"); + else return fmtTime(iso); + return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry) } export function time(iso) { diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 064865f..4dd0379 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -241,6 +241,17 @@ run "acme unset when nothing there" 0 $J $ACME acme unset adbraun.test run "acme in unknown zone" 1 $J $ACME acme set example.org v run "token list --all" 0 $J token list --all contains "certbot-hook" +cp $E/acme.token $E/acme.token.old +run "acme token rotates itself (grace)" 0 $J $ACME token rotate --current --save --grace 1h +run "rotated token works" 0 $J $ACME whoami +run "old secret works during grace" 0 $J --token @$E/acme.token.old whoami +run "rotate again without grace" 0 $J $ACME token rotate --current --save +run "old secret refused after rotation (exit 3)" 3 $J --token @$E/acme.token.old whoami +run "rotated token keeps its grants" 0 $J $ACME acme set adbraun.test rotated +run "acme unset after rotation" 0 $J $ACME acme unset adbraun.test rotated +run "acme token cannot rotate the admin's token (exit 4)" 4 $J $ACME token rotate 1 +run "token list shows rotation" 0 $J token list --user certbot +contains "certbot-hook" TID=$($J token list --user certbot -o json | grep -o '"id": [0-9]*' | head -1 | grep -o '[0-9]*') run "token revoke" 0 $J token revoke $TID run "revoked token refused (exit 3)" 3 $J $ACME whoami