# Signed tokens

The `signing` package issues tokens that cannot be forged or altered. They expire
after an age you choose and survive secret-key rotation.

Use them when you need to know that your application created a value, and when:

- links in emails: confirm an address, accept an invitation, unsubscribe;
- values you hand to the browser and read back.

> [!WARNING]
> Tokens are **signed, not encrypted**. Anyone holding a token can decode its
> payload. Put identifiers in tokens, never passwords or other secrets.

## Ask your agent

> Add an email-confirmation link to my Cicadel project using the `signing`
> package: a purpose-specific signer, a typed JSON payload and a 48-hour expiry.
> Configure `CICADEL_SECRET_KEY` and make `check --deploy` pass.

## Configure the secret key

Generate a key and supply it through your deployment's secret configuration:

```sh
go tool cicadel secretkey       # prints a new 43-character random key
export CICADEL_SECRET_KEY=...   # never commit it
```

| Field | Environment variable | Purpose |
| --- | --- | --- |
| `SecretKey` | `CICADEL_SECRET_KEY` | Signs new tokens. At least 32 bytes. Empty disables signing. |
| `SecretKeyFallbacks` | `CICADEL_SECRET_KEY_FALLBACKS` | Comma-separated previous keys that still verify tokens. At most 8. |

- Settings validation rejects short keys, fallbacks without a current key and
  repeated keys. It never prints them.
- `go run manage.go check --deploy` warns (`settings:secret_key`) when no key is set.
- `apptest.Settings()` uses the fixed `apptest.TestSecretKey`, so tests can sign
  tokens without configuration.

## Sign and verify in a handler

Each kind of token has a **purpose**: a stable printable name such as
`"accounts.confirm-email"`. A token signed for one purpose never verifies for
another, even under the same secret key.

```go
type confirmation struct {
	UserID int64  `json:"user_id"`
	Email  string `json:"email"`
}

func sendConfirmation(w http.ResponseWriter, r *http.Request) {
	s, err := signing.For(r.Context(), "accounts.confirm-email")
	if err != nil {
		response.Error(w, 500) // signing.ErrNoKey: CICADEL_SECRET_KEY is missing
		return
	}
	token, err := signing.SignJSON(s, confirmation{UserID: user.ID, Email: user.Email})
	if err != nil {
		response.Error(w, 500)
		return
	}
	link := "https://example.com/confirm?token=" + token // URL-safe; no escaping needed
	// email the link
}

func confirm(w http.ResponseWriter, r *http.Request) {
	s, err := signing.For(r.Context(), "accounts.confirm-email")
	if err != nil {
		response.Error(w, 500)
		return
	}
	c, err := signing.VerifyJSON[confirmation](s, r.URL.Query().Get("token"), 48*time.Hour)
	switch {
	case errors.Is(err, signing.ErrExpired):
		// genuine but too old: offer to send a new link
	case err != nil:
		response.Error(w, 400) // forged, altered, wrong purpose or retired key
		return
	}
	// c.UserID and c.Email are exactly what you signed
}
```

The application puts its keyring on every request's context, so `signing.For`
works in any handler. Outside a request, such as a management command or a
background job, use `app.Signer(purpose)`. Both use the same keys, so a token
issued in a job verifies in a handler.

## API

| API | Use |
| --- | --- |
| `s.Sign(payload []byte)` / `s.Verify(token, maxAge)` | Raw bytes, at most `signing.MaxPayload` (8 KiB). |
| `signing.SignJSON(s, v)` / `signing.VerifyJSON[T](s, token, maxAge)` | Typed payloads. Unknown fields are rejected, so changing `T` invalidates older tokens. |
| `s.VerifyNoExpiry(token)` / `signing.VerifyJSONNoExpiry[T]` | Tokens that deliberately never expire, such as unsubscribe links. |
| `s.Issued(token)` | When a genuine token was signed. |
| `s.WithClock(now)` | A copy reading time from `now`, for tests. |

Verification rules:

- `Verify` requires a positive `maxAge`. Zero is a programming error, not "no expiry".
- `errors.Is(err, signing.ErrInvalid)` is true for every rejected token, including
  expired ones. Test `signing.ErrExpired` first to treat expiry differently.
- A token whose issue time is more than `signing.ClockSkew` (one minute) in the
  future is rejected.

### Single-use tokens

A signature proves who issued a token. It does not prove the token is unused.

To make a token single-use, include state that the action changes. Compare it on
verification. Good choices are a hash of the current email address or of the
password hash.

For example, a password-reset token that carries a hash of the stored password
hash stops working once the password changes. Never put the raw secret in the token.

## Rotate the secret key

1. Generate a new key. Deploy it as `CICADEL_SECRET_KEY` and move the old key to
   `CICADEL_SECRET_KEY_FALLBACKS`. New tokens use the new key. Existing tokens
   still verify.
2. Wait for the longest `maxAge` your application uses.
3. Remove the old key from `CICADEL_SECRET_KEY_FALLBACKS`. Tokens signed with it
   now fail with `signing.ErrInvalid`.

> [!WARNING]
> If a key leaks, skip the wait. Deploy a new key without the leaked one as a
> fallback. Every token signed with it, including `VerifyNoExpiry` tokens,
> becomes invalid immediately.

## How it works

The token format is `base64url(payload) "." base36(unix seconds) "." base64url(MAC)`.

- The MAC is HMAC-SHA256 over the first two parts.
- It uses a per-purpose key derived as HMAC-SHA256(secret key,
  `"cicadel-signing-v1\x00" + purpose`).
- Verification compares MACs in constant time against the current key and each
  fallback.
- Only the URL-safe base64 alphabet is accepted, so every token has exactly one
  encoding.
- The payload is decoded only after the MAC matches.
- Input longer than a maximum-size token is rejected before any work.

Tests that cover this:

- `signing/signing_test.go`: tampering at every position, purpose and key
  separation, expiry boundaries, clock skew, rotation, key validation, JSON
  strictness, concurrency and the `FuzzVerify` fuzz target.
- `signing_test.go`: request-context signing, rotation across deployments and
  settings validation through a running application.
- `internal/scaffold/signing_test.go`: a generated project loading keys from the
  environment.
