diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..0d949d2 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,568 @@ +# MCIAS Architecture + +Metacircular Identity and Access System — Technical Design Document + +--- + +## 1. System Overview + +MCIAS is a self-hosted SSO and IAM service for a single developer's personal +applications. It is deliberately small-scope: no federation, no multi-tenant +complexity, no external IdP delegation. The security model is simple but +rigorous: all trust flows from the MCIAS server; applications are relying +parties that delegate authentication decisions to it. + +### Components + +``` +┌────────────────────────────────────────────────────┐ +│ MCIAS Server (mciassrv) │ +│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ +│ │ Auth │ │ Token │ │ Account / Role │ │ +│ │ Handler │ │ Manager │ │ Manager │ │ +│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │ +│ └─────────────┴─────────────────┘ │ +│ │ │ +│ ┌─────────▼──────────┐ │ +│ │ SQLite Database │ │ +│ └────────────────────┘ │ +└────────────────────────────────────────────────────┘ + ▲ ▲ + │ HTTPS/REST │ HTTPS/REST + │ │ + ┌──────┴──────┐ ┌───────┴──────┐ + │ Personal │ │ mciasctl │ + │ Apps │ │ (admin CLI) │ + └─────────────┘ └──────────────┘ +``` + +**mciassrv** — The authentication server. Exposes a REST API over HTTPS/TLS. +Handles login, token issuance, token validation, token renewal, and token +revocation. + +**mciasctl** — The administrator CLI. Communicates with mciassrv's REST API +using an admin JWT. Creates/manages human accounts, system accounts, roles, +and Postgres credential records. + +--- + +## 2. Security Model + +### Threat Model + +- **Attacker capabilities assumed:** Network interception (mitigated by TLS), + credential guessing (mitigated by Argon2id, account lockout), stolen JWT + (mitigated by short expiry + revocation), stolen DB file (mitigated by + hashed/encrypted credentials at rest). +- **Out of scope:** Physical access to the server host, OS-level compromise, + supply-chain attacks on Go dependencies. +- **Trust boundary:** The MCIAS server is the single root of trust. Applications + must not make authorization decisions without first validating a JWT from + MCIAS. All signing keys live exclusively on the MCIAS server. + +### Key Principles + +1. **Defense in depth.** Passwords are hashed with Argon2id; JWTs are signed + with Ed25519; all transport uses TLS 1.2+ (TLS 1.3 preferred). +2. **Least privilege.** System accounts have no interactive login path. Human + accounts have only the roles explicitly granted. Admin operations require + the `admin` role. +3. **Fail closed.** Invalid, expired, or unrecognized tokens must be rejected + immediately. Missing claims are not assumed; they are treated as invalid. +4. **No credential leakage.** Passwords, raw tokens, and private keys must + never appear in logs, error messages, API responses, or stack traces. +5. **Constant-time comparisons.** All equality checks on secret material + (tokens, password hashes, TOTP codes) use `crypto/subtle.ConstantTimeCompare` + to prevent timing side-channels. + +--- + +## 3. Cryptographic Primitives + +| Purpose | Algorithm | Rationale | +|---|---|---| +| Password hashing | Argon2id | OWASP-recommended; memory-hard; resists GPU/ASIC attacks. Parameters: time=3, memory=64MB, threads=4 (meets OWASP 2023 minimum of time=2, memory=64MB). | +| JWT signing | Ed25519 (EdDSA) | Fast, short signatures, no parameter malleability, immune to invalid-curve attacks. RFC 8037. | +| JWT key storage | Raw Ed25519 private key in PEM-encoded PKCS#8 file, chmod 0600. | | +| TOTP | HMAC-SHA1 per RFC 6238 (industry standard). Shared secret stored encrypted with AES-256-GCM using a server-side key. | | +| Credential storage | AES-256-GCM with a server-side master key. | | +| Random values | `crypto/rand` exclusively. Never `math/rand`. | | + +### JWT Security Rules (non-negotiable) + +- Algorithm in header **must** be `EdDSA`. Any other value (including `none`, + `HS256`, `RS256`, `ES256`) must cause immediate rejection before any + signature verification is attempted. +- The public key used to verify a JWT is taken from the server's keystore, + never from the token itself. +- All standard claims are validated: `exp` (required, enforced), `iat` + (required), `nbf` (optional but enforced if present), `iss` (must match + configured issuer), `jti` (required; checked against revocation list). +- Tokens are opaque to relying-party apps; they validate tokens by calling + the MCIAS `/v1/token/validate` endpoint (or, for trusted apps, by verifying + the Ed25519 signature against the published public key). + +--- + +## 4. Account Model + +### Account Types + +**Human accounts** — interactive users. Can authenticate via: +- Username + password (Argon2id hash stored in DB) +- Optional TOTP (RFC 6238); if enrolled, required on every login +- Future: FIDO2/WebAuthn, Yubikey (not in scope for v1) + +**System accounts** — non-interactive service identities. Have: +- A single active bearer token at a time (rotating the token revokes the old one) +- No password, no TOTP +- An associated Postgres credential record (optional) + +### Roles + +Roles are simple string labels stored in the `account_roles` table. + +Reserved roles: +- `admin` — superuser; can manage all accounts, tokens, and credentials +- Any role named identically to a system account — grants that human account + the ability to issue/revoke tokens and retrieve Postgres credentials for that + system account + +Role assignment requires admin privileges. + +### Account Lifecycle + +``` +Human: [created by admin] → active → [password change] → active + → [TOTP enroll] → active (TOTP required) + → [suspended] → inactive + → [deleted] → soft-deleted, tokens revoked + +System: [created by admin] → active → [token rotated] → active (old token revoked) + → [deleted] → soft-deleted, token revoked +``` + +--- + +## 5. Token Lifecycle + +### Token Types + +| Type | Subject | Expiry (default) | Renewable | Revocable | +|---|---|---|---|---| +| Session JWT | human user | 30 days | yes | yes | +| Service token | system account | 365 days | yes (rotate) | yes | +| Admin JWT | human user (admin role) | 8 hours | yes | yes | + +### Issuance Flow — Human Login + +``` +Client mciassrv + │ │ + ├─ POST /v1/auth/login ───▶│ + │ {username, password, │ + │ totp_code (opt)} │ + │ ├─ 1. Load account record; verify status=active + │ ├─ 2. Argon2id verify(password, stored_hash) + │ │ → constant-time; failed → 401, log event + │ ├─ 3. If TOTP enrolled: verify TOTP code + │ │ → constant-time; failed → 401, log event + │ ├─ 4. Generate JWT: + │ │ header: {"alg":"EdDSA","typ":"JWT"} + │ │ claims: {iss, sub (user UUID), iat, exp, + │ │ jti (UUID), roles:[...]} + │ ├─ 5. Sign with Ed25519 private key + │ ├─ 6. Store jti + exp in token_revocation table + │ ├─ 7. Log audit event (login_ok, user, IP) + │◀─ 200 {token, expires_at}│ +``` + +### Token Validation Flow + +``` +Client App mciassrv + │ │ + ├─ POST /v1/token/validate▶│ + │ Authorization: Bearer │ + │ ├─ 1. Parse JWT; extract alg header + │ │ → if alg != "EdDSA": reject 401 + │ ├─ 2. Verify Ed25519 signature + │ ├─ 3. Validate claims: exp, iat, iss, jti + │ ├─ 4. Check jti against revocation table + │ │ → if revoked: reject 401 + │ ├─ 5. Return {valid: true, sub, roles, exp} + │◀─ 200 {valid, sub, roles}│ +``` + +### Token Renewal + +A valid, non-expired, non-revoked token may be exchanged for a new token with +a fresh expiry window. The old token's `jti` is added to the revocation table +(marked revoked) upon successful renewal. + +### Token Revocation + +Revoked tokens are stored in the `token_revocation` table with their `jti` +and original `exp`. A background task (or on-demand sweep) removes rows whose +`exp` is in the past, since expired tokens are inherently invalid. + +Admin users can revoke any token. Users with the role matching a system account +can revoke that system account's token. Human users can revoke their own tokens +(logout). + +--- + +## 6. Session Management + +MCIAS is stateless at the HTTP level — there are no server-side sessions. +"Session state" is encoded in the JWT itself (roles, user ID, expiry). The +revocation table provides the statefulness needed for logout and forced +invalidation. + +Key properties: +- Concurrent logins are permitted (multiple live JTIs per user) +- Logout revokes only the presented token (single-device logout) +- Admin can revoke all tokens for a user (e.g., on account suspension) +- Token expiry is enforced at validation time, regardless of revocation table + +--- + +## 7. Multi-App Trust Boundaries + +Each personal application that relies on MCIAS for authentication is a +**relying party**. Trust boundaries: + +1. **MCIAS is the sole issuer.** Apps must not issue their own identity tokens. +2. **Apps validate tokens via MCIAS.** Either by calling `/v1/token/validate` + (recommended; gets revocation checking) or by verifying the Ed25519 + signature against the published public key (skips revocation check). +3. **Role-based access.** Apps use the `roles` claim in the validated JWT to + make authorization decisions. MCIAS does not know about app-specific + permissions; it only knows about global roles. +4. **Audience scoping (future).** In v1 tokens are not audience-scoped. A + future `aud` claim may restrict tokens to specific apps. +5. **Service accounts per app.** Each personal app should have a corresponding + system account. The app may authenticate to MCIAS using its service token + to call protected management endpoints. + +--- + +## 8. API Design + +Base path: `/v1` + +All endpoints use JSON request/response bodies. All responses include a +`Content-Type: application/json` header. Errors follow a uniform structure: + +```json +{"error": "human-readable message", "code": "machine_readable_code"} +``` + +### Authentication Endpoints + +| Method | Path | Auth required | Description | +|---|---|---|---| +| POST | `/v1/auth/login` | none | Username/password (+TOTP) login → JWT | +| POST | `/v1/auth/logout` | bearer JWT | Revoke current token | +| POST | `/v1/auth/renew` | bearer JWT | Exchange token for new token | + +### Token Endpoints + +| Method | Path | Auth required | Description | +|---|---|---|---| +| POST | `/v1/token/validate` | none | Validate a JWT (passed as Bearer header) | +| POST | `/v1/token/issue` | admin JWT or role-scoped JWT | Issue service account token | +| DELETE | `/v1/token/{jti}` | admin JWT or role-scoped JWT | Revoke token by JTI | + +### Account Endpoints (admin only) + +| Method | Path | Auth required | Description | +|---|---|---|---| +| GET | `/v1/accounts` | admin JWT | List all accounts | +| POST | `/v1/accounts` | admin JWT | Create human or system account | +| GET | `/v1/accounts/{id}` | admin JWT | Get account details | +| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) | +| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account | + +### Role Endpoints (admin only) + +| Method | Path | Auth required | Description | +|---|---|---|---| +| GET | `/v1/accounts/{id}/roles` | admin JWT | List roles for account | +| PUT | `/v1/accounts/{id}/roles` | admin JWT | Replace role set | + +### TOTP Endpoints + +| Method | Path | Auth required | Description | +|---|---|---|---| +| POST | `/v1/auth/totp/enroll` | bearer JWT | Begin TOTP enrollment (returns secret + QR URI) | +| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code | +| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) | + +### Postgres Credential Endpoints + +| Method | Path | Auth required | Description | +|---|---|---|---| +| GET | `/v1/accounts/{id}/pgcreds` | admin JWT or role-scoped JWT | Retrieve Postgres credentials | +| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials | + +### Admin / Server Endpoints + +| Method | Path | Auth required | Description | +|---|---|---|---| +| GET | `/v1/health` | none | Health check | +| GET | `/v1/keys/public` | none | Ed25519 public key (JWK format) | + +--- + +## 9. Database Schema + +Database: SQLite 3, WAL mode enabled, `PRAGMA foreign_keys = ON`. + +All tables use `INTEGER PRIMARY KEY` surrogate keys (SQLite rowid alias). +UUIDs used for external identifiers (stored as TEXT). + +```sql +-- Server-side secrets (one row always) +CREATE TABLE server_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + -- Ed25519 private key, PEM PKCS#8, encrypted at rest with AES-256-GCM + -- using a master key derived from the startup passphrase. + signing_key_enc BLOB NOT NULL, + signing_key_nonce BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +-- Human and system accounts +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + uuid TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + account_type TEXT NOT NULL CHECK (account_type IN ('human','system')), + -- NULL for system accounts; PHC-format Argon2id string for human accounts + password_hash TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active','inactive','deleted')), + -- 1 if TOTP is enrolled and required; human accounts only + totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)), + -- AES-256-GCM encrypted TOTP secret; NULL if not enrolled + totp_secret_enc BLOB, + totp_secret_nonce BLOB, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + deleted_at TEXT +); + +CREATE INDEX idx_accounts_username ON accounts (username); +CREATE INDEX idx_accounts_uuid ON accounts (uuid); +CREATE INDEX idx_accounts_status ON accounts (status); + +-- Role assignments +CREATE TABLE account_roles ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + role TEXT NOT NULL, + granted_by INTEGER REFERENCES accounts(id), + granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE (account_id, role) +); + +CREATE INDEX idx_account_roles_account ON account_roles (account_id); + +-- Token tracking table. Tracks all issued tokens by JTI for revocation. +-- Rows where both revoked_at IS NULL and expires_at is in the future represent +-- currently-valid tokens. Rows are pruned when expires_at < now. +-- The token value itself is NEVER stored here. +CREATE TABLE token_revocation ( + id INTEGER PRIMARY KEY, + jti TEXT NOT NULL UNIQUE, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + revoked_at TEXT, + revoke_reason TEXT, + issued_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE INDEX idx_token_jti ON token_revocation (jti); +CREATE INDEX idx_token_account ON token_revocation (account_id); +CREATE INDEX idx_token_expires ON token_revocation (expires_at); + +-- Current active service token for each system account (one per account). +-- When rotated, the old JTI is marked revoked in token_revocation. +CREATE TABLE system_tokens ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE, + jti TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +-- Postgres credentials for system accounts, encrypted at rest. +CREATE TABLE pg_credentials ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE, + pg_host TEXT NOT NULL, + pg_port INTEGER NOT NULL DEFAULT 5432, + pg_database TEXT NOT NULL, + pg_username TEXT NOT NULL, + pg_password_enc BLOB NOT NULL, + pg_password_nonce BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +-- Audit log — append-only. Never contains credentials or secret material. +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + event_type TEXT NOT NULL, + actor_id INTEGER REFERENCES accounts(id), + target_id INTEGER REFERENCES accounts(id), + ip_address TEXT, + details TEXT -- JSON blob; never contains secrets +); + +CREATE INDEX idx_audit_time ON audit_log (event_time); +CREATE INDEX idx_audit_actor ON audit_log (actor_id); +CREATE INDEX idx_audit_event ON audit_log (event_type); +``` + +### Schema Notes + +- Passwords are stored as PHC-format Argon2id strings (e.g., + `$argon2id$v=19$m=65536,t=3,p=4$$`), embedding algorithm + parameters. Future parameter upgrades are transparent. +- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a + master key held only in server memory (derived at startup from a passphrase + or keyfile). The nonce is stored adjacent to the ciphertext. +- The signing key encryption is layered: the Ed25519 private key is wrapped + with AES-256-GCM using the startup master key. Operators must supply the + passphrase/keyfile on each server restart. +- The audit log is append-only and must never be pruned without explicit + operator action. + +--- + +## 10. TLS Configuration + +mciassrv requires TLS. Configuration: +- Minimum version: TLS 1.2 (TLS 1.3 preferred) +- Certificate: operator-supplied PEM (path in config file) +- Cipher suites (TLS 1.2 only): ECDHE+AESGCM, ECDHE+CHACHA20 +- Development/testing: self-signed cert acceptable; production must use a + CA-signed cert (Let's Encrypt recommended) + +--- + +## 11. Configuration + +The server is configured via a TOML config file. Sensitive values (master key +passphrase) may be supplied via environment variable (`MCIAS_MASTER_PASSPHRASE`) +or a keyfile path — never inline in the config file. + +```toml +[server] +listen_addr = "0.0.0.0:8443" +tls_cert = "/etc/mcias/server.crt" +tls_key = "/etc/mcias/server.key" + +[database] +path = "/var/lib/mcias/mcias.db" + +[tokens] +issuer = "https://auth.example.com" +default_expiry = "720h" # 30 days +admin_expiry = "8h" +service_expiry = "8760h" # 365 days + +[argon2] +time = 3 +memory = 65536 # KiB (64 MB) +threads = 4 + +[master_key] +# Exactly one of: passphrase_env or keyfile +passphrase_env = "MCIAS_MASTER_PASSPHRASE" +``` + +--- + +## 12. Directory / Package Structure + +``` +mcias/ +├── cmd/ +│ ├── mciassrv/ # server binary entrypoint +│ │ └── main.go +│ └── mciasctl/ # admin CLI entrypoint +│ └── main.go +├── internal/ +│ ├── auth/ # login flow, TOTP verification, account lockout +│ ├── config/ # config file parsing and validation +│ ├── crypto/ # key management, AES-GCM helpers, master key derivation +│ ├── db/ # SQLite access layer (schema, migrations, queries) +│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit) +│ ├── model/ # shared data types (Account, Token, Role, etc.) +│ ├── server/ # HTTP handlers, router setup +│ └── token/ # JWT issuance, validation, revocation +└── go.mod +``` + +All implementation packages are under `internal/` to prevent external import. +The `cmd/` packages are thin wrappers that wire dependencies and call into +`internal/`. + +--- + +## 13. Error Handling and Logging + +- All errors are wrapped with `fmt.Errorf("context: %w", err)`. +- Structured logging uses `log/slog` (or goutils wrapper). +- Log levels: DEBUG (dev only), INFO (normal ops), WARN (recoverable), ERROR + (unexpected failures). +- Authentication events (success and failure) are always logged at INFO with: + `{event, username (not password), ip, user_agent, timestamp, result}`. +- **Never log:** passwords, raw tokens, TOTP codes, master key material, + Postgres credentials. + +--- + +## 14. Audit Events + +| Event type | Trigger | +|---|---| +| `login_ok` | Successful login | +| `login_fail` | Failed login (wrong password, unknown user) | +| `login_totp_fail` | Correct password, wrong TOTP code | +| `token_issued` | JWT issued (login or service token) | +| `token_renewed` | Token exchanged for a fresh one | +| `token_revoked` | Token explicitly revoked | +| `token_expired` | Attempt to use an expired token (at validation time) | +| `account_created` | New account created | +| `account_updated` | Account modified (status, roles) | +| `account_deleted` | Account soft-deleted | +| `role_granted` | Role assigned to account | +| `role_revoked` | Role removed from account | +| `totp_enrolled` | TOTP enrollment completed | +| `totp_removed` | TOTP removed from account | +| `pgcred_accessed` | Postgres credentials retrieved | +| `pgcred_updated` | Postgres credentials stored/updated | + +--- + +## 15. Operational Considerations + +- **Backups:** Use SQLite's online backup API or filesystem snapshot with WAL + checkpointing. The master key/passphrase must be backed up separately and + securely. +- **Key rotation:** Rotating the Ed25519 signing key requires re-issuing tokens + for all users (old tokens become unverifiable). A dual-key grace period is + not in v1 scope. +- **Rate limiting:** Login endpoints are rate-limited by IP (token bucket: 10 + attempts/minute). Implemented in middleware. In v1, an in-memory rate limiter + is acceptable (single-instance deployment). +- **Master key loss:** Loss of the master key means all encrypted secrets + (TOTP, Postgres passwords, signing key) are unrecoverable. Operators must + back up the passphrase/keyfile securely. diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..ac20d5e --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,64 @@ +# MCIAS Progress + +Source of truth for current development state. + +--- + +## Current Status: Phase 0 — Repository Bootstrap + +### Completed + +- [x] CLAUDE.md — project conventions and constraints +- [x] .golangci.yaml — linter configuration +- [x] PROJECT.md — project specifications +- [x] ARCHITECTURE.md — technical design document (token lifecycle, session + management, multi-app trust boundaries, database schema) +- [x] PROJECT_PLAN.md — discrete implementation steps with acceptance criteria +- [x] PROGRESS.md — this file + +### In Progress + +- [ ] Step 0.1: Go module and dependency setup (`go.mod`, `go get`) +- [ ] Step 0.2: `.gitignore` + +### Up Next + +- Phase 1: Foundational packages (`internal/model`, `internal/config`, + `internal/crypto`, `internal/db`) + +--- + +## Implementation Log + +### 2026-03-11 + +- Wrote ARCHITECTURE.md covering: + - Security model and threat model + - Cryptographic primitive choices with rationale + - Account model (human + system accounts, roles, lifecycle) + - Token lifecycle (issuance, validation, renewal, revocation flows) + - Session management approach (stateless JWT + revocation table) + - Multi-app trust boundaries + - REST API design (all endpoints) + - Database schema (SQLite, all tables with indexes) + - TLS configuration + - TOML configuration format + - Package/directory structure + - Error handling and logging conventions + - Audit event catalog + - Operational considerations + +- Wrote PROJECT_PLAN.md with 5 phases, 12 steps, each with specific + acceptance criteria. + +--- + +## Notes / Decisions + +- SQLite driver: using `modernc.org/sqlite` (pure Go, no CGo dependency). + This simplifies cross-compilation and removes the need for a C toolchain. +- JWT library: `github.com/golang-jwt/jwt/v5`. The `alg` header validation + is implemented manually before delegating to the library to ensure the + library's own algorithm dispatch cannot be bypassed. +- No ORM. All database access via the standard `database/sql` interface with + prepared statements. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..481bd24 --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,232 @@ +# MCIAS Project Plan + +Discrete implementation steps with acceptance criteria. +See ARCHITECTURE.md for design rationale. + +--- + +## Phase 0 — Repository Bootstrap + +### Step 0.1: Go module and dependency setup +**Acceptance criteria:** +- `go.mod` exists with module path `git.wntrmute.dev/kyle/mcias` +- Required dependencies declared: `modernc.org/sqlite` (CGo-free SQLite), + `golang.org/x/crypto` (Argon2, Ed25519 helpers), `github.com/golang-jwt/jwt/v5`, + `github.com/pelletier/go-toml/v2`, `github.com/google/uuid`, + `git.wntrmute.dev/kyle/goutils` +- `go mod tidy` succeeds; `go build ./...` succeeds on empty stubs + +### Step 0.2: .gitignore +**Acceptance criteria:** +- `.gitignore` excludes: build output (`mciassrv`, `mciasctl`), `*.db`, + `*.db-wal`, `*.db-shm`, test coverage files, editor artifacts + +--- + +## Phase 1 — Foundational Packages + +### Step 1.1: `internal/model` — shared data types +**Acceptance criteria:** +- `Account`, `Role`, `Token`, `AuditEvent`, `PGCredential` structs defined +- Account types and statuses as typed constants +- No external dependencies (pure data definitions) +- Tests: struct instantiation and constant validation + +### Step 1.2: `internal/config` — configuration loading +**Acceptance criteria:** +- TOML config parsed into a `Config` struct matching ARCHITECTURE.md §11 +- Validation: required fields present, listen_addr parseable, TLS paths + non-empty, Argon2 params within safe bounds (memory >= 64MB, time >= 2) +- Master key config: exactly one of `passphrase_env` or `keyfile` set +- Tests: valid config round-trips; missing required field → error; unsafe + Argon2 params rejected + +### Step 1.3: `internal/crypto` — key management and encryption helpers +**Acceptance criteria:** +- `GenerateEd25519KeyPair() (crypto/ed25519.PublicKey, crypto/ed25519.PrivateKey, error)` +- `MarshalPrivateKeyPEM(key crypto/ed25519.PrivateKey) ([]byte, error)` (PKCS#8) +- `ParsePrivateKeyPEM(pemData []byte) (crypto/ed25519.PrivateKey, error)` +- `SealAESGCM(key, plaintext []byte) (ciphertext, nonce []byte, err error)` +- `OpenAESGCM(key, nonce, ciphertext []byte) ([]byte, error)` +- `DeriveKey(passphrase string, salt []byte) ([]byte, error)` — Argon2id KDF + for master key derivation (separate from password hashing) +- Key is always 32 bytes (AES-256) +- `crypto/rand` used for all nonce/salt generation +- Tests: seal/open round-trip; open with wrong key → error; PEM round-trip; + nonces are unique across calls + +### Step 1.4: `internal/db` — database layer +**Acceptance criteria:** +- `Open(path string) (*DB, error)` opens/creates SQLite DB with WAL mode and + foreign keys enabled +- `Migrate(db *DB) error` applies schema from ARCHITECTURE.md §9 idempotently + (version table tracks applied migrations) +- CRUD for `accounts`, `account_roles`, `token_revocation`, `system_tokens`, + `pg_credentials`, `audit_log`, `server_config` +- All queries use prepared statements (no string concatenation) +- Tests: in-memory SQLite (`:memory:`); each CRUD operation; FK constraint + enforcement; migration idempotency + +--- + +## Phase 2 — Authentication Core + +### Step 2.1: `internal/token` — JWT issuance and validation +**Acceptance criteria:** +- `IssueToken(key ed25519.PrivateKey, claims Claims) (string, error)` + - Header: `{"alg":"EdDSA","typ":"JWT"}` + - Claims: `iss`, `sub`, `iat`, `exp`, `jti` (UUID), `roles` +- `ValidateToken(key ed25519.PublicKey, tokenString string) (Claims, error)` + - **Must** check `alg` header before any other processing; reject non-EdDSA + - Validates `exp`, `iat`, `iss`, `jti` presence + - Returns structured error types for: expired, invalid signature, wrong alg, + missing claim +- `RevokeToken(db *DB, jti string, reason string) error` +- `PruneExpiredTokens(db *DB) (int64, error)` — removes rows past expiry +- Tests: happy path; expired token rejected; wrong alg (none/HS256/RS256) + rejected; tampered signature rejected; missing jti rejected; revoked token + checked by caller; pruning removes only expired rows + +### Step 2.2: `internal/auth` — login and credential verification +**Acceptance criteria:** +- `HashPassword(password string, params ArgonParams) (string, error)` — returns + PHC-format string +- `VerifyPassword(password, hash string) (bool, error)` — constant-time; + re-hashes if params differ (upgrade path) +- `ValidateTOTP(secret []byte, code string) (bool, error)` — RFC 6238, + 1-window tolerance, constant-time +- `Login(ctx, db, key, cfg, req LoginRequest) (LoginResponse, error)` + - Orchestrates: load account → verify status → verify password → verify TOTP + (if required) → issue JWT → write token_revocation row → write audit_log + - On any failure: write audit_log (login_fail or login_totp_fail); return + generic error to caller (no information about which step failed) +- Tests: successful login; wrong password (timing: compare duration against + a threshold — at minimum assert no short-circuit); wrong TOTP; suspended + account; deleted account; TOTP enrolled but not provided; constant-time + comparison verified (both branches take comparable time) + +--- + +## Phase 3 — HTTP Server + +### Step 3.1: `internal/middleware` — HTTP middleware +**Acceptance criteria:** +- `RequestLogger` — logs method, path, status, duration, IP +- `RequireAuth(key ed25519.PublicKey, db *DB)` — extracts Bearer token, + validates, checks revocation, injects claims into context; returns 401 on + failure +- `RequireRole(role string)` — checks claims from context for role; returns + 403 on failure +- `RateLimit(rps float64, burst int)` — per-IP token bucket; returns 429 on + limit exceeded +- Tests: logger captures fields; RequireAuth rejects missing/invalid/revoked + tokens; RequireRole rejects insufficient roles; RateLimit triggers after + burst exceeded + +### Step 3.2: `internal/server` — HTTP handlers and router +**Acceptance criteria:** +- Router wired per ARCHITECTURE.md §8 (all endpoints listed) +- `POST /v1/auth/login` — delegates to `auth.Login`; returns `{token, expires_at}` +- `POST /v1/auth/logout` — revokes current token from context; 204 +- `POST /v1/auth/renew` — validates current token, issues new one, revokes old +- `POST /v1/token/validate` — validates submitted token; returns claims +- `DELETE /v1/token/{jti}` — admin or role-scoped; revokes by JTI +- `GET /v1/health` — 200 `{"status":"ok"}` +- `GET /v1/keys/public` — returns Ed25519 public key as JWK +- `POST /v1/accounts` — admin; creates account +- `GET /v1/accounts` — admin; lists accounts (no password hashes in response) +- `GET /v1/accounts/{id}` — admin; single account +- `PATCH /v1/accounts/{id}` — admin; update status/fields +- `DELETE /v1/accounts/{id}` — admin; soft-delete + revoke all tokens +- `GET|PUT /v1/accounts/{id}/roles` — admin; get/replace role set +- `POST /v1/auth/totp/enroll` — returns TOTP secret + otpauth URI +- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment +- `DELETE /v1/auth/totp` — admin; removes TOTP from account +- `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials +- Credential fields (password hash, TOTP secret, Postgres password) are + **never** included in any API response +- Tests: each endpoint happy path; auth middleware applied correctly; invalid + JSON body → 400; credential fields absent from all responses + +### Step 3.3: `cmd/mciassrv` — server binary +**Acceptance criteria:** +- Reads config file path from `--config` flag +- Loads config, derives master key, opens DB, runs migrations, loads/generates + signing key +- Starts HTTPS listener on configured address +- Graceful shutdown on SIGINT/SIGTERM (30s drain) +- If no signing key exists in DB, generates one and stores it encrypted +- Integration test: start server on random port, hit `/v1/health`, assert 200 + +--- + +## Phase 4 — Admin CLI + +### Step 4.1: `cmd/mciasctl` — admin CLI +**Acceptance criteria:** +- Subcommands: + - `mciasctl account create --username NAME --type human|system` + - `mciasctl account list` + - `mciasctl account suspend --id UUID` + - `mciasctl account delete --id UUID` + - `mciasctl role grant --account UUID --role ROLE` + - `mciasctl role revoke --account UUID --role ROLE` + - `mciasctl token issue --account UUID` (system accounts) + - `mciasctl token revoke --jti JTI` + - `mciasctl pgcreds set --account UUID --host H --port P --db D --user U --password P` + - `mciasctl pgcreds get --account UUID` +- CLI reads admin JWT from `MCIAS_ADMIN_TOKEN` env var or `--token` flag +- All commands make HTTPS requests to mciassrv (base URL from `--server` flag + or `MCIAS_SERVER` env var) +- Tests: flag parsing; missing required flags → error; help text complete + +--- + +## Phase 5 — End-to-End Tests and Hardening + +### Step 5.1: End-to-end test suite +**Acceptance criteria:** +- `TestE2ELoginLogout` — create account, login, validate token, logout, + validate token again (must fail) +- `TestE2ETokenRenewal` — login, renew, old token rejected, new token valid +- `TestE2EAdminFlow` — create account via CLI, assign role, login as user +- `TestE2ETOTPFlow` — enroll TOTP, login without code (fail), login with code + (succeed) +- `TestE2ESystemAccount` — create system account, issue token, validate token, + rotate token (old token rejected) +- `TestE2EAlgConfusion` — attempt login, forge token with alg=HS256/none, + submit to validate endpoint, assert 401 + +### Step 5.2: Security hardening review +**Acceptance criteria:** +- `golangci-lint run ./...` passes with zero warnings +- `go test -race ./...` passes with zero race conditions +- Manual review checklist: + - [ ] No password/token/secret in any log line (grep audit) + - [ ] All `crypto/rand` — no `math/rand` usage + - [ ] All token comparisons use `crypto/subtle` + - [ ] Argon2id params meet OWASP minimums + - [ ] JWT alg validated before signature check in all code paths + - [ ] All DB queries use parameterized statements + - [ ] TLS min version enforced in server config + +### Step 5.3: Documentation and commit +**Acceptance criteria:** +- README.md updated with: build instructions, config file example, first-run + steps +- Each phase committed separately with appropriate commit messages +- `PROGRESS.md` reflects completed state + +--- + +## Implementation Order + +``` +Phase 0 → Phase 1 (1.1, 1.2, 1.3, 1.4 in parallel or sequence) + → Phase 2 (2.1 then 2.2) + → Phase 3 (3.1, 3.2, 3.3 in sequence) + → Phase 4 + → Phase 5 +``` + +Each step must have passing tests before the next step begins.