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