* 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.
14 KiB
14 KiB
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.modexists with module pathgit.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 tidysucceeds;go build ./...succeeds on empty stubs
Step 0.2: .gitignore
Acceptance criteria:
.gitignoreexcludes: 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,PGCredentialstructs 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
Configstruct 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_envorkeyfileset - 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/randused 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 enabledMigrate(db *DB) errorapplies 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
- Header:
ValidateToken(key ed25519.PublicKey, tokenString string) (Claims, error)- Must check
algheader before any other processing; reject non-EdDSA - Validates
exp,iat,iss,jtipresence - Returns structured error types for: expired, invalid signature, wrong alg, missing claim
- Must check
RevokeToken(db *DB, jti string, reason string) errorPruneExpiredTokens(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 stringVerifyPassword(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-timeLogin(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, IPRequireAuth(key ed25519.PublicKey, db *DB)— extracts Bearer token, validates, checks revocation, injects claims into context; returns 401 on failureRequireRole(role string)— checks claims from context for role; returns 403 on failureRateLimit(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 toauth.Login; returns{token, expires_at}POST /v1/auth/logout— revokes current token from context; 204POST /v1/auth/renew— validates current token, issues new one, revokes oldPOST /v1/token/validate— validates submitted token; returns claimsDELETE /v1/token/{jti}— admin or role-scoped; revokes by JTIGET /v1/health— 200{"status":"ok"}GET /v1/keys/public— returns Ed25519 public key as JWKPOST /v1/accounts— admin; creates accountGET /v1/accounts— admin; lists accounts (no password hashes in response)GET /v1/accounts/{id}— admin; single accountPATCH /v1/accounts/{id}— admin; update status/fieldsDELETE /v1/accounts/{id}— admin; soft-delete + revoke all tokensGET|PUT /v1/accounts/{id}/roles— admin; get/replace role setPOST /v1/auth/totp/enroll— returns TOTP secret + otpauth URIPOST /v1/auth/totp/confirm— confirms TOTP enrollmentDELETE /v1/auth/totp— admin; removes TOTP from accountGET|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
--configflag - 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|systemmciasctl account listmciasctl account suspend --id UUIDmciasctl account delete --id UUIDmciasctl role grant --account UUID --role ROLEmciasctl role revoke --account UUID --role ROLEmciasctl token issue --account UUID(system accounts)mciasctl token revoke --jti JTImciasctl pgcreds set --account UUID --host H --port P --db D --user U --password Pmciasctl pgcreds get --account UUID
- CLI reads admin JWT from
MCIAS_ADMIN_TOKENenv var or--tokenflag - All commands make HTTPS requests to mciassrv (base URL from
--serverflag orMCIAS_SERVERenv 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 validTestE2EAdminFlow— create account via CLI, assign role, login as userTestE2ETOTPFlow— 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 warningsgo test -race ./...passes with zero race conditions- Manual review checklist:
- No password/token/secret in any log line (grep audit)
- All
crypto/rand— nomath/randusage - 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.mdreflects 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.goparses--configflag - 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 pendingmciasdb 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 accountsmciasdb account get --id UUID— prints single account recordmciasdb account create --username NAME --type human|system— inserts row, prints new UUIDmciasdb account set-password --id UUID— prompts twice (confirm), re-hashes with Argon2id, updates row; no--passwordflag permittedmciasdb account set-status --id UUID --status STATUS— updates statusmciasdb account reset-totp --id UUID— clears totp_required and totp_secret_encmciasdb role list --id UUID— prints rolesmciasdb role grant --id UUID --role ROLE— inserts role rowmciasdb 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 accountmciasdb token revoke --jti JTI— sets revoked_at = now on the rowmciasdb token revoke-all --id UUID— revokes all non-revoked tokens for accountmciasdb prune tokens— deletes rows fromtoken_revocationwhere 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 lastmciasdb audit query --account UUID— filters by actor_id or target_idmciasdb audit query --type EVENT_TYPE— filters by event_typemciasdb audit query --since TIMESTAMP— filters by event_time >= RFC-3339 timestamp- Flags are combinable (AND semantics)
--jsonflag 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 sensitivemciasdb pgcreds set --id UUID --host H --port P --db D --user U— prompts for password interactively (no--passwordflag), 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:
.gitignoreupdated to excludemciasdbbinary- README.md updated with mciasdb usage section (when to use vs mciasctl, config requirements, example commands)
PROGRESS.mdupdated 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.