Files
mcias/ARCHITECTURE.md
2026-03-11 12:19:17 -07:00

24 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     │ direct file I/O
         │              │                │
  ┌──────┴──────┐  ┌────┴─────┐  ┌──────┴──────┐
  │  Personal   │  │ mciasctl │  │   mciasdb   │
  │    Apps     │  │  (admin  │  │  (DB tool)  │
  └─────────────┘  │   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.

mciasdb — The database maintenance tool. Operates directly on the SQLite file, bypassing the server API. Intended for break-glass recovery, offline inspection, schema verification, and maintenance tasks that cannot be performed through the live server. Requires the same master key material as mciassrv (passphrase or keyfile) to decrypt secrets at rest.


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:

{"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.