23 KiB
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
- Defense in depth. Passwords are hashed with Argon2id; JWTs are signed with Ed25519; all transport uses TLS 1.2+ (TLS 1.3 preferred).
- Least privilege. System accounts have no interactive login path. Human
accounts have only the roles explicitly granted. Admin operations require
the
adminrole. - Fail closed. Invalid, expired, or unrecognized tokens must be rejected immediately. Missing claims are not assumed; they are treated as invalid.
- No credential leakage. Passwords, raw tokens, and private keys must never appear in logs, error messages, API responses, or stack traces.
- Constant-time comparisons. All equality checks on secret material
(tokens, password hashes, TOTP codes) use
crypto/subtle.ConstantTimeCompareto 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 (includingnone,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/validateendpoint (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:
- MCIAS is the sole issuer. Apps must not issue their own identity tokens.
- 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). - Role-based access. Apps use the
rolesclaim in the validated JWT to make authorization decisions. MCIAS does not know about app-specific permissions; it only knows about global roles. - Audience scoping (future). In v1 tokens are not audience-scoped. A
future
audclaim may restrict tokens to specific apps. - 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:
{"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).
-- 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.
[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.