Adding docs.
This commit is contained in:
568
ARCHITECTURE.md
Normal file
568
ARCHITECTURE.md
Normal file
@@ -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$<salt>$<hash>`), 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.
|
||||
64
PROGRESS.md
Normal file
64
PROGRESS.md
Normal file
@@ -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.
|
||||
232
PROJECT_PLAN.md
Normal file
232
PROJECT_PLAN.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user