diff --git a/AUDIT.md b/AUDIT.md
index 940ca96..0626f5a 100644
--- a/AUDIT.md
+++ b/AUDIT.md
@@ -1,24 +1,148 @@
# MCIAS Security Audit Report
-**Date:** 2026-03-14 (updated — all findings remediated)
+**Date:** 2026-03-14 (updated — penetration test round 4)
**Original audit date:** 2026-03-13
**Auditor role:** Penetration tester (code review + live instance probing)
-**Scope:** Full codebase and running instance at localhost:8443 — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, headers, and operational security.
+**Scope:** Full codebase and running instance at mcias.metacircular.net:8443 — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, headers, and operational security.
**Methodology:** Static code analysis, live HTTP probing, architectural review.
---
## Executive Summary
-MCIAS has a strong security posture. All findings from three audit rounds (CRIT-01/CRIT-02, DEF-01 through DEF-10, and SEC-01 through SEC-12) have been remediated. The cryptographic foundations are sound, JWT validation is correct, SQL injection is not possible, XSS is prevented by Go's html/template auto-escaping, and CSRF protection is well-implemented.
+MCIAS has a strong security posture. All findings from the first three audit rounds (CRIT-01/CRIT-02, DEF-01 through DEF-10, and SEC-01 through SEC-12) have been remediated. The cryptographic foundations are sound, JWT validation is correct, SQL injection is not possible, XSS is prevented by Go's html/template auto-escaping, and CSRF protection is well-implemented.
-**All findings from this audit have been remediated.** See the remediation table below for details.
+A fourth-round penetration test (PEN-01 through PEN-07) against the live instance at `mcias.metacircular.net:8443` identified 7 new findings: 2 medium, 2 low, and 3 informational. **Unauthorized access was not achieved** — the system's defense-in-depth held. See the open findings table below for details.
+
+---
+
+## Open Findings (PEN-01 through PEN-07)
+
+Identified during the fourth-round penetration test on 2026-03-14 against the live instance at `mcias.metacircular.net:8443` and the source code at the same commit.
+
+| ID | Severity | Finding | Status |
+|----|----------|---------|--------|
+| PEN-01 | Medium | `extractBearerFromRequest` does not validate "Bearer " prefix | **Fixed** — uses `strings.SplitN` + `strings.EqualFold` prefix validation, matching middleware implementation |
+| PEN-02 | Medium | Security headers missing from live instance responses | **Open** (code/deployment discrepancy) |
+| PEN-03 | Low | CSP `unsafe-inline` on `/docs` Swagger UI endpoint | **Open** |
+| PEN-04 | Info | OpenAPI spec publicly accessible without authentication | **Open** |
+| PEN-05 | Info | gRPC port 9443 publicly accessible | **Open** |
+| PEN-06 | Low | REST login increments lockout counter for missing TOTP code | **Open** |
+| PEN-07 | Info | Rate limiter is per-IP only, no per-account limiting | **Open** |
+
+
+Finding descriptions (click to expand)
+
+### PEN-01 — `extractBearerFromRequest` Does Not Validate "Bearer " Prefix (Medium)
+
+**File:** `internal/server/server.go` (lines 1414–1425)
+
+The server-level `extractBearerFromRequest` function extracts the token by slicing the `Authorization` header at offset 7 (`len("Bearer ")`) without first verifying that the header actually starts with `"Bearer "`. Any 8+ character `Authorization` value is accepted — e.g., `Authorization: XXXXXXXX` would extract `X` as the token string.
+
+```go
+// Current (vulnerable):
+if len(auth) <= len(prefix) {
+ return "", fmt.Errorf("malformed Authorization header")
+}
+return auth[len(prefix):], nil // no prefix check
+```
+
+The middleware-level `extractBearerToken` in `internal/middleware/middleware.go` (lines 303–316) correctly uses `strings.SplitN` and `strings.EqualFold` to validate the prefix. The server-level function should be replaced with a call to the middleware version, or the same validation logic should be applied.
+
+**Impact:** Low in practice because the extracted garbage is then passed to JWT validation which will reject it. However, it violates defense-in-depth: a future change to token validation could widen the attack surface, and the inconsistency between the two extraction functions is a maintenance hazard.
+
+**Recommendation:** Replace `extractBearerFromRequest` with a call to `middleware.extractBearerToken` (after exporting it or moving the function), or replicate the prefix validation.
+
+**Fix:** `extractBearerFromRequest` now uses `strings.SplitN` and `strings.EqualFold` to validate the `"Bearer"` prefix before extracting the token, matching the middleware implementation. Test `TestExtractBearerFromRequest` covers valid tokens, missing headers, non-Bearer schemes (Token, Basic), empty tokens, case-insensitive matching, and the previously-accepted garbage input.
+
+---
+
+### PEN-02 — Security Headers Missing from Live Instance Responses (Medium)
+
+**Live probe:** `https://mcias.metacircular.net:8443/login`
+
+The live instance's `/login` response did not include the security headers (`X-Content-Type-Options`, `Strict-Transport-Security`, `Cache-Control`, `Permissions-Policy`) that the source code's `globalSecurityHeaders` and UI `securityHeaders` middleware should be applying (SEC-04 and SEC-10 fixes).
+
+This is likely a code/deployment discrepancy — the deployed binary may predate the SEC-04/SEC-10 fixes, or the middleware may not be wired into the route chain correctly for all paths.
+
+**Impact:** Without HSTS, browsers will not enforce HTTPS-only access. Without `X-Content-Type-Options: nosniff`, MIME-type sniffing attacks are possible. Without `Cache-Control: no-store`, authenticated responses may be cached by proxies or browsers.
+
+**Recommendation:** Redeploy the current source to the live instance and verify headers with `curl -I`.
+
+---
+
+### PEN-03 — CSP `unsafe-inline` on `/docs` Swagger UI Endpoint (Low)
+
+**File:** `internal/server/server.go` (lines 1450–1452)
+
+The `docsSecurityHeaders` wrapper sets a Content-Security-Policy that includes `script-src 'self' 'unsafe-inline'` and `style-src 'self' 'unsafe-inline'`. This is required by Swagger UI's rendering approach, but it weakens CSP protection on the docs endpoint.
+
+**Impact:** If an attacker can inject content into the Swagger UI page (e.g., via a reflected parameter in the OpenAPI spec URL), inline scripts would execute. The blast radius is limited to the `/docs` path, which requires no authentication (see PEN-04).
+
+**Recommendation:** Consider serving Swagger UI from a separate subdomain or using CSP nonces instead of `unsafe-inline`. Alternatively, accept the risk given the limited scope.
+
+---
+
+### PEN-04 — OpenAPI Spec Publicly Accessible Without Authentication (Informational)
+
+**Live probe:** `GET /openapi.yaml` returns the full API specification without authentication.
+
+The OpenAPI spec reveals all API endpoints, request/response schemas, authentication flows, and error codes. While security-through-obscurity is not a defense, exposing the full API surface to unauthenticated users provides a roadmap for attackers.
+
+**Recommendation:** Consider requiring authentication for `/openapi.yaml` and `/docs`, or accept the risk if the API surface is intended to be public.
+
+---
+
+### PEN-05 — gRPC Port 9443 Publicly Accessible (Informational)
+
+**Live probe:** Port 9443 accepts TLS connections and serves gRPC.
+
+The gRPC interface is accessible from the public internet. While it requires authentication for all RPCs, exposing it increases the attack surface (gRPC-specific vulnerabilities, protocol-level attacks).
+
+**Recommendation:** If gRPC is only used for server-to-server communication, restrict access at the firewall/network level. If it must be public, ensure gRPC-specific rate limiting and monitoring are in place (SEC-06 fix applies here).
+
+---
+
+### PEN-06 — REST Login Increments Lockout Counter for Missing TOTP Code (Low)
+
+**File:** `internal/server/server.go` (lines 271–277)
+
+When a TOTP-enrolled account submits a login request without a TOTP code, the REST handler calls `s.db.RecordLoginFailure(acct.ID)` before returning the `"TOTP code required"` error. This increments the lockout counter even though the user has not actually failed authentication — they simply omitted the TOTP field.
+
+The gRPC handler was fixed for this exact issue in DEF-08, but the REST handler was not updated to match.
+
+```go
+// Current (REST — increments lockout for missing TOTP):
+if acct.TOTPRequired {
+ if req.TOTPCode == "" {
+ s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"totp_missing"}`)
+ _ = s.db.RecordLoginFailure(acct.ID) // should not increment
+ middleware.WriteError(w, http.StatusUnauthorized, "TOTP code required", "totp_required")
+ return
+ }
+```
+
+**Impact:** An attacker who knows a username with TOTP enabled can lock the account by sending 10 login requests with a valid password but no TOTP code. The password must be correct (wrong passwords also increment the counter), but this lowers the bar from "must guess TOTP" to "must omit TOTP." More practically, legitimate users with buggy clients that forget the TOTP field could self-lock.
+
+**Recommendation:** Remove the `RecordLoginFailure` call from the TOTP-missing branch, matching the gRPC handler's behavior after the DEF-08 fix.
+
+---
+
+### PEN-07 — Rate Limiter Is Per-IP Only, No Per-Account Limiting (Informational)
+
+The rate limiter uses a per-IP token bucket. An attacker with access to multiple IP addresses (botnet, cloud instances, rotating proxies) can distribute brute-force attempts across IPs to bypass the per-IP limit.
+
+The account lockout mechanism (10 failures in 15 minutes) provides a secondary defense, but it is a blunt instrument — it locks out the legitimate user as well.
+
+**Recommendation:** Consider adding per-account rate limiting as a complement to per-IP limiting. This would cap login attempts per username regardless of source IP, without affecting other users. The account lockout already partially serves this role, but a softer rate limit (e.g., 1 req/s per username) would slow distributed attacks without locking out the user.
+
+
---
## Remediated Findings (SEC-01 through SEC-12)
-All findings from this audit have been remediated. The original descriptions are preserved below for reference.
+All findings from the SEC audit round have been remediated. The original descriptions are preserved below for reference.
| ID | Severity | Finding | Status |
|----|----------|---------|--------|
@@ -186,9 +310,37 @@ These implementation details are exemplary and should be maintained:
---
+## Penetration Test — Attacks That Failed (2026-03-14)
+
+The following attacks were attempted against the live instance and failed, confirming the effectiveness of existing defenses:
+
+| Attack | Result |
+|--------|--------|
+| JWT `alg:none` bypass | Rejected — `ValidateToken` enforces `alg=EdDSA` |
+| JWT `alg:HS256` key-confusion | Rejected — only EdDSA accepted |
+| Forged JWT with random Ed25519 key | Rejected — signature verification failed |
+| Username enumeration via timing | Not possible — ~355ms for both existing and non-existing users (dummy Argon2 working) |
+| Username enumeration via error messages | Not possible — identical `"invalid credentials"` for all failure modes |
+| Account lockout enumeration | Not possible — locked accounts return same response as wrong password (SEC-02 fix confirmed) |
+| SQL injection via login fields | Not possible — parameterized queries throughout |
+| JSON body bomb (oversized payload) | Rejected — `http.MaxBytesReader` returns 413 (SEC-05 fix confirmed) |
+| Unknown JSON fields | Rejected — `DisallowUnknownFields` active on decoder |
+| Rate limit bypass | Working correctly — 429 after burst exhaustion, `Retry-After` header present |
+| Admin endpoint access without auth | Properly returns 401 |
+| Directory traversal on static files | Not possible — `noDirListing` wrapper returns 404 (SEC-07 fix confirmed) |
+| Public key endpoint | Returns Ed25519 PKIX key (expected; public by design) |
+
+---
+
## Remediation Status
-**All findings remediated.** No open items remain. Next audit should focus on:
+**CRIT/DEF/SEC series:** All 24 findings remediated. No open items.
+
+**PEN series (2026-03-14):** 1 of 7 findings remediated; 6 open (1 medium, 2 low, 3 informational). Unauthorized access was not achieved. Priority remediation items:
+1. **PEN-02** (Medium): Redeploy current source to live instance and verify security headers
+2. **PEN-06** (Low): Remove `RecordLoginFailure` from REST TOTP-missing branch
+
+Next audit should focus on:
+- Verifying PEN-01 through PEN-07 remediation
- Any new features added since 2026-03-14
- Dependency updates and CVE review
-- Live penetration testing of remediated endpoints
diff --git a/README.md b/README.md
index 996ac61..ffd3d2c 100644
--- a/README.md
+++ b/README.md
@@ -64,10 +64,10 @@ EOF
Generate the certificate:
```sh
-cert genkey -a ec -s 521 > /etc/mcias/server.key
-cert selfsign -p /etc/mcias/server.key -f /tmp/request.yaml > /etc/mcias/server.crt
-chmod 0640 /etc/mcias/server.key
-chown root:mcias /etc/mcias/server.key
+cert genkey -a ec -s 521 > /srv/mcias/server.key
+cert selfsign -p /srv/mcias/server.key -f /tmp/request.yaml > /srv/mcias/server.crt
+chmod 0640 /srv/mcias/server.key
+chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
rm /tmp/request.yaml
```
@@ -75,21 +75,21 @@ rm /tmp/request.yaml
```sh
openssl req -x509 -newkey ed25519 -days 3650 \
- -keyout /etc/mcias/server.key \
- -out /etc/mcias/server.crt \
+ -keyout /srv/mcias/server.key \
+ -out /srv/mcias/server.crt \
-subj "/CN=auth.example.com" \
-nodes
-chmod 0640 /etc/mcias/server.key
-chown root:mcias /etc/mcias/server.key
+chmod 0640 /srv/mcias/server.key
+chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
```
### 2. Configure the server
```sh
-cp dist/mcias.conf.example /etc/mcias/mcias.conf
-$EDITOR /etc/mcias/mcias.conf
-chmod 0640 /etc/mcias/mcias.conf
-chown root:mcias /etc/mcias/mcias.conf
+cp dist/mcias.conf.example /srv/mcias/mcias.toml
+$EDITOR /srv/mcias/mcias.toml
+chmod 0640 /srv/mcias/mcias.toml
+chown mcias:mcias /srv/mcias/mcias.toml
```
Minimum required fields:
@@ -97,11 +97,11 @@ Minimum required fields:
```toml
[server]
listen_addr = "0.0.0.0:8443"
-tls_cert = "/etc/mcias/server.crt"
-tls_key = "/etc/mcias/server.key"
+tls_cert = "/srv/mcias/server.crt"
+tls_key = "/srv/mcias/server.key"
[database]
-path = "/var/lib/mcias/mcias.db"
+path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
@@ -116,10 +116,10 @@ For local development, use `dist/mcias-dev.conf.example`.
### 3. Set the master key passphrase
```sh
-cp dist/mcias.env.example /etc/mcias/env
-$EDITOR /etc/mcias/env # replace the placeholder passphrase
-chmod 0640 /etc/mcias/env
-chown root:mcias /etc/mcias/env
+cp dist/mcias.env.example /srv/mcias/env
+$EDITOR /srv/mcias/env # replace the placeholder passphrase
+chmod 0640 /srv/mcias/env
+chown mcias:mcias /srv/mcias/env
```
> **Important:** Back up the passphrase to a secure offline location.
@@ -130,10 +130,10 @@ chown root:mcias /etc/mcias/env
```sh
export MCIAS_MASTER_PASSPHRASE=your-passphrase
-mciasdb --config /etc/mcias/mcias.conf account create \
+mciasdb --config /srv/mcias/mcias.toml account create \
--username admin --type human
-mciasdb --config /etc/mcias/mcias.conf account set-password --id
-mciasdb --config /etc/mcias/mcias.conf role grant --id --role admin
+mciasdb --config /srv/mcias/mcias.toml account set-password --id
+mciasdb --config /srv/mcias/mcias.toml role grant --id --role admin
```
### 5. Start the server
@@ -143,7 +143,7 @@ mciasdb --config /etc/mcias/mcias.conf role grant --id --role admin
systemctl enable --now mcias
# manual
-MCIAS_MASTER_PASSPHRASE=your-passphrase mciassrv -config /etc/mcias/mcias.conf
+MCIAS_MASTER_PASSPHRASE=your-passphrase mciassrv -config /srv/mcias/mcias.toml
```
### 6. Verify
@@ -193,7 +193,7 @@ See `man mciasctl` for the full reference.
```sh
export MCIAS_MASTER_PASSPHRASE=your-passphrase
-CONF="--config /etc/mcias/mcias.conf"
+CONF="--config /srv/mcias/mcias.toml"
mciasdb $CONF schema verify
mciasdb $CONF account list
@@ -217,22 +217,22 @@ Enable the gRPC listener in config:
[server]
listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443"
-tls_cert = "/etc/mcias/server.crt"
-tls_key = "/etc/mcias/server.key"
+tls_cert = "/srv/mcias/server.crt"
+tls_key = "/srv/mcias/server.key"
```
Using mciasgrpcctl:
```sh
export MCIAS_TOKEN=$ADMIN_JWT
-mciasgrpcctl -server auth.example.com:9443 -cacert /etc/mcias/server.crt health
+mciasgrpcctl -server auth.example.com:9443 -cacert /srv/mcias/server.crt health
mciasgrpcctl account list
```
Using grpcurl:
```sh
-grpcurl -cacert /etc/mcias/server.crt \
+grpcurl -cacert /srv/mcias/server.crt \
-H "authorization: Bearer $ADMIN_JWT" \
auth.example.com:9443 \
mcias.v1.AdminService/Health
@@ -265,14 +265,13 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) §8 (Web Management UI) for design detail
```sh
make docker
-mkdir -p /srv/mcias/config
-cp dist/mcias.conf.docker.example /srv/mcias/config/mcias.conf
-$EDITOR /srv/mcias/config/mcias.conf
+mkdir -p /srv/mcias
+cp dist/mcias.conf.docker.example /srv/mcias/mcias.toml
+$EDITOR /srv/mcias/mcias.toml
docker run -d \
--name mcias \
- -v /srv/mcias/config:/etc/mcias:ro \
- -v mcias-data:/data \
+ -v /srv/mcias:/srv/mcias \
-e MCIAS_MASTER_PASSPHRASE=your-passphrase \
-p 8443:8443 \
-p 9443:9443 \
diff --git a/RUNBOOK.md b/RUNBOOK.md
new file mode 100644
index 0000000..74c3fdd
--- /dev/null
+++ b/RUNBOOK.md
@@ -0,0 +1,464 @@
+# MCIAS Runbook
+
+Operational procedures for running and maintaining the MCIAS authentication
+server. All required files live under `/srv/mcias`.
+
+---
+
+## Directory Layout
+
+```
+/srv/mcias/
+ mcias.toml — server configuration (TOML)
+ server.crt — TLS certificate (PEM)
+ server.key — TLS private key (PEM, mode 0640)
+ mcias.db — SQLite database (WAL mode creates .db-wal and .db-shm)
+ env — environment file: MCIAS_MASTER_PASSPHRASE (mode 0640)
+ master.key — optional raw AES-256 key file (mode 0640, alternative to env)
+```
+
+All files are owned by the `mcias` system user and group (`mcias:mcias`).
+The directory itself is mode `0750`.
+
+---
+
+## Installation
+
+Run as root from the repository root after `make build`:
+
+```sh
+sh dist/install.sh
+```
+
+This script is idempotent. It:
+1. Creates the `mcias` system user and group if they do not exist.
+2. Installs binaries to `/usr/local/bin/`.
+3. Creates `/srv/mcias/` with correct ownership and permissions.
+4. Installs the systemd service unit to `/etc/systemd/system/mcias.service`.
+5. Installs example config files to `/srv/mcias/` (will not overwrite existing files).
+
+After installation, complete the steps below before starting the service.
+
+---
+
+## First-Run Setup
+
+### 1. Generate a TLS certificate
+
+**Self-signed (personal/development use):**
+
+```sh
+openssl req -x509 -newkey ed25519 -days 3650 \
+ -keyout /srv/mcias/server.key \
+ -out /srv/mcias/server.crt \
+ -subj "/CN=auth.example.com" \
+ -nodes
+chmod 0640 /srv/mcias/server.key
+chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
+```
+
+**Using the `cert` tool:**
+
+```sh
+go install github.com/kisom/cert@latest
+
+cat > /tmp/request.yaml < /srv/mcias/server.key
+cert selfsign -p /srv/mcias/server.key -f /tmp/request.yaml > /srv/mcias/server.crt
+chmod 0640 /srv/mcias/server.key
+chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
+rm /tmp/request.yaml
+```
+
+### 2. Write the configuration file
+
+```sh
+cp /srv/mcias/mcias.conf.example /srv/mcias/mcias.toml
+$EDITOR /srv/mcias/mcias.toml
+chmod 0640 /srv/mcias/mcias.toml
+chown mcias:mcias /srv/mcias/mcias.toml
+```
+
+Minimum required settings:
+
+```toml
+[server]
+listen_addr = "0.0.0.0:8443"
+tls_cert = "/srv/mcias/server.crt"
+tls_key = "/srv/mcias/server.key"
+
+[database]
+path = "/srv/mcias/mcias.db"
+
+[tokens]
+issuer = "https://auth.example.com"
+
+[master_key]
+passphrase_env = "MCIAS_MASTER_PASSPHRASE"
+```
+
+See `dist/mcias.conf.example` for the full annotated reference.
+
+### 3. Set the master key passphrase
+
+```sh
+cp /srv/mcias/mcias.env.example /srv/mcias/env
+$EDITOR /srv/mcias/env # set MCIAS_MASTER_PASSPHRASE to a long random value
+chmod 0640 /srv/mcias/env
+chown mcias:mcias /srv/mcias/env
+```
+
+Generate a strong passphrase:
+
+```sh
+openssl rand -base64 32
+```
+
+> **IMPORTANT:** Back up the passphrase to a secure offline location.
+> Losing it permanently destroys access to all encrypted data in the database.
+
+### 4. Create the first admin account
+
+```sh
+export MCIAS_MASTER_PASSPHRASE=your-passphrase
+
+mciasdb --config /srv/mcias/mcias.toml account create \
+ --username admin --type human
+# note the UUID printed
+
+mciasdb --config /srv/mcias/mcias.toml account set-password --id
+mciasdb --config /srv/mcias/mcias.toml role grant --id --role admin
+```
+
+### 5. Enable and start the service
+
+```sh
+systemctl enable mcias
+systemctl start mcias
+systemctl status mcias
+```
+
+### 6. Verify
+
+```sh
+curl -k https://auth.example.com:8443/v1/health
+# {"status":"ok"}
+```
+
+---
+
+## Routine Operations
+
+### Start / stop / restart
+
+```sh
+systemctl start mcias
+systemctl stop mcias
+systemctl restart mcias
+```
+
+### View logs
+
+```sh
+journalctl -u mcias -f
+journalctl -u mcias --since "1 hour ago"
+```
+
+### Check service status
+
+```sh
+systemctl status mcias
+```
+
+### Reload configuration
+
+The server reads its configuration at startup only. To apply config changes:
+
+```sh
+systemctl restart mcias
+```
+
+---
+
+## Account Management
+
+All account management can be done via `mciasctl` (REST API) when the server
+is running, or `mciasdb` for offline/break-glass operations.
+
+```sh
+# Set env for offline tool
+export MCIAS_MASTER_PASSPHRASE=your-passphrase
+CONF="--config /srv/mcias/mcias.toml"
+
+# List accounts
+mciasdb $CONF account list
+
+# Create account
+mciasdb $CONF account create --username alice --type human
+
+# Set password (prompts interactively)
+mciasdb $CONF account set-password --id
+
+# Grant or revoke a role
+mciasdb $CONF role grant --id --role admin
+mciasdb $CONF role revoke --id --role admin
+
+# Disable account
+mciasdb $CONF account set-status --id --status inactive
+
+# Delete account
+mciasdb $CONF account set-status --id --status deleted
+```
+
+---
+
+## Token Management
+
+```sh
+CONF="--config /srv/mcias/mcias.toml"
+
+# List active tokens for an account
+mciasdb $CONF token list --id
+
+# Revoke a specific token by JTI
+mciasdb $CONF token revoke --jti
+
+# Revoke all tokens for an account (e.g., suspected compromise)
+mciasdb $CONF token revoke-all --id
+
+# Prune expired tokens from the database
+mciasdb $CONF prune tokens
+```
+
+---
+
+## Database Maintenance
+
+### Verify schema
+
+```sh
+mciasdb --config /srv/mcias/mcias.toml schema verify
+```
+
+### Run pending migrations
+
+```sh
+mciasdb --config /srv/mcias/mcias.toml schema migrate
+```
+
+### Force schema version (break-glass)
+
+```sh
+mciasdb --config /srv/mcias/mcias.toml schema force --version N
+```
+
+Use only when `schema migrate` reports a dirty version after a failed migration.
+
+### Backup the database
+
+SQLite WAL mode creates three files. Back up all three atomically using the
+SQLite backup API or by stopping the server first:
+
+```sh
+# Online backup (preferred — no downtime):
+sqlite3 /srv/mcias/mcias.db ".backup /path/to/backup/mcias-$(date +%F).db"
+
+# Offline backup:
+systemctl stop mcias
+cp /srv/mcias/mcias.db /path/to/backup/mcias-$(date +%F).db
+systemctl start mcias
+```
+
+Store backups alongside a copy of the master key passphrase in a secure
+offline location. A database backup without the passphrase is unrecoverable.
+
+---
+
+## Audit Log
+
+```sh
+CONF="--config /srv/mcias/mcias.toml"
+
+# Show last 50 audit events
+mciasdb $CONF audit tail --n 50
+
+# Query by account
+mciasdb $CONF audit query --account
+
+# Query by event type since a given time
+mciasdb $CONF audit query --type login_failure --since 2026-01-01T00:00:00Z
+
+# Output as JSON (for log shipping)
+mciasdb $CONF audit query --json
+```
+
+---
+
+## Upgrading
+
+1. Build the new binaries: `make build`
+2. Stop the service: `systemctl stop mcias`
+3. Install new binaries: `sh dist/install.sh`
+ - The script will not overwrite existing config files.
+ - New example files are placed with a `.new` suffix for review.
+4. Review any `.new` config files in `/srv/mcias/` and merge changes manually.
+5. Run schema migrations if required:
+ ```sh
+ mciasdb --config /srv/mcias/mcias.toml schema migrate
+ ```
+6. Start the service: `systemctl start mcias`
+7. Verify: `curl -k https://auth.example.com:8443/v1/health`
+
+---
+
+## Master Key Rotation
+
+> This operation is not yet automated. Until a rotation command is
+> implemented, rotation requires a full re-encryption of the database.
+> Contact the project maintainer for the current procedure.
+
+---
+
+## TLS Certificate Renewal
+
+Replace the certificate and key files, then restart the server:
+
+```sh
+# Generate or obtain new cert/key, then:
+cp new-server.crt /srv/mcias/server.crt
+cp new-server.key /srv/mcias/server.key
+chmod 0640 /srv/mcias/server.key
+chown mcias:mcias /srv/mcias/server.crt /srv/mcias/server.key
+systemctl restart mcias
+```
+
+For Let's Encrypt with Certbot, add a deploy hook:
+
+```sh
+# /etc/letsencrypt/renewal-hooks/deploy/mcias.sh
+#!/bin/sh
+cp /etc/letsencrypt/live/auth.example.com/fullchain.pem /srv/mcias/server.crt
+cp /etc/letsencrypt/live/auth.example.com/privkey.pem /srv/mcias/server.key
+chmod 0640 /srv/mcias/server.key
+chown mcias:mcias /srv/mcias/server.crt /srv/mcias/server.key
+systemctl restart mcias
+```
+
+---
+
+## Docker Deployment
+
+```sh
+make docker
+
+mkdir -p /srv/mcias
+cp dist/mcias.conf.docker.example /srv/mcias/mcias.toml
+$EDITOR /srv/mcias/mcias.toml
+
+# Place TLS cert and key under /srv/mcias/
+# Set ownership so uid 10001 (container mcias user) can read them.
+chown -R 10001:10001 /srv/mcias
+
+docker run -d \
+ --name mcias \
+ -v /srv/mcias:/srv/mcias \
+ -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
+ -p 8443:8443 \
+ -p 9443:9443 \
+ --restart unless-stopped \
+ mcias:latest
+```
+
+See `dist/mcias.conf.docker.example` for the full annotated Docker config.
+
+---
+
+## Troubleshooting
+
+### Server fails to start: "open database"
+
+Check that `/srv/mcias/` is writable by the `mcias` user:
+
+```sh
+ls -la /srv/mcias/
+stat /srv/mcias/mcias.db # if it already exists
+```
+
+Fix: `chown mcias:mcias /srv/mcias`
+
+### Server fails to start: "environment variable ... is not set"
+
+The `MCIAS_MASTER_PASSPHRASE` env var is missing. Ensure `/srv/mcias/env`
+exists, is readable by the mcias user, and contains the correct variable:
+
+```sh
+grep MCIAS_MASTER_PASSPHRASE /srv/mcias/env
+```
+
+Also confirm the systemd unit loads it:
+
+```sh
+systemctl cat mcias | grep EnvironmentFile
+```
+
+### Server fails to start: "decrypt signing key"
+
+The master key passphrase has changed or is wrong. The passphrase must match
+the one used when the database was first initialized (the KDF salt is stored
+in the database). Restore the correct passphrase from your offline backup.
+
+### TLS errors in client connections
+
+Verify the certificate is valid and covers the correct hostname:
+
+```sh
+openssl x509 -in /srv/mcias/server.crt -noout -text | grep -E "Subject|DNS"
+openssl x509 -in /srv/mcias/server.crt -noout -dates
+```
+
+### Database locked / WAL not cleaning up
+
+Check for lingering `mcias.db-wal` and `mcias.db-shm` files after an unclean
+shutdown. These are safe to leave in place — SQLite will recover on next open.
+Do not delete them while the server is running.
+
+### Schema dirty after failed migration
+
+```sh
+mciasdb --config /srv/mcias/mcias.toml schema verify
+mciasdb --config /srv/mcias/mcias.toml schema force --version N
+mciasdb --config /srv/mcias/mcias.toml schema migrate
+```
+
+Replace `N` with the last successfully applied version number.
+
+---
+
+## File Permissions Reference
+
+| Path | Mode | Owner |
+|------|------|-------|
+| `/srv/mcias/` | `0750` | `mcias:mcias` |
+| `/srv/mcias/mcias.toml` | `0640` | `mcias:mcias` |
+| `/srv/mcias/server.crt` | `0644` | `mcias:mcias` |
+| `/srv/mcias/server.key` | `0640` | `mcias:mcias` |
+| `/srv/mcias/mcias.db` | `0640` | `mcias:mcias` |
+| `/srv/mcias/env` | `0640` | `mcias:mcias` |
+| `/srv/mcias/master.key` | `0640` | `mcias:mcias` |
+
+Verify permissions:
+
+```sh
+ls -la /srv/mcias/
+```
diff --git a/cmd/mciasdb/main.go b/cmd/mciasdb/main.go
index 498ab8e..880161e 100644
--- a/cmd/mciasdb/main.go
+++ b/cmd/mciasdb/main.go
@@ -9,7 +9,7 @@
//
// Usage:
//
-// mciasdb --config /etc/mcias/mcias.toml [subcommand] [flags]
+// mciasdb --config /srv/mcias/mcias.toml [subcommand] [flags]
//
// Commands:
//
@@ -53,7 +53,7 @@ import (
)
func main() {
- configPath := flag.String("config", "mcias.toml", "path to TOML configuration file")
+ configPath := flag.String("config", "/srv/mcias/mcias.toml", "path to TOML configuration file")
flag.Usage = usage
flag.Parse()
diff --git a/cmd/mciassrv/main.go b/cmd/mciassrv/main.go
index 1d92eaa..c416bb0 100644
--- a/cmd/mciassrv/main.go
+++ b/cmd/mciassrv/main.go
@@ -9,7 +9,7 @@
//
// Usage:
//
-// mciassrv -config /etc/mcias/mcias.toml
+// mciassrv -config /srv/mcias/mcias.toml
package main
import (
@@ -39,7 +39,7 @@ import (
)
func main() {
- configPath := flag.String("config", "mcias.toml", "path to TOML configuration file")
+ configPath := flag.String("config", "/srv/mcias/mcias.toml", "path to TOML configuration file")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
diff --git a/dist/install.sh b/dist/install.sh
index fac6873..4b9b89a 100644
--- a/dist/install.sh
+++ b/dist/install.sh
@@ -6,7 +6,7 @@
# This script must be run as root. It:
# 1. Creates the mcias system user and group (idempotent).
# 2. Copies binaries to /usr/local/bin/.
-# 3. Creates /etc/mcias/ and /var/lib/mcias/ with correct permissions.
+# 3. Creates /srv/mcias/ with correct permissions.
# 4. Installs the systemd service unit.
# 5. Prints post-install instructions.
#
@@ -25,8 +25,7 @@ set -eu
# Configuration
# ---------------------------------------------------------------------------
BIN_DIR="/usr/local/bin"
-CONF_DIR="/etc/mcias"
-DATA_DIR="/var/lib/mcias"
+SRV_DIR="/srv/mcias"
MAN_DIR="/usr/share/man/man1"
SYSTEMD_DIR="/etc/systemd/system"
SERVICE_USER="mcias"
@@ -114,23 +113,19 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
done
-# Step 3: Create configuration directory.
-info "Creating $CONF_DIR"
-install -d -m 0750 -o root -g "$SERVICE_GROUP" "$CONF_DIR"
+# Step 3: Create service directory.
+info "Creating $SRV_DIR"
+install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR"
# Install example config files; never overwrite existing configs.
for f in mcias.conf.example mcias.env.example; do
src="$SCRIPT_DIR/$f"
- dst="$CONF_DIR/$f"
+ dst="$SRV_DIR/$f"
if [ -f "$src" ]; then
- install -m 0640 -o root -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
+ install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
fi
done
-# Step 4: Create data directory.
-info "Creating $DATA_DIR"
-install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$DATA_DIR"
-
# Step 5: Install systemd service unit.
if [ -d "$SYSTEMD_DIR" ]; then
info "Installing systemd service unit to $SYSTEMD_DIR"
@@ -175,26 +170,26 @@ Next steps:
# Self-signed (development / personal use):
openssl req -x509 -newkey ed25519 -days 3650 \\
- -keyout /etc/mcias/server.key \\
- -out /etc/mcias/server.crt \\
+ -keyout /srv/mcias/server.key \\
+ -out /srv/mcias/server.crt \\
-subj "/CN=auth.example.com" \\
-nodes
- chmod 0640 /etc/mcias/server.key
- chown root:mcias /etc/mcias/server.key
+ chmod 0640 /srv/mcias/server.key
+ chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
2. Copy and edit the configuration file:
- cp /etc/mcias/mcias.conf.example /etc/mcias/mcias.conf
- \$EDITOR /etc/mcias/mcias.conf
- chmod 0640 /etc/mcias/mcias.conf
- chown root:mcias /etc/mcias/mcias.conf
+ cp /srv/mcias/mcias.conf.example /srv/mcias/mcias.toml
+ \$EDITOR /srv/mcias/mcias.toml
+ chmod 0640 /srv/mcias/mcias.toml
+ chown mcias:mcias /srv/mcias/mcias.toml
3. Set the master key passphrase:
- cp /etc/mcias/mcias.env.example /etc/mcias/env
- \$EDITOR /etc/mcias/env # replace the placeholder passphrase
- chmod 0640 /etc/mcias/env
- chown root:mcias /etc/mcias/env
+ cp /srv/mcias/mcias.env.example /srv/mcias/env
+ \$EDITOR /srv/mcias/env # replace the placeholder passphrase
+ chmod 0640 /srv/mcias/env
+ chown mcias:mcias /srv/mcias/env
IMPORTANT: Back up the passphrase to a secure offline location.
Losing it means losing access to all encrypted data in the database.
@@ -208,16 +203,16 @@ Next steps:
5. Create the first admin account using mciasdb (while the server is
running, or before first start):
- MCIAS_MASTER_PASSPHRASE=\$(grep MCIAS_MASTER_PASSPHRASE /etc/mcias/env | cut -d= -f2) \\
- mciasdb --config /etc/mcias/mcias.conf account create \\
+ MCIAS_MASTER_PASSPHRASE=\$(grep MCIAS_MASTER_PASSPHRASE /srv/mcias/env | cut -d= -f2) \\
+ mciasdb --config /srv/mcias/mcias.toml account create \\
--username admin --type human
Then set a password:
- MCIAS_MASTER_PASSPHRASE=... mciasdb --config /etc/mcias/mcias.conf \\
+ MCIAS_MASTER_PASSPHRASE=... mciasdb --config /srv/mcias/mcias.toml \\
account set-password --id
And grant the admin role:
- MCIAS_MASTER_PASSPHRASE=... mciasdb --config /etc/mcias/mcias.conf \\
+ MCIAS_MASTER_PASSPHRASE=... mciasdb --config /srv/mcias/mcias.toml \\
role grant --id --role admin
For full documentation, see: man mciassrv
diff --git a/dist/mcias-dev.conf.example b/dist/mcias-dev.conf.example
index 5ec080c..bce69a6 100644
--- a/dist/mcias-dev.conf.example
+++ b/dist/mcias-dev.conf.example
@@ -15,7 +15,7 @@
# export MCIAS_MASTER_PASSPHRASE=devpassphrase
#
# Start the server:
-# mciassrv -config /path/to/mcias-dev.conf
+# mciassrv -config /path/to/mcias-dev.toml
[server]
listen_addr = "127.0.0.1:8443"
diff --git a/dist/mcias.conf.docker.example b/dist/mcias.conf.docker.example
index 3a9611a..1ebfa12 100644
--- a/dist/mcias.conf.docker.example
+++ b/dist/mcias.conf.docker.example
@@ -1,38 +1,36 @@
# mcias.conf.docker.example — Config template for container deployment
#
-# Mount this file into the container at /etc/mcias/mcias.conf:
+# Mount this file into the container at /srv/mcias/mcias.toml:
#
# docker run -d \
# --name mcias \
-# -v /path/to/mcias.conf:/etc/mcias/mcias.conf:ro \
-# -v /path/to/certs:/etc/mcias:ro \
-# -v mcias-data:/data \
+# -v /srv/mcias:/srv/mcias \
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
# -p 8443:8443 \
# -p 9443:9443 \
# mcias:latest
#
# The container runs as uid 10001 (mcias). Ensure that:
-# - /data volume is writable by uid 10001
+# - /srv/mcias is writable by uid 10001
# - TLS cert and key are readable by uid 10001
#
# TLS: The server performs TLS termination inside the container; there is no
-# plain-text mode. Mount your certificate and key under /etc/mcias/.
+# plain-text mode. Place your certificate and key under /srv/mcias/.
# For Let's Encrypt certificates, mount the live/ directory read-only.
[server]
listen_addr = "0.0.0.0:8443"
grpc_addr = "0.0.0.0:9443"
-tls_cert = "/etc/mcias/server.crt"
-tls_key = "/etc/mcias/server.key"
+tls_cert = "/srv/mcias/server.crt"
+tls_key = "/srv/mcias/server.key"
# If a reverse proxy (nginx, Caddy, Traefik) sits in front of this container,
# set trusted_proxy to its container IP so real client IPs are used for rate
# limiting and audit logging. Leave commented out for direct exposure.
# trusted_proxy = "172.17.0.1"
[database]
-# VOLUME /data is declared in the Dockerfile; map a named volume here.
-path = "/data/mcias.db"
+# All data lives under /srv/mcias for a single-volume deployment.
+path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
diff --git a/dist/mcias.conf.example b/dist/mcias.conf.example
index e4546bd..2002f01 100644
--- a/dist/mcias.conf.example
+++ b/dist/mcias.conf.example
@@ -1,12 +1,12 @@
# mcias.conf — Reference configuration for mciassrv
#
-# Copy this file to /etc/mcias/mcias.conf and adjust the values for your
+# Copy this file to /srv/mcias/mcias.toml and adjust the values for your
# deployment. All fields marked REQUIRED must be set before the server will
# start. Fields marked OPTIONAL can be omitted to use defaults.
#
# File permissions: mode 0640, owner root:mcias.
-# chmod 0640 /etc/mcias/mcias.conf
-# chown root:mcias /etc/mcias/mcias.conf
+# chmod 0640 /srv/mcias/mcias.toml
+# chown root:mcias /srv/mcias/mcias.toml
# ---------------------------------------------------------------------------
# [server] — Network listener configuration
@@ -26,11 +26,11 @@ listen_addr = "0.0.0.0:8443"
# REQUIRED. Path to the TLS certificate (PEM format).
# Self-signed certificates work fine for personal deployments; for
# public-facing deployments consider a certificate from Let's Encrypt.
-tls_cert = "/etc/mcias/server.crt"
+tls_cert = "/srv/mcias/server.crt"
# REQUIRED. Path to the TLS private key (PEM format).
# Permissions: mode 0640, owner root:mcias.
-tls_key = "/etc/mcias/server.key"
+tls_key = "/srv/mcias/server.key"
# OPTIONAL. IP address of a trusted reverse proxy (e.g. nginx, Caddy, HAProxy).
# When set, the rate limiter and audit log extract the real client IP from the
@@ -55,7 +55,7 @@ tls_key = "/etc/mcias/server.key"
# REQUIRED. Path to the SQLite database file.
# The directory must be writable by the mcias user. WAL mode is enabled
# automatically; expect three files: mcias.db, mcias.db-wal, mcias.db-shm.
-path = "/var/lib/mcias/mcias.db"
+path = "/srv/mcias/mcias.db"
# ---------------------------------------------------------------------------
# [tokens] — JWT issuance policy
@@ -113,13 +113,13 @@ threads = 4
# database on first run and reused on subsequent runs so the same passphrase
# always produces the same master key.
#
-# Set the passphrase in /etc/mcias/env (loaded by the systemd EnvironmentFile
+# Set the passphrase in /srv/mcias/env (loaded by the systemd EnvironmentFile
# directive). See dist/mcias.env.example for the template.
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# Option B: Key file mode. The file must contain exactly 32 bytes of raw key
-# material (AES-256). Generate with: openssl rand -out /etc/mcias/master.key 32
+# material (AES-256). Generate with: openssl rand -out /srv/mcias/master.key 32
# Permissions: mode 0640, owner root:mcias.
#
# Uncomment and comment out passphrase_env to switch modes.
-# keyfile = "/etc/mcias/master.key"
+# keyfile = "/srv/mcias/master.key"
diff --git a/dist/mcias.env.example b/dist/mcias.env.example
index 1c1f394..9804b1d 100644
--- a/dist/mcias.env.example
+++ b/dist/mcias.env.example
@@ -1,10 +1,10 @@
-# /etc/mcias/env — Environment file for mciassrv (systemd EnvironmentFile).
+# /srv/mcias/env — Environment file for mciassrv (systemd EnvironmentFile).
#
# This file is loaded by the mcias.service unit before the server starts.
# It must be readable only by root and the mcias service account:
#
-# chmod 0640 /etc/mcias/env
-# chown root:mcias /etc/mcias/env
+# chmod 0640 /srv/mcias/env
+# chown root:mcias /srv/mcias/env
#
# SECURITY: This file contains the master key passphrase. Treat it with
# the same care as a private key. Do not commit it to version control.
diff --git a/dist/mcias.service b/dist/mcias.service
index ea45d54..d9ea77e 100644
--- a/dist/mcias.service
+++ b/dist/mcias.service
@@ -11,11 +11,11 @@ User=mcias
Group=mcias
# Configuration and secrets.
-# /etc/mcias/env must contain MCIAS_MASTER_PASSPHRASE=
+# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=
# See dist/mcias.env.example for the template.
-EnvironmentFile=/etc/mcias/env
+EnvironmentFile=/srv/mcias/env
-ExecStart=/usr/local/bin/mciassrv -config /etc/mcias/mcias.conf
+ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
Restart=on-failure
RestartSec=5
@@ -30,11 +30,11 @@ LimitNOFILE=65536
CapabilityBoundingSet=
# Filesystem restrictions.
-# mciassrv reads /etc/mcias (config, TLS cert/key) and writes /var/lib/mcias (DB).
+# mciassrv reads and writes /srv/mcias (config, TLS cert/key, database).
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
-ReadWritePaths=/var/lib/mcias
+ReadWritePaths=/srv/mcias
# Additional hardening.
NoNewPrivileges=true
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index a1366ef..a3a622a 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -12,11 +12,11 @@ func validConfig() string {
return `
[server]
listen_addr = "0.0.0.0:8443"
-tls_cert = "/etc/mcias/server.crt"
-tls_key = "/etc/mcias/server.key"
+tls_cert = "/srv/mcias/server.crt"
+tls_key = "/srv/mcias/server.key"
[database]
-path = "/var/lib/mcias/mcias.db"
+path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
@@ -154,11 +154,11 @@ func TestValidateMasterKeyBothSet(t *testing.T) {
path := writeTempConfig(t, `
[server]
listen_addr = "0.0.0.0:8443"
-tls_cert = "/etc/mcias/server.crt"
-tls_key = "/etc/mcias/server.key"
+tls_cert = "/srv/mcias/server.crt"
+tls_key = "/srv/mcias/server.key"
[database]
-path = "/var/lib/mcias/mcias.db"
+path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
@@ -173,7 +173,7 @@ threads = 4
[master_key]
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
-keyfile = "/etc/mcias/master.key"
+keyfile = "/srv/mcias/master.key"
`)
_, err := Load(path)
if err == nil {
@@ -185,11 +185,11 @@ func TestValidateMasterKeyNoneSet(t *testing.T) {
path := writeTempConfig(t, `
[server]
listen_addr = "0.0.0.0:8443"
-tls_cert = "/etc/mcias/server.crt"
-tls_key = "/etc/mcias/server.key"
+tls_cert = "/srv/mcias/server.crt"
+tls_key = "/srv/mcias/server.key"
[database]
-path = "/var/lib/mcias/mcias.db"
+path = "/srv/mcias/mcias.db"
[tokens]
issuer = "https://auth.example.com"
diff --git a/internal/server/server.go b/internal/server/server.go
index 7406ce7..bb8d48f 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -18,6 +18,7 @@ import (
"log/slog"
"net"
"net/http"
+ "strings"
"time"
"git.wntrmute.dev/kyle/mcias/internal/audit"
@@ -1412,16 +1413,23 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
}
// extractBearerFromRequest extracts a Bearer token from the Authorization header.
+// Security (PEN-01): validates the "Bearer" prefix using case-insensitive
+// comparison before extracting the token. The previous implementation sliced
+// at a fixed offset without checking the prefix, accepting any 8+ character
+// Authorization value.
func extractBearerFromRequest(r *http.Request) (string, error) {
auth := r.Header.Get("Authorization")
if auth == "" {
return "", fmt.Errorf("no Authorization header")
}
- const prefix = "Bearer "
- if len(auth) <= len(prefix) {
+ parts := strings.SplitN(auth, " ", 2)
+ if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", fmt.Errorf("malformed Authorization header")
}
- return auth[len(prefix):], nil
+ if parts[1] == "" {
+ return "", fmt.Errorf("empty Bearer token")
+ }
+ return parts[1], nil
}
// docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index 5578bdb..92cda20 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -620,8 +620,9 @@ func TestRenewToken(t *testing.T) {
acct := createTestHumanAccount(t, srv, "renew-user")
handler := srv.Handler()
- // Issue a short-lived token (2s) so we can wait past the 50% threshold.
- oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, 2*time.Second)
+ // Issue a short-lived token (4s) so we can wait past the 50% threshold
+ // while leaving enough headroom before expiry to avoid flakiness.
+ oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, 4*time.Second)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
@@ -630,8 +631,8 @@ func TestRenewToken(t *testing.T) {
t.Fatalf("TrackToken: %v", err)
}
- // Wait for >50% of the 2s lifetime to elapse.
- time.Sleep(1100 * time.Millisecond)
+ // Wait for >50% of the 4s lifetime to elapse.
+ time.Sleep(2100 * time.Millisecond)
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr)
if rr.Code != http.StatusOK {
@@ -793,6 +794,46 @@ func TestLoginLockedAccountReturns401(t *testing.T) {
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
// of its lifetime has elapsed (SEC-03).
+// TestExtractBearerFromRequest verifies that extractBearerFromRequest correctly
+// validates the "Bearer" prefix before extracting the token string.
+// Security (PEN-01): the previous implementation sliced at a fixed offset
+// without checking the prefix, accepting any 8+ character Authorization value.
+func TestExtractBearerFromRequest(t *testing.T) {
+ tests := []struct {
+ name string
+ header string
+ want string
+ wantErr bool
+ }{
+ {"valid", "Bearer mytoken123", "mytoken123", false},
+ {"missing header", "", "", true},
+ {"no bearer prefix", "Token mytoken123", "", true},
+ {"basic auth scheme", "Basic dXNlcjpwYXNz", "", true},
+ {"empty token", "Bearer ", "", true},
+ {"bearer only no space", "Bearer", "", true},
+ {"case insensitive", "bearer mytoken123", "mytoken123", false},
+ {"mixed case", "BEARER mytoken123", "mytoken123", false},
+ {"garbage 8 chars", "XXXXXXXX", "", true},
+ {"token with spaces", "Bearer token with spaces", "token with spaces", false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ if tc.header != "" {
+ req.Header.Set("Authorization", tc.header)
+ }
+ got, err := extractBearerFromRequest(req)
+ if (err != nil) != tc.wantErr {
+ t.Errorf("wantErr=%v, got err=%v", tc.wantErr, err)
+ }
+ if !tc.wantErr && got != tc.want {
+ t.Errorf("token = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
func TestRenewTokenTooEarly(t *testing.T) {
srv, _, priv, _ := newTestServer(t)
acct := createTestHumanAccount(t, srv, "renew-early-user")