# 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.