> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Dashboard Auth

> Dashboard SPA authentication — HMAC key management and session handling

# Dashboard Write Auth

The dashboard requires HMAC auth on **both** tiers, using the same `api_client_keys`
infrastructure that guards `/v3/*`. Reads accept any role; writes require `admin` or
`operator`.

## Overview

* **Auth model**: per-operator, per-request HMAC-SHA-256 (same canonical-string format as `/v3/*`
  admin HMAC). Dashboard writer keys carry **`role='operator'`** in the 3-role RBAC model; `admin`
  keys are also accepted so operators do not need two keys. Agent keys are rejected.
* **Isolation**: separation is by **channel + route role**, not a unique purpose. The dashboard
  surface is exposed at `/dashboard/*` (the global `HMACAuthMiddleware` only guards
  `settings.api_prefix`, i.e. `/v3/*`), and each write route mounts `require_dashboard_writer_hmac`,
  which enforces `role ∈ {admin, operator}`. `dashboard_writer` is now only a **log label** (kept
  for forensic-line clarity), not an access-control scope.
* **Key store**: `api_client_keys` table (existing). A key is issued per operator workstation via
  the bootstrap CLI (it sets `purpose='dashboard_writer'` for the log label and `role='operator'`).
* **No secrets in bundle**: the Vite build carries zero auth secrets. The operator pastes their
  HMAC secret into the browser's "Unlock writes" modal at session start; it is imported as a
  non-extractable `CryptoKey` and lives only in memory.

## Protected Routes

All dashboard routes require valid `X-QTG-*` HMAC headers — the router attaches the reader
dependency to every route. Read (`GET`) routes accept `admin`, `operator`, or `agent`; the
write routes below require `admin` or `operator`.

| Method  | Path                                                                     | Note                                 |
| ------- | ------------------------------------------------------------------------ | ------------------------------------ |
| `POST`  | `/dashboard/movements/{id}/approve`                                      |                                      |
| `POST`  | `/dashboard/movements/{id}/reject`                                       |                                      |
| `POST`  | `/dashboard/movements/{id}/resume`                                       |                                      |
| `POST`  | `/dashboard/movements/{id}/retry`                                        |                                      |
| `POST`  | `/dashboard/movements/{id}/cancel`                                       |                                      |
| `POST`  | `/dashboard/authorities`                                                 |                                      |
| `PATCH` | `/dashboard/authorities/{authority_key}`                                 |                                      |
| `POST`  | `/dashboard/authorities/{authority_key}/proposals/{proposal_id}/promote` |                                      |
| `POST`  | `/dashboard/authorities/{authority_key}/proposals/{proposal_id}/reject`  |                                      |
| `POST`  | `/dashboard/capital-transfers`                                           |                                      |
| `POST`  | `/dashboard/routes`                                                      |                                      |
| `POST`  | `/dashboard/allowed-addresses`                                           | Address allowlist — fund-safety gate |
| `POST`  | `/dashboard/allowed-addresses/{address_id}/revoke`                       |                                      |
| `POST`  | `/dashboard/allowed-addresses/{address_id}/reactivate`                   |                                      |
| `POST`  | `/dashboard/auto-approve-policies`                                       | Pro — same auth dependency           |
| `PATCH` | `/dashboard/auto-approve-policies/{id}`                                  | Pro — same auth dependency           |

## Header Specification

Every dashboard write request must carry all four headers:

| Header            | Format                 | Example         |
| ----------------- | ---------------------- | --------------- |
| `X-QTG-Key-Id`    | string                 | `dw_alice_desk` |
| `X-QTG-Timestamp` | Unix seconds (string)  | `1747200000`    |
| `X-QTG-Nonce`     | UUID v4 string         | `a1b2c3d4-...`  |
| `X-QTG-Signature` | lower-hex HMAC-SHA-256 | `3a4f...`       |

Missing or malformed headers → `401`. Valid headers but key role ∉ {admin, operator} → `403`.
Replayed nonce (same `key_id` + `nonce` seen before) → `401`.

## Canonical String Format

The signature is `HMAC-SHA-256(secret, canonical_string)` where:

```
canonical_string =
  METHOD\n
  /path/to/resource\n
  query_string\n
  sha256_hex(body_bytes)\n
  timestamp\n
  nonce
```

* `METHOD`: uppercase HTTP verb (`POST`, `PATCH`, …).
* Path: the **routed** path — what the server dispatches on, percent-decoded, no origin
  and no query string. Do not re-derive it by reassembling a URL: that round trip strips
  CR/LF/TAB and honours `?`/`#`, so distinct targets collapse onto one string (JEP-589).
* `query_string`: the query **exactly as sent**, with no leading `?` and **no sorting**.
  Empty string if no query.

  > **Changed 2026-08-16 (JEP-644).** This was previously sorted by key and then by
  > value. Sorting erased repeated-key order — `?status=a&status=b` and
  > `?status=b&status=a` produced one signature while the handler read different
  > values — so a client must now transmit the exact bytes it signed rather than an
  > equivalent reordering of them. A signer that still sorts will send `b=2&a=1` while
  > having signed `a=1&b=2`, and every such request 401s.
* `sha256_hex(body_bytes)`: lowercase hex of SHA-256 over the raw UTF-8 request body bytes.
  For empty body, this is the SHA-256 of the empty string.
* `timestamp`: the value of `X-QTG-Timestamp`.
* `nonce`: the value of `X-QTG-Nonce`.
* Lines joined with `\n` (LF only, no trailing newline).

**Reference implementation**: `canonical_string` in
`src/qtg/interfaces/api/middleware/auth/hmac.py`, and the frontend mirror in
`frontend/src/api/dashboardWriterAuth.ts` (`signRequest`).

Cross-language test vectors: `tests/fixtures/canonical_string_vectors.json`.

## Key Issuance

Issue one key per operator workstation using the bootstrap CLI:

```bash theme={null}
MG_CLI_BOOTSTRAP_ALLOWED=true \
uv run python -m qtg.interfaces.tools.bootstrap_dashboard_writer_key \
  --key-id "dw_alice_desk" \
  --description "Alice's primary workstation"
```

Source: `src/qtg/interfaces/tools/bootstrap_dashboard_writer_key.py`.

Output is JSON containing `key_id` and `hmac_secret`. Store both in a password manager. The
secret is stored on the server as plaintext in the `api_client_keys.hmac_secret` column —
anyone with read access to the application DB can recover it. Protect your application DB at
rest (filesystem encryption, restricted file permissions, encrypted backups). Treat suspected
DB-read access as a key-compromise event and revoke affected keys via the SQL UPDATE in the
"Key Rotation and Revocation" section below.

Default quota: `MG_DASHBOARD_WRITER_EXPECTED_KEY_COUNT=8` active keys. Raise for larger teams.

## Key Rotation and Revocation

There is no TTL. To revoke (lost device, suspected compromise, operator offboarding):

```sql theme={null}
UPDATE api_client_keys SET status = 'revoked' WHERE key_id = 'dw_alice_desk';
```

Revocation is immediate — the next write request with that key returns 401. To issue a
replacement, run the bootstrap CLI with a new `--key-id`.

## Browser Unlock UX

1. The dashboard nav bar shows **🔒 Unlock writes** when session is locked.
2. Click to open the **Unlock Writes** modal.
3. Enter `key_id` and `hmac_secret`. 1Password / Bitwarden autofill works if you saved a Login
   item for the dashboard origin URL; `autocomplete="username"` and `autocomplete="current-password"`
   attributes are set on the respective inputs.
4. Optionally check **"Remember Key ID on this device"** — stores only the `key_id` string in
   `localStorage`; the secret is **never** persisted anywhere.
5. Click **Unlock**. The secret is imported as a non-extractable `CryptoKey`
   (`extractable=false`) and lives in memory only for the browser session.
6. The nav indicator changes to **🔓 Unlocked as dw\_alice\_desk**.

Clicking the nav indicator while unlocked locks the session (clears the in-memory `CryptoKey`).
The `key_id` hint remains in `localStorage` if "Remember" was checked, prefilling the next unlock
modal.

If a write request is attempted while locked, a `DashboardWritesLockedError` is thrown and the
unlock modal opens automatically.

## Forensic Surfaces

**1. Auth-log line (universal — all dashboard write routes today).**
On every successful HMAC verification, `require_dashboard_writer_hmac` emits one `INFO`-level
log line:

```
dashboard_writer_auth_ok request_id=<uuid> key_id=<str> client_id=<uuid> source_ip=<str> user_agent="<str>" method=<str> path=<str>
```

The `path` field includes the concrete resource ID (e.g., `/dashboard/movements/abc-123/approve`),
enabling direct grep by movement/transfer/authority ID without a timestamp join. Forensic
correlation across a log aggregator and the HTTP access log proceeds via `request_id`, which is
also echoed in the `X-Request-ID` response header.

**2. `RegistryAuditEvent` DB row.**
The allowlist write routes (`/dashboard/allowed-addresses*`) and the authority routes write a
`RegistryAuditEvent` row carrying the actor's `client_id` / `key_id`. Its `source_ip`,
`user_agent`, and `request_id` columns are nullable and still NULL on those rows — none of the
constructors populates them, so the auth-log line below is where the request envelope lives.

