* Rewrite .golangci.yaml to v2 schema: linters-settings -> linters.settings, issues.exclude-rules -> issues.exclusions.rules, issues.exclude-dirs -> issues.exclusions.paths * Drop deprecated revive exported/package-comments rules: personal project, not a public library; godoc completeness is not a CI req * Add //nolint:gosec G101 on PassphraseEnv default in config.go: environment variable name is not a credential value * Add //nolint:gosec G101 on EventPGCredUpdated in model.go: audit event type string, not a credential Security: no logic changes. gosec G101 suppressions are false positives confirmed by code inspection: neither constant holds a credential value.
320 lines
14 KiB
Markdown
320 lines
14 KiB
Markdown
# 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
|
|
|
|
---
|
|
|
|
## Phase 6 — mciasdb: Database Maintenance Tool
|
|
|
|
See ARCHITECTURE.md §16 for full design rationale, trust model, and command
|
|
surface.
|
|
|
|
### Step 6.1: `cmd/mciasdb` — binary skeleton and config loading
|
|
**Acceptance criteria:**
|
|
- Binary at `cmd/mciasdb/main.go` parses `--config` flag
|
|
- Loads `[database]` and `[master_key]` sections from mciassrv config format
|
|
- Derives master key (passphrase or keyfile, identical logic to mciassrv)
|
|
- Opens SQLite DB with WAL mode and FK enforcement (reuses `internal/db`)
|
|
- Exits with error if DB cannot be opened (e.g., busy-timeout exceeded)
|
|
- Help text lists all subcommands
|
|
|
|
### Step 6.2: Schema subcommands
|
|
**Acceptance criteria:**
|
|
- `mciasdb schema verify` — opens DB, reports current schema version, exits 0
|
|
if up-to-date, exits 1 if migrations pending
|
|
- `mciasdb schema migrate` — applies any pending migrations, reports each one
|
|
applied, exits 0
|
|
- Tests: verify on fresh DB reports version 0; migrate advances to current
|
|
version; verify after migrate exits 0
|
|
|
|
### Step 6.3: Account and role subcommands
|
|
**Acceptance criteria:**
|
|
- `mciasdb account list` — prints uuid, username, type, status for all accounts
|
|
- `mciasdb account get --id UUID` — prints single account record
|
|
- `mciasdb account create --username NAME --type human|system` — inserts row,
|
|
prints new UUID
|
|
- `mciasdb account set-password --id UUID` — prompts twice (confirm), re-hashes
|
|
with Argon2id, updates row; no `--password` flag permitted
|
|
- `mciasdb account set-status --id UUID --status STATUS` — updates status
|
|
- `mciasdb account reset-totp --id UUID` — clears totp_required and
|
|
totp_secret_enc
|
|
- `mciasdb role list --id UUID` — prints roles
|
|
- `mciasdb role grant --id UUID --role ROLE` — inserts role row
|
|
- `mciasdb role revoke --id UUID --role ROLE` — deletes role row
|
|
- All write operations append an audit log row with actor tagged `mciasdb`
|
|
- Tests: each subcommand happy path against in-memory SQLite; unknown UUID
|
|
returns error; set-password with mismatched confirmation returns error
|
|
|
|
### Step 6.4: Token subcommands
|
|
**Acceptance criteria:**
|
|
- `mciasdb token list --id UUID` — prints jti, issued_at, expires_at,
|
|
revoked_at for account
|
|
- `mciasdb token revoke --jti JTI` — sets revoked_at = now on the row
|
|
- `mciasdb token revoke-all --id UUID` — revokes all non-revoked tokens for
|
|
account
|
|
- `mciasdb prune tokens` — deletes rows from `token_revocation` where
|
|
expires_at < now; prints count removed
|
|
- All write operations append an audit log row
|
|
- Tests: revoke on unknown JTI returns error; revoke-all on account with no
|
|
active tokens is a no-op (exits 0); prune removes only expired rows
|
|
|
|
### Step 6.5: Audit log subcommands
|
|
**Acceptance criteria:**
|
|
- `mciasdb audit tail [--n N]` — prints last N events (default 50), newest last
|
|
- `mciasdb audit query --account UUID` — filters by actor_id or target_id
|
|
- `mciasdb audit query --type EVENT_TYPE` — filters by event_type
|
|
- `mciasdb audit query --since TIMESTAMP` — filters by event_time >= RFC-3339
|
|
timestamp
|
|
- Flags are combinable (AND semantics)
|
|
- `--json` flag on any audit subcommand emits newline-delimited JSON
|
|
- Tests: tail returns correct count; query filters correctly; --json output is
|
|
valid JSON
|
|
|
|
### Step 6.6: Postgres credentials subcommands
|
|
**Acceptance criteria:**
|
|
- `mciasdb pgcreds get --id UUID` — decrypts and prints host, port, db,
|
|
username, password with a warning header that output is sensitive
|
|
- `mciasdb pgcreds set --id UUID --host H --port P --db D --user U` — prompts
|
|
for password interactively (no `--password` flag), encrypts with AES-256-GCM,
|
|
stores row
|
|
- All write operations append an audit log row
|
|
- Tests: get on account with no pgcreds returns error; set then get round-trips
|
|
correctly (decrypted value matches original)
|
|
|
|
### Step 6.7: .gitignore and documentation
|
|
**Acceptance criteria:**
|
|
- `.gitignore` updated to exclude `mciasdb` binary
|
|
- README.md updated with mciasdb usage section (when to use vs mciasctl,
|
|
config requirements, example commands)
|
|
- `PROGRESS.md` updated to reflect Phase 6 complete
|
|
|
|
---
|
|
|
|
## 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
|
|
→ Phase 6 (6.1 → 6.2 → 6.3 → 6.4 → 6.5 → 6.6 → 6.7)
|
|
```
|
|
|
|
Each step must have passing tests before the next step begins.
|