Fix F-02: replace password-in-hidden-field with nonce

- ui/ui.go: add pendingLogin struct and pendingLogins sync.Map
  to UIServer; add issueTOTPNonce (generates 128-bit random nonce,
  stores accountID with 90s TTL) and consumeTOTPNonce (single-use,
  expiry-checked LoadAndDelete); add dummyHash() method
- ui/handlers_auth.go: split handleLoginPost into step 1
  (password verify → issue nonce) and step 2 (handleTOTPStep,
  consume nonce → validate TOTP) via a new finishLogin helper;
  password never transmitted or stored after step 1
- ui/ui_test.go: refactor newTestMux to reuse new
  newTestUIServer; add TestTOTPNonceIssuedAndConsumed,
  TestTOTPNonceUnknownRejected, TestTOTPNonceExpired, and
  TestLoginPostPasswordNotInTOTPForm; 11/11 tests pass
- web/templates/fragments/totp_step.html: replace
  'name=password' hidden field with 'name=totp_nonce'
- db/accounts.go: add GetAccountByID for TOTP step lookup
- AUDIT.md: mark F-02 as fixed
Security: the plaintext password previously survived two HTTP
  round-trips and lived in the browser DOM during the TOTP step.
  The nonce approach means the password is verified once and
  immediately discarded; only an opaque random token tied to an
  account ID (never a credential) crosses the wire on step 2.
  Nonces are single-use and expire after 90 seconds to limit
  the window if one is captured.
This commit is contained in:
2026-03-11 20:33:04 -07:00
parent bf9002a31c
commit d42f51fc83
10 changed files with 1877 additions and 54 deletions

571
INTEGRATION.md Normal file
View File