## Known Limitations

**1. JS signing-oracle XSS surface.**
The HMAC secret passes through the JS heap during `unlockSession()` before being imported as a
`CryptoKey`. Active XSS during that import window can read the raw bytes. After import,
`crypto.subtle.exportKey()` is rejected (`extractable=false`), blocking direct exfiltration
to a remote attacker — but **active same-origin XSS can still use the live `CryptoKey` as a
signing oracle**, generating valid write requests for as long as the page is open. This is
materially better than the prior bundle-baked Bearer token (which any dashboard reader could grep
and replay anywhere, anytime), but it is not a substitute for proper XSS hygiene and is not
equivalent to server-side session auth.

**2. Workstation loss.**
If the operator checked "Remember Key ID", the `key_id` hint persists in `localStorage`. The
secret does not persist anywhere. A lost device requires CLI revocation
(`uv run python -m qtg.interfaces.tools.revoke_auth_key --key-id <id>`) to invalidate the key.

**3. Read auth can be disabled, but only outside production.**
`MG_DASHBOARD_READ_AUTH_ENABLED=false` is honored only when `MG_ENVIRONMENT` is one of
`local` / `development` / `dev` / `test` / `ci`. Any other value — including a typo — keeps
read auth enforced. Public exposure of the dashboard origin is still not supported by this
design. See `docs/runbooks/operator-network-access.md` for network boundary guidance.

**4. No rate limiting on dashboard write routes today.**
The global `RateLimiter` covers `/v3/*` only. Dashboard write routes have no rate limiting.
Defense-in-depth via reverse proxy (e.g., Nginx `limit_req`, Tailscale ACL) is the available
mitigation until a future spec extends rate limiting to `/dashboard/*`.

**5. Log retention dependency for all dashboard write forensics.**
Because the `RegistryAuditEvent` rows written by dashboard routes carry no request envelope
(no `source_ip` / `user_agent` / `request_id`), the auth-log line is the universal forensic
record linking a write to a workstation identity. Default Docker `json-file`
log driver rotation can discard logs before an incident window opens. Recommended minimum:
`max-size=100m`, `max-file=10`, or forward to a log aggregator (Loki, CloudWatch, etc.) with
at least 30-day retention. Operators who omit this lose workstation forensics for every
dashboard write route.

**6. Server-side session/SSO is the durable XSS-resistant end state.**
The HMAC model above (known limitation #1) is the correct design for the assumed deployment
surface (small operator team behind a LAN/VPC/Tailscale boundary). If the threat model changes —
dashboard exposed beyond trusted operator workstations, or a client-side XSS surface is
introduced — the migration is mechanical: replace `require_dashboard_writer_hmac` with a
session middleware and HTTP-only cookie reader. The `role='operator'` gate and forensic
columns (`source_ip`, `user_agent`, `request_id`) transfer cleanly.
Dashboard auth v2 — server-side session/SSO plus a body-size guard — is tracked as separate work.

**7. Plaintext HMAC secrets on disk.**
`api_client_keys.hmac_secret` is stored as plaintext text in the application DB. This matches
the existing `/v3/*` admin HMAC key infrastructure. DB-read access (e.g., a leaked backup) is
equivalent to dashboard-write capability. Encrypt your application DB at rest and limit
backup access. Encrypted-at-rest secret storage is out of scope for this release — a future
spec may add `hmac_secret_ciphertext` with KMS decryption in `_resolve_key`.

**8. No body-size guard before signature verification.**
`verify_hmac_request` reads the full request body into memory via `await request.body()`
before computing the canonical string and checking the signature. An unauthenticated actor
with network access (LAN / VPC / Tailscale) can submit oversized invalid writes to consume
memory/CPU. Mitigation: deploy behind a reverse proxy (nginx, Caddy, Tailscale serve) with
a `client_max_body_size` cap, OR rely on uvicorn's defaults. A future spec may add a
Content-Length-aware middleware that rejects oversized requests with 413 before reaching
the HMAC layer.

## Migration from Bearer (v1)

The legacy `MG_DASHBOARD_WRITE_TOKEN` bearer-token model is **removed**. The server now
**hard-fails at startup** if `MG_DASHBOARD_WRITE_TOKEN` is present in the environment — even when
set to an empty value (`MG_DASHBOARD_WRITE_TOKEN=`). Remove it entirely and issue per-operator
HMAC keys instead.

See `docs/runbooks/dashboard-auth-v1-to-hmac.md` for the step-by-step migration guide.