@@ -0,0 +1,571 @@
# MCIAS Integration Guide
MCIAS is an HTTP+TLS authentication service. It issues Ed25519-signed JWTs and
provides account, role, and credential management. This guide covers everything
needed to integrate a new system with MCIAS as its authentication backend.
---
## Contents
1. [Concepts](#concepts)
2. [Quick Start — Human Login](#quick-start--human-login)
3. [Quick Start — System Account](#quick-start--system-account)
4. [Verifying a Token in Your Service](#verifying-a-token-in-your-service)
5. [TOTP (Two-Factor Authentication)](#totp-two-factor-authentication)
6. [Setting Up a System Account](#setting-up-a-system-account)
7. [Role-Based Authorization](#role-based-authorization)
8. [Client Libraries](#client-libraries)
9. [Token Lifecycle](#token-lifecycle)
10. [Error Reference](#error-reference)
11. [API Reference](#api-reference)
12. [Security Notes for Integrators](#security-notes-for-integrators)
---
## Concepts
**Human account** — A person who logs in with username + password (+ optional
TOTP). The server returns a JWT valid for 30 days (8 hours for admins). The
token is bearer-only; there are no cookies or server-side sessions.
**System account** — A non-interactive identity for a service, daemon, or
automated process. It has no password. An admin issues a long-lived bearer token
(1 year) that the service stores and sends with every request.
**JWT** — All tokens are signed with Ed25519 (algorithm `EdDSA`). They contain
`sub` (account UUID), `roles`, `iss`, `iat`, `exp`, and `jti`. Tokens can be
validated offline using the public key, or online via `/v1/token/validate`.
**Role** — An arbitrary string scoped to your deployment (e.g. `admin`,
`editor`, `readonly`). MCIAS enforces `admin` internally; your services enforce
any other roles by inspecting the `roles` claim.
---
## Quick Start — Human Login
```sh
# Login
RESPONSE=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \
--cacert /etc/mcias/server.crt \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"s3cr3t"}')
TOKEN=$(echo "$RESPONSE" | jq -r .token)
EXPIRES=$(echo "$RESPONSE" | jq -r .expires_at)
# Use the token
curl -sS https://my-app.example.com/api/resource \
-H "Authorization: Bearer $TOKEN"
# Renew before expiry (extends the window, invalidates the old token)
NEW_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/renew \
--cacert /etc/mcias/server.crt \
-H "Authorization: Bearer $TOKEN" | jq -r .token)
# Logout (revokes the token immediately)
curl -sS -X POST https://auth.example.com:8443/v1/auth/logout \
--cacert /etc/mcias/server.crt \
-H "Authorization: Bearer $TOKEN"
```
### Login with TOTP
If the user has enrolled TOTP, include the current 6-digit code. Omitting it
returns HTTP 401 with code `totp_required`.
```sh
curl -sS -X POST https://auth.example.com:8443/v1/auth/login \
--cacert /etc/mcias/server.crt \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"s3cr3t","totp_code":"123456"}'
```
---
## Quick Start — System Account
A system account authenticates non-interactively. An admin creates it, issues a
token, and the service includes that token in every request.
```sh
# 1. Admin: create the system account
TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \
--cacert /etc/mcias/server.crt \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"..."}' | jq -r .token)
ACCOUNT=$(curl -sS -X POST https://auth.example.com:8443/v1/accounts \
--cacert /etc/mcias/server.crt \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"username":"my-service","account_type":"system"}')
SVC_UUID=$(echo "$ACCOUNT" | jq -r .id)
# 2. Admin: issue a bearer token for the service (valid 1 year)
SVC_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/token/issue \
--cacert /etc/mcias/server.crt \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"account_id\":\"$SVC_UUID\"}" | jq -r .token)
# 3. Store SVC_TOKEN in the service's secret store (env var, Vault, etc.)
# 4. Service: use it in every outbound or MCIAS API call
curl -sS https://protected-api.example.com/v1/resource \
-H "Authorization: Bearer $SVC_TOKEN"
```
To rotate the token (e.g. after exposure), reissue it. The old token is
automatically revoked.
```sh
curl -sS -X POST https://auth.example.com:8443/v1/token/issue \
--cacert /etc/mcias/server.crt \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"account_id\":\"$SVC_UUID\"}"
```
---
## Verifying a Token in Your Service
Your service receives a Bearer token from a client. It must verify the token
before trusting any claims. There are two approaches:
### Option A: Offline verification (recommended)
Fetch the public key once at startup and cache it. Verify the JWT locally
without a round-trip to MCIAS.
```
GET /v1/keys/public
```
Response (JWK, RFC 8037):
```json
{
"kty": "OKP",
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"x": "<base64url-encoded public key>"
}
```
Verification steps your code must perform (in order):
1. **Check `alg` header** — must be exactly `"EdDSA"`. Reject `"none"` and all
other values before touching the signature.
2. **Verify the Ed25519 signature** using the public key.
3. **Validate claims:** `exp` > now, `iat` <= now, `iss` matches your configured
issuer.
4. **Check for revocation** — if your service needs real-time revocation, call
`/v1/token/validate` instead (Option B). Offline verification will accept a
token until it expires, even if it was explicitly revoked.
```python
# Python example using PyJWT
import jwt # pip install PyJWT[crypto]
PUBLIC_KEY = load_ed25519_jwk(mcias_client.get_public_key())
def verify_token(bearer: str) -> dict:
token = bearer.removeprefix("Bearer ").strip()
# Step 1 is performed by PyJWT when algorithms=["EdDSA"] is specified.
payload = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["EdDSA"],
issuer="https://auth.example.com",
options={"require": ["exp", "iat", "iss", "sub", "jti"]},
)
return payload # {"sub": "...", "roles": [...], "exp": ..., "jti": "..."}
```
```go
// Go example using golang-jwt/jwt/v5
import jwtlib "github.com/golang-jwt/jwt/v5"
func verifyToken(tokenStr string, pubKey ed25519.PublicKey, issuer string) (*Claims, error) {
parsed, err := jwtlib.ParseWithClaims(tokenStr, &Claims{},
func(t *jwtlib.Token) (any, error) {
// Step 1: algorithm must be EdDSA
if _, ok := t.Method.(*jwtlib.SigningMethodEd25519); !ok {
return nil, fmt.Errorf("unexpected alg: %v", t.Header["alg"])
}
return pubKey, nil
},
jwtlib.WithIssuedAt(),
jwtlib.WithIssuer(issuer),
)
if err != nil {
return nil, err
}
return parsed.Claims.(*Claims), nil
}
```
### Option B: Online validation
Call MCIAS for every request. This reflects revocations immediately but adds
latency. Suitable for high-security paths.
```
POST /v1/token/validate
Authorization: Bearer <token>
```
or equivalently with a JSON body:
```json
{ "token": "<JWT>" }
```
Response:
```json
{
"valid": true,
"sub": "550e8400-e29b-41d4-a716-446655440000",
"roles": ["editor"],
"expires_at": "2026-04-10T12:34:56Z"
}
```
On failure: `{ "valid": false }` (HTTP 200, never 401). Do not branch on the
HTTP status code; always inspect `valid`.
---
## TOTP (Two-Factor Authentication)
TOTP enrollment is a two-step process and must be completed in one session.
Abandoning after step 1 does **not** lock the account.
### Step 1: Enroll
```
POST /v1/auth/totp/enroll
Authorization: Bearer <token>
```
Response:
```json
{
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"
}
```
Show the `otpauth_uri` as a QR code (e.g. using a TOTP app like Authenticator).
The secret is shown **once**; it is not retrievable after this call.
### Step 2: Confirm
```
POST /v1/auth/totp/confirm
Authorization: Bearer <token>
Content-Type: application/json
{ "code": "123456" }
```
HTTP 204 on success. After this call, all future logins for this account require
a TOTP code.
### Admin: Remove TOTP
```
DELETE /v1/auth/totp
Authorization: Bearer <admin-token>
Content-Type: application/json
{ "account_id": "<UUID>" }
```
Use this for account recovery when a user loses their TOTP device.
---
## Setting Up a System Account
Full walkthrough using `mciasctl`:
```sh
export MCIAS_TOKEN=$(curl -sS -X POST https://auth.example.com:8443/v1/auth/login \
--cacert /etc/mcias/server.crt \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"..."}' | jq -r .token)
# 1. Create the account
mciasctl -server https://auth.example.com:8443 \
account create -username my-service -type system
# 2. Note the UUID printed, then assign roles
mciasctl role set -id <UUID> -roles readonly
# 3. Issue the service token
mciasctl token issue -id <UUID>
# Prints: token=eyJ... expires_at=2027-03-11T...
# 4. Store the token securely (e.g. in a Kubernetes secret, Vault, .env)
echo "MCIAS_SVC_TOKEN=eyJ..." >> /etc/my-service/env
# 5. Rotate (when token is compromised or at scheduled intervals)
mciasctl token issue -id <UUID>
# New token issued; old token automatically revoked
```
---
## Role-Based Authorization
Roles are arbitrary strings. MCIAS enforces only `admin` internally. Your
services enforce any other roles by inspecting the `roles` JWT claim.
### Assigning roles
```sh
# Grant roles (replaces existing)
mciasctl role set -id <UUID> -roles editor,readonly
# Or via the API
curl -sS -X PUT https://auth.example.com:8443/v1/accounts/<UUID>/roles \
--cacert /etc/mcias/server.crt \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"roles":["editor","readonly"]}'
```
### Checking roles in your service
Roles are in the `roles` claim of the decoded JWT:
```json
{
"sub": "550e8400-...",
"roles": ["editor", "readonly"],
"exp": 1775000000,
"iss": "https://auth.example.com"
}
```
```python
def require_role(payload: dict, role: str) -> None:
if role not in payload.get("roles", []):
raise PermissionError(f"role '{role}' required")
```
```go
func hasRole(claims *Claims, role string) bool {
for _, r := range claims.Roles {
if r == role {
return true
}
}
return false
}
```
---
## Client Libraries
Four language implementations are available in the `clients/` directory. All
expose the same API surface:
| Language | Location | Install |
|----------|----------|---------|
| Go | `clients/go/` | `go get git.wntrmute.dev/kyle/mcias/clients/go` |
| Python | `clients/python/` | `pip install ./clients/python` |
| Rust | `clients/rust/` | `cargo add mcias-client` |
| Common Lisp | `clients/lisp/` | ASDF `mcias-client` |
### Go
```go
import mcias "git.wntrmute.dev/kyle/mcias/clients/go"
c, err := mcias.New("https://auth.example.com:8443", "/etc/mcias/server.crt", "")
if err != nil { ... }
token, expiresAt, err := c.Login("alice", "s3cr3t", "")
if err != nil { ... }
result, err := c.ValidateToken(token)
if err != nil || !result.Valid { ... }
fmt.Println(result.Sub, result.Roles)
```
### Python
```python
from mcias_client import Client, MciasAuthError
c = Client("https://auth.example.com:8443", ca_cert="/etc/mcias/server.crt")
token, expires_at = c.login("alice", "s3cr3t")
try:
result = c.validate_token(token)
if not result.valid:
raise MciasAuthError("token invalid")
print(result.sub, result.roles)
except MciasAuthError as e:
print("auth error:", e)
```
### Error types (all languages)
| Name | HTTP | Meaning |
|------|------|---------|
| `MciasAuthError` | 401 | Token missing, invalid, or expired |
| `MciasForbiddenError` | 403 | Insufficient role |
| `MciasNotFoundError` | 404 | Resource does not exist |
| `MciasInputError` | 400 | Malformed request |
| `MciasConflictError` | 409 | Conflict (e.g. duplicate username) |
| `MciasServerError` | 5xx | Unexpected server error |
| `MciasTransportError` | — | Network or TLS failure |
---
## Token Lifecycle
```
┌─────────────────────────────────┐
│ MCIAS │
POST /v1/auth/login │ │
User ─────────────────────► Argon2id verify │
◄─────────────────── │ Ed25519 sign JWT │
JWT (30d / 8h) │ Track JTI in DB │
│ │
Service ──► POST /v1/token/validate ──► Check sig + JTI │
◄── {valid, sub, roles} │
│ │
User ──► POST /v1/auth/renew ─────────► Revoke old JTI │
◄── New JWT Issue new JWT │
│ │
User ──► POST /v1/auth/logout ────────► Revoke JTI │
│ │
└─────────────────────────────────┘
```
**Expiry defaults:**
| Account type | Default expiry | Notes |
|---|---|---|
| Human | 30 days | Configurable via `tokens.default_expiry` |
| Admin (`admin` role) | 8 hours | Configurable via `tokens.admin_expiry` |
| System account | 1 year | Configurable via `tokens.service_expiry` |
Tokens are revoked on explicit logout, account deletion, or admin revocation.
Expired-but-not-revoked tokens can be pruned from the database with
`mciasdb prune tokens`.
---
## Error Reference
All error responses are JSON with `error` (human-readable) and `code` (machine-readable):
```json
{ "error": "invalid credentials", "code": "unauthorized" }
```
| HTTP | `code` | When |
|------|--------|------|
| 400 | `bad_request` | Missing required field or malformed JSON |
| 401 | `unauthorized` | Wrong credentials, expired or invalid token |
| 401 | `totp_required` | Correct password but TOTP code not provided |
| 403 | `forbidden` | Token valid but lacks required role |
| 404 | `not_found` | Account or token does not exist |
| 409 | `conflict` | Username already taken |
| 429 | `rate_limited` | Too many login or validate requests |
| 500 | `internal_error` | Unexpected server error |
---
## API Reference
See [openapi.yaml](openapi.yaml) for the machine-readable OpenAPI 3.1 spec.
A Swagger UI is served at `https://<server>/docs` when the server is running.
### Authentication header
All authenticated endpoints require:
```
Authorization: Bearer <JWT>
```
### Endpoints summary
#### Public (no auth)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/v1/health` | Health check |
| GET | `/v1/keys/public` | Ed25519 public key (JWK) |
| POST | `/v1/auth/login` | Login; returns JWT |
| POST | `/v1/token/validate` | Validate a JWT |
#### Authenticated
| Method | Path | Description |
|--------|------|-------------|
| POST | `/v1/auth/logout` | Revoke current token |
| POST | `/v1/auth/renew` | Exchange token for a fresh one |
| POST | `/v1/auth/totp/enroll` | Begin TOTP enrollment |
| POST | `/v1/auth/totp/confirm` | Confirm TOTP enrollment |
#### Admin only (`admin` role required)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/v1/accounts` | List all accounts |
| POST | `/v1/accounts` | Create account |
| GET | `/v1/accounts/{id}` | Get account |
| PATCH | `/v1/accounts/{id}` | Update account status |
| DELETE | `/v1/accounts/{id}` | Soft-delete account |
| GET | `/v1/accounts/{id}/roles` | Get roles |
| PUT | `/v1/accounts/{id}/roles` | Replace roles |
| GET | `/v1/accounts/{id}/pgcreds` | Get Postgres credentials |
| PUT | `/v1/accounts/{id}/pgcreds` | Set Postgres credentials |
| POST | `/v1/token/issue` | Issue service account token |
| DELETE | `/v1/token/{jti}` | Revoke token by JTI |
| DELETE | `/v1/auth/totp` | Remove TOTP from account |
| GET | `/v1/audit` | Query audit log |
---
## Security Notes for Integrators
**Always validate `alg` before the signature.** Never use a JWT library that
accepts `alg: none` or falls back to HMAC. MCIAS only issues `EdDSA` tokens.
**Cache the public key, but refresh it.** Fetch `/v1/keys/public` at startup.
Refresh it if signature verification starts failing (key rotation). Do not
hard-code the key bytes.
**Use the CA certificate.** MCIAS typically runs with a self-signed or
private-CA cert. Pass the cert path to your HTTP client; do not disable TLS
verification.
**Do not log tokens.** Strip the `Authorization` header from access logs.
A leaked token is valid until it expires or is explicitly revoked.
**For revocation-sensitive paths, use online validation.** The `/v1/token/validate`
endpoint reflects revocations instantly. Offline verification is fine for
low-sensitivity reads; use online validation for writes, privilege escalation,
and any action with irreversible effects.
**System accounts have one active token at a time.** Issuing a new token via
`/v1/token/issue` revokes the previous one. Do not share a system account
across services that rotate independently; create one system account per service.
**Roles are not hierarchical.** `admin` does not imply any other role and vice
versa. Check each required role explicitly.