commit 4ddd32b117427f710347841f44e56d163be6d1e2 Author: Kyle Isom Date: Sat Mar 14 20:43:11 2026 -0700 Implement Phase 1: core framework, operational tooling, and runbook Core packages: crypto (Argon2id/AES-256-GCM), config (TOML/viper), db (SQLite/migrations), barrier (encrypted storage), seal (state machine with rate-limited unseal), auth (MCIAS integration with token cache), policy (priority-based ACL engine), engine (interface + registry). Server: HTTPS with TLS 1.2+, REST API, auth/admin middleware, htmx web UI (init, unseal, login, dashboard pages). CLI: cobra/viper subcommands (server, init, status, snapshot) with env var override support (METACRYPT_ prefix). Operational tooling: Dockerfile (multi-stage, non-root), docker-compose, hardened systemd units (service + daily backup timer), install script, backup script with retention pruning, production config examples. Runbook covering installation, configuration, daily operations, backup/restore, monitoring, troubleshooting, and security procedures. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ded820a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(git:*)" + ] + } +} diff --git a/.claude/skills/checkpoint/SKILL.md b/.claude/skills/checkpoint/SKILL.md new file mode 100644 index 0000000..437dfeb --- /dev/null +++ b/.claude/skills/checkpoint/SKILL.md @@ -0,0 +1,8 @@ +# Checkpoint Skill + +1. Run `go build ./...` abort if errors +2. Run `go test ./...` abort if failures +3. Run `go vet ./...` +4. Run `git add -A && git status` show user what will be committed +5. Generate an appropriate commit message based on your instructions. +6. Run `git commit -m ""` and verify with `git log -1` \ No newline at end of file diff --git a/.claude/tasks/security-audit/TASK.md b/.claude/tasks/security-audit/TASK.md new file mode 100644 index 0000000..c20ee20 --- /dev/null +++ b/.claude/tasks/security-audit/TASK.md @@ -0,0 +1,8 @@ +Run a full security audit of this Go codebase. For each finding rated +HIGH or CRITICAL: spawn a sub-agent using Task to implement the fix +across all affected files (models, handlers, migrations, templates, +tests). Each sub-agent must: 1) write a failing test that reproduces the +vulnerability, 2) implement the fix, 3) run `go test ./...` and `go vet +./...` in a loop until all pass, 4) commit with a message referencing +the finding ID. After all sub-agents complete, generate a summary of +what was fixed and what needs manual review. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f5511e --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Binary (root only, not cmd/metacrypt/) +/metacrypt +/metacrypt.exe + +# Database +*.db +*.db-wal +*.db-shm + +# Backups +backup.db + +# TLS certs (never commit real certs) +certs/ + +# Config with real values (root only, not examples/) +/metacrypt.toml + +# IDE +.idea/ +.vscode/ +*.swp +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b647e97 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Metacrypt is a cryptographic service for the Metacircular platform, written in Go. It provides cryptographic resources via an "engines" architecture (CA, SSH CA, transit encryption, user-to-user encryption). Authentication is handled by MCIAS (Metacircular Identity and Access Service) using the client library at `git.wntrmute.dev/kyle/mcias/clients/go`. MCIAS API docs: https://mcias.metacircular.net:8443/docs + +## Build & Test Commands + +```bash +go build ./... # Build all packages +go test ./... # Run all tests +go vet ./... # Static analysis +``` + +## Architecture + +- **Engines**: Modular cryptographic service providers (CA, SSH CA, transit, user-to-user encryption) +- **Storage**: SQLite database with an encrypted storage barrier (similar to HashiCorp Vault) +- **Seal/Unseal**: Single password unseals the service; a master encryption key serves as a key-encryption key (KEK) to decrypt per-engine data encryption keys +- **Auth**: MCIAS integration; MCIAS admin users get admin privileges on this service diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c58908e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM golang:1.23-alpine AS builder + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt ./cmd/metacrypt + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S metacrypt \ + && adduser -S -G metacrypt -h /metacrypt -s /sbin/nologin metacrypt + +COPY --from=builder /metacrypt /usr/local/bin/metacrypt +COPY web/ /metacrypt/web/ + +# /data is the single volume mount point. +# It must contain: +# metacrypt.toml — configuration file +# certs/ — TLS certificate and key +# metacrypt.db — created automatically on first run +VOLUME /data +WORKDIR /data + +EXPOSE 8443 + +USER metacrypt + +ENTRYPOINT ["metacrypt"] +CMD ["server", "--config", "/data/metacrypt.toml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f39e4ff --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: build test vet clean docker all + +build: + go build ./... + +metacrypt: + go build -trimpath -ldflags="-s -w" -o metacrypt ./cmd/metacrypt + +test: + go test ./... + +vet: + go vet ./... + +clean: + rm -f metacrypt + +docker: + docker build -t metacrypt . + +docker-compose: + docker compose -f deploy/docker/docker-compose.yml up --build + +all: vet test metacrypt diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..fb52a5d --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,15 @@ +The metacrypt service provides cryptographic resources for metacircular users. It will use the Metacircular Identity and Access Service (MCIAS), whose API is documented at https://mcias.metacircular.net:8443/docs. The MCIAS admin user should be granted admin privileges on the service. + +Metacrypt is based on the concept of "engines," each of which provides a specific cryptographic services. The complete system will have engines for a CA, an SSH CA, transit encryption, and user-to-user encryption. + +Like other Metacircular services, it will use a SQLite database as its primary source of truth. + +It should have a data model similar to what hashicorp vault does, in that it will have an encrypted storage barrier. However, only a single password needs to be provided to unseal it. A master encryption key will be used as a key-encryption key to decrypt other data encryption keys. + +The first step is to build out the basic framework for the application, to include login, unsealing, and the encrypted barrier. + +We will be using Go as the main language. The MCIAS client library (git.wntrmute.dev/kyle/mcias/clients/go) is used for authentication. Use 256-bit symmetric keys and Ed25519/Curve25519 or NIST P-521 where appropriate for public key algorithms. Use Argon2 for password hashing. + +It will need a gRPC and JSON REST API, as well as a web frontend. + +First, we'll devise a detailed specification and architecture design for this system. Ask any necessary clarifications during this phase. \ No newline at end of file diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..bcd9989 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,415 @@ +# Metacrypt Operations Runbook + +## Overview + +Metacrypt is a cryptographic service for the Metacircular platform. It provides an encrypted storage barrier, engine-based cryptographic operations, and MCIAS-backed authentication. The service uses a seal/unseal model: it starts sealed after every restart and must be unsealed with a password before it can serve requests. + +### Service States + +| State | Description | +|---|---| +| **Uninitialized** | Fresh install. Must run `metacrypt init` or use the web UI. | +| **Sealed** | Initialized but locked. No cryptographic operations available. | +| **Initializing** | Transient state during first-time setup. | +| **Unsealed** | Fully operational. All APIs and engines available. | + +### Architecture + +``` +Client → HTTPS (:8443) → Metacrypt Server + ├── Auth (proxied to MCIAS) + ├── Policy Engine (ACL rules in barrier) + ├── Engine Registry (mount/unmount crypto engines) + └── Encrypted Barrier → SQLite (on disk) +``` + +Key hierarchy: + +``` +Seal Password (operator-held, never stored) + → Argon2id → Key-Wrapping Key (KWK, ephemeral) + → AES-256-GCM decrypt → Master Encryption Key (MEK) + → AES-256-GCM → all barrier-stored data +``` + +--- + +## Installation + +### Binary Install (systemd) + +```bash +# Build +make metacrypt + +# Install (as root) +sudo deploy/scripts/install.sh ./metacrypt +``` + +This creates: + +| Path | Purpose | +|---|---| +| `/usr/local/bin/metacrypt` | Binary | +| `/etc/metacrypt/metacrypt.toml` | Configuration | +| `/etc/metacrypt/certs/` | TLS certificates | +| `/var/lib/metacrypt/` | Database and backups | + +### Docker Install + +```bash +# Build image +make docker + +# Or use docker compose +docker compose -f deploy/docker/docker-compose.yml up -d +``` + +The Docker container mounts a single volume at `/data` which must contain: + +| File | Required | Description | +|---|---|---| +| `metacrypt.toml` | Yes | Configuration (use `deploy/examples/metacrypt-docker.toml` as template) | +| `certs/server.crt` | Yes | TLS certificate | +| `certs/server.key` | Yes | TLS private key | +| `certs/mcias-ca.crt` | If MCIAS uses private CA | MCIAS CA certificate | +| `metacrypt.db` | No | Created automatically on first run | + +To prepare a Docker volume: + +```bash +docker volume create metacrypt-data + +# Copy files into the volume +docker run --rm -v metacrypt-data:/data -v $(pwd)/deploy/examples:/src alpine \ + sh -c "cp /src/metacrypt-docker.toml /data/metacrypt.toml && mkdir -p /data/certs" + +# Then copy your TLS certs into the volume the same way +``` + +--- + +## Configuration + +Configuration is loaded from TOML. The config file location is determined by (in order): + +1. `--config` flag +2. `METACRYPT_CONFIG` environment variable (via viper) +3. `metacrypt.toml` in the current directory +4. `/etc/metacrypt/metacrypt.toml` + +All config values can be overridden via environment variables with the `METACRYPT_` prefix (e.g., `METACRYPT_SERVER_LISTEN_ADDR`). + +See `deploy/examples/metacrypt.toml` for a fully commented production config. + +### Required Settings + +- `server.listen_addr` — bind address (e.g., `:8443`) +- `server.tls_cert` / `server.tls_key` — TLS certificate and key paths +- `database.path` — SQLite database file path +- `mcias.server_url` — MCIAS authentication server URL + +### TLS Certificates + +Metacrypt always terminates TLS. Minimum TLS 1.2 is enforced. + +To generate a self-signed certificate for testing: + +```bash +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-384 \ + -keyout server.key -out server.crt -days 365 -nodes \ + -subj "/CN=metacrypt.local" \ + -addext "subjectAltName=DNS:metacrypt.local,DNS:localhost,IP:127.0.0.1" +``` + +For production, use certificates from your internal CA or a public CA. + +--- + +## First-Time Setup + +### Option A: CLI (recommended for servers) + +```bash +metacrypt init --config /etc/metacrypt/metacrypt.toml +``` + +This prompts for a seal password, generates the master encryption key, and stores the encrypted MEK in the database. The service is left in the unsealed state. + +### Option B: Web UI + +Start the server and navigate to `https://:8443/`. If the service is uninitialized, you will be redirected to the init page. + +### Seal Password Requirements + +- The seal password is the root of all security. If lost, the data is unrecoverable. +- Store it in a secure location (password manager, HSM, sealed envelope in a safe). +- The password is never stored — only a salt and encrypted MEK are persisted. +- Argon2id parameters (time=3, memory=128 MiB, threads=4) are stored in the database at init time. + +--- + +## Daily Operations + +### Starting the Service + +```bash +# systemd +sudo systemctl start metacrypt + +# Docker +docker compose -f deploy/docker/docker-compose.yml up -d + +# Manual +metacrypt server --config /etc/metacrypt/metacrypt.toml +``` + +The service starts **sealed**. It must be unsealed before it can serve requests. + +### Unsealing + +After every restart, the service must be unsealed: + +```bash +# Via API +curl -sk -X POST https://localhost:8443/v1/unseal \ + -H 'Content-Type: application/json' \ + -d '{"password":""}' + +# Via web UI +# Navigate to https://:8443/unseal +``` + +**Rate limiting**: After 5 failed unseal attempts within one minute, a 60-second lockout is enforced. + +### Checking Status + +```bash +# Remote check +metacrypt status --addr https://metacrypt.example.com:8443 --ca-cert /path/to/ca.crt + +# Via API +curl -sk https://localhost:8443/v1/status +# Returns: {"state":"sealed"} or {"state":"unsealed"} etc. +``` + +### Sealing (Emergency) + +An admin user can seal the service at any time, which zeroizes all key material in memory: + +```bash +curl -sk -X POST https://localhost:8443/v1/seal \ + -H "Authorization: Bearer " +``` + +This immediately makes all cryptographic operations unavailable. Use this if you suspect a compromise. + +### Authentication + +Metacrypt proxies authentication to MCIAS. Users log in with their MCIAS credentials: + +```bash +# API login +curl -sk -X POST https://localhost:8443/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"..."}' +# Returns: {"token":"...","expires_at":"..."} + +# Use the token for subsequent requests +curl -sk https://localhost:8443/v1/auth/tokeninfo \ + -H "Authorization: Bearer " +``` + +Users with the MCIAS `admin` role automatically get admin privileges in Metacrypt. + +--- + +## Backup and Restore + +### Creating Backups + +```bash +# CLI +metacrypt snapshot --config /etc/metacrypt/metacrypt.toml --output /var/lib/metacrypt/backups/metacrypt-$(date +%Y%m%d).db + +# Using the backup script (with 30-day retention) +deploy/scripts/backup.sh 30 +``` + +The backup is a consistent SQLite snapshot created with `VACUUM INTO`. The backup file contains the same encrypted data as the live database — the seal password is still required to access it. + +### Automated Backups (systemd) + +```bash +sudo systemctl enable --now metacrypt-backup.timer +``` + +This runs a backup daily at 02:00 with up to 5 minutes of jitter. + +### Restoring from Backup + +1. Stop the service: `systemctl stop metacrypt` +2. Replace the database: `cp /var/lib/metacrypt/backups/metacrypt-20260314.db /var/lib/metacrypt/metacrypt.db` +3. Fix permissions: `chown metacrypt:metacrypt /var/lib/metacrypt/metacrypt.db && chmod 0600 /var/lib/metacrypt/metacrypt.db` +4. Start the service: `systemctl start metacrypt` +5. Unseal with the original seal password + +**The seal password does not change between backups.** A backup restored from any point in time uses the same seal password that was set during `metacrypt init`. + +--- + +## Monitoring + +### Health Check + +```bash +curl -sk https://localhost:8443/v1/status +``` + +Returns HTTP 200 in all states. Check the `state` field: + +- `unsealed` — healthy, fully operational +- `sealed` — needs unseal, no crypto operations available +- `uninitialized` — needs init + +### Log Output + +Metacrypt logs structured JSON to stdout. When running under systemd, logs go to the journal: + +```bash +# Follow logs +journalctl -u metacrypt -f + +# Recent errors +journalctl -u metacrypt --priority=err --since="1 hour ago" +``` + +### Key Metrics to Monitor + +| What | How | Alert When | +|---|---|---| +| Service state | `GET /v1/status` | `state != "unsealed"` for more than a few minutes after restart | +| TLS certificate expiry | External cert checker | < 30 days to expiry | +| Database file size | `stat /var/lib/metacrypt/metacrypt.db` | Unexpectedly large growth | +| Backup age | `find /var/lib/metacrypt/backups -name '*.db' -mtime +2` | No backup in 48 hours | +| MCIAS connectivity | Login attempt | Auth failures not caused by bad credentials | + +--- + +## Troubleshooting + +### Service won't start + +| Symptom | Cause | Fix | +|---|---|---| +| `config: server.tls_cert is required` | Missing or invalid config | Check config file path and contents | +| `db: create file: permission denied` | Wrong permissions on data dir | `chown -R metacrypt:metacrypt /var/lib/metacrypt` | +| `server: tls: failed to find any PEM data` | Bad cert/key files | Verify PEM format: `openssl x509 -in server.crt -text -noout` | + +### Unseal fails + +| Symptom | Cause | Fix | +|---|---|---| +| `invalid password` (401) | Wrong seal password | Verify password. There is no recovery if the password is lost. | +| `too many attempts` (429) | Rate limited | Wait 60 seconds, then try again | +| `not initialized` (412) | Database is empty/new | Run `metacrypt init` | + +### Authentication fails + +| Symptom | Cause | Fix | +|---|---|---| +| `invalid credentials` (401) | Bad username/password or MCIAS down | Verify MCIAS is reachable: `curl -sk https://mcias.metacircular.net:8443/v1/health` | +| `sealed` (503) | Service not unsealed | Unseal the service first | +| Connection refused to MCIAS | Network/TLS issue | Check `mcias.server_url` and `mcias.ca_cert` in config | + +### Database Issues + +```bash +# Check database integrity +sqlite3 /var/lib/metacrypt/metacrypt.db "PRAGMA integrity_check;" + +# Check WAL mode +sqlite3 /var/lib/metacrypt/metacrypt.db "PRAGMA journal_mode;" +# Should return: wal + +# Check file permissions +ls -la /var/lib/metacrypt/metacrypt.db +# Should be: -rw------- metacrypt metacrypt +``` + +--- + +## Security Considerations + +### Seal Password + +- The seal password is the single point of trust. Protect it accordingly. +- Use a strong, unique password (recommend 20+ characters or a passphrase). +- Store it in at least two independent secure locations. +- Rotate by re-initializing (requires data migration — not yet automated). + +### Key Material Lifecycle + +- **KWK** (Key-Wrapping Key): derived from password, used only during unseal, zeroized immediately after. +- **MEK** (Master Encryption Key): held in memory while unsealed, zeroized on seal. +- **DEKs** (Data Encryption Keys): per-engine, stored encrypted in the barrier, zeroized on seal. + +Sealing the service (`POST /v1/seal`) explicitly zeroizes all key material from process memory. + +### File Permissions + +| Path | Mode | Owner | +|---|---|---| +| `/etc/metacrypt/metacrypt.toml` | 0640 | metacrypt:metacrypt | +| `/etc/metacrypt/certs/server.key` | 0600 | metacrypt:metacrypt | +| `/var/lib/metacrypt/metacrypt.db` | 0600 | metacrypt:metacrypt | +| `/var/lib/metacrypt/backups/` | 0700 | metacrypt:metacrypt | + +### systemd Hardening + +The provided service unit applies: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`, `MemoryDenyWriteExecute`, and namespace restrictions. Only `/var/lib/metacrypt` is writable. + +### Docker Security + +The container runs as a non-root `metacrypt` user. The `/data` volume should be owned by the container's metacrypt UID (determined at build time). Do not run the container with `--privileged`. + +--- + +## Operational Procedures + +### Planned Restart + +1. Notify users that crypto operations will be briefly unavailable +2. `systemctl restart metacrypt` +3. Unseal the service +4. Verify: `metacrypt status --addr https://localhost:8443` + +### Password Rotation + +There is no online password rotation in Phase 1. To change the seal password: + +1. Create a backup: `metacrypt snapshot --output pre-rotation.db` +2. Stop the service +3. Re-initialize with a new database and password +4. Migrate data from the old barrier (requires custom tooling or a future `metacrypt rekey` command) + +### Disaster Recovery + +If the server is lost but you have a database backup and the seal password: + +1. Install Metacrypt on a new server (see Installation) +2. Copy the backup database to `/var/lib/metacrypt/metacrypt.db` +3. Fix ownership: `chown metacrypt:metacrypt /var/lib/metacrypt/metacrypt.db` +4. Start the service and unseal with the original password + +The database backup contains the encrypted MEK and all barrier data. No additional secrets beyond the seal password are needed for recovery. + +### Upgrading Metacrypt + +1. Build or download the new binary +2. Create a backup: `metacrypt snapshot --output pre-upgrade.db` +3. Replace the binary: `install -m 0755 metacrypt /usr/local/bin/metacrypt` +4. Restart: `systemctl restart metacrypt` +5. Unseal and verify + +Database migrations run automatically on startup. diff --git a/cmd/metacrypt/init.go b/cmd/metacrypt/init.go new file mode 100644 index 0000000..af6eb77 --- /dev/null +++ b/cmd/metacrypt/init.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "fmt" + "syscall" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/config" + "git.wntrmute.dev/kyle/metacrypt/internal/crypto" + "git.wntrmute.dev/kyle/metacrypt/internal/db" + "git.wntrmute.dev/kyle/metacrypt/internal/seal" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Interactive first-time setup", + Long: "Initialize Metacrypt with a seal password. This must be run before the server can be used.", + RunE: runInit, +} + +func init() { + rootCmd.AddCommand(initCmd) +} + +func runInit(cmd *cobra.Command, args []string) error { + configPath := cfgFile + if configPath == "" { + configPath = "metacrypt.toml" + } + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + database, err := db.Open(cfg.Database.Path) + if err != nil { + return err + } + defer database.Close() + + if err := db.Migrate(database); err != nil { + return err + } + + b := barrier.NewAESGCMBarrier(database) + sealMgr := seal.NewManager(database, b) + if err := sealMgr.CheckInitialized(); err != nil { + return err + } + if sealMgr.State() != seal.StateUninitialized { + return fmt.Errorf("already initialized") + } + + fmt.Print("Enter seal password: ") + pw1, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return fmt.Errorf("reading password: %w", err) + } + + fmt.Print("Confirm seal password: ") + pw2, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return fmt.Errorf("reading password: %w", err) + } + + if !crypto.ConstantTimeEqual(pw1, pw2) { + return fmt.Errorf("passwords do not match") + } + + params := crypto.Argon2Params{ + Time: cfg.Seal.Argon2Time, + Memory: cfg.Seal.Argon2Memory, + Threads: cfg.Seal.Argon2Threads, + } + + fmt.Println("Initializing (this may take a moment)...") + if err := sealMgr.Initialize(context.Background(), pw1, params); err != nil { + return err + } + + crypto.Zeroize(pw1) + crypto.Zeroize(pw2) + + fmt.Println("Metacrypt initialized and unsealed successfully.") + return nil +} diff --git a/cmd/metacrypt/main.go b/cmd/metacrypt/main.go new file mode 100644 index 0000000..2942c6c --- /dev/null +++ b/cmd/metacrypt/main.go @@ -0,0 +1,14 @@ +// Metacrypt is a cryptographic service for the Metacircular platform. +package main + +import ( + "fmt" + "os" +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/metacrypt/root.go b/cmd/metacrypt/root.go new file mode 100644 index 0000000..5782341 --- /dev/null +++ b/cmd/metacrypt/root.go @@ -0,0 +1,33 @@ +package main + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +var rootCmd = &cobra.Command{ + Use: "metacrypt", + Short: "Metacrypt cryptographic service", + Long: "Metacrypt is a cryptographic service for the Metacircular platform.", +} + +func init() { + cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default metacrypt.toml)") +} + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + viper.SetConfigName("metacrypt") + viper.SetConfigType("toml") + viper.AddConfigPath(".") + viper.AddConfigPath("/etc/metacrypt") + } + viper.AutomaticEnv() + viper.SetEnvPrefix("METACRYPT") + viper.ReadInConfig() +} diff --git a/cmd/metacrypt/server.go b/cmd/metacrypt/server.go new file mode 100644 index 0000000..f2539d7 --- /dev/null +++ b/cmd/metacrypt/server.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + mcias "git.wntrmute.dev/kyle/mcias/clients/go" + "github.com/spf13/cobra" + + "git.wntrmute.dev/kyle/metacrypt/internal/auth" + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/config" + "git.wntrmute.dev/kyle/metacrypt/internal/db" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" + "git.wntrmute.dev/kyle/metacrypt/internal/policy" + "git.wntrmute.dev/kyle/metacrypt/internal/seal" + "git.wntrmute.dev/kyle/metacrypt/internal/server" +) + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Start the Metacrypt server", + Long: "Start the Metacrypt HTTPS server. The service starts in sealed state.", + RunE: runServer, +} + +func init() { + rootCmd.AddCommand(serverCmd) +} + +func runServer(cmd *cobra.Command, args []string) error { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + configPath := cfgFile + if configPath == "" { + configPath = "metacrypt.toml" + } + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + database, err := db.Open(cfg.Database.Path) + if err != nil { + return err + } + defer database.Close() + + if err := db.Migrate(database); err != nil { + return err + } + + b := barrier.NewAESGCMBarrier(database) + sealMgr := seal.NewManager(database, b) + + if err := sealMgr.CheckInitialized(); err != nil { + return err + } + + mcClient, err := mcias.New(cfg.MCIAS.ServerURL, mcias.Options{ + CACertPath: cfg.MCIAS.CACert, + }) + if err != nil { + return err + } + + authenticator := auth.NewAuthenticator(mcClient) + policyEngine := policy.NewEngine(b) + engineRegistry := engine.NewRegistry(b) + + srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + if err := srv.Start(); err != nil { + logger.Error("server error", "error", err) + os.Exit(1) + } + }() + + <-ctx.Done() + logger.Info("shutting down") + return srv.Shutdown(context.Background()) +} diff --git a/cmd/metacrypt/snapshot.go b/cmd/metacrypt/snapshot.go new file mode 100644 index 0000000..e2130cd --- /dev/null +++ b/cmd/metacrypt/snapshot.go @@ -0,0 +1,58 @@ +package main + +import ( + "database/sql" + "fmt" + "os" + + "github.com/spf13/cobra" + + "git.wntrmute.dev/kyle/metacrypt/internal/config" + "git.wntrmute.dev/kyle/metacrypt/internal/db" +) + +var snapshotCmd = &cobra.Command{ + Use: "snapshot", + Short: "Create a database snapshot", + Long: "Create a backup of the Metacrypt database using SQLite's VACUUM INTO.", + RunE: runSnapshot, +} + +var snapshotOutput string + +func init() { + snapshotCmd.Flags().StringVarP(&snapshotOutput, "output", "o", "backup.db", "output file path") + rootCmd.AddCommand(snapshotCmd) +} + +func runSnapshot(cmd *cobra.Command, args []string) error { + configPath := cfgFile + if configPath == "" { + configPath = "metacrypt.toml" + } + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + database, err := db.Open(cfg.Database.Path) + if err != nil { + return err + } + defer database.Close() + + if err := sqliteBackup(database, snapshotOutput); err != nil { + return err + } + fmt.Printf("Snapshot saved to %s\n", snapshotOutput) + return nil +} + +func sqliteBackup(srcDB *sql.DB, dstPath string) error { + _, err := srcDB.Exec("VACUUM INTO ?", dstPath) + if err != nil { + return fmt.Errorf("snapshot: %w", err) + } + return os.Chmod(dstPath, 0600) +} diff --git a/cmd/metacrypt/status.go b/cmd/metacrypt/status.go new file mode 100644 index 0000000..5a3cabd --- /dev/null +++ b/cmd/metacrypt/status.go @@ -0,0 +1,66 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Check service seal state", + Long: "Query a running Metacrypt server for its current seal state.", + RunE: runStatus, +} + +var ( + statusAddr string + statusCACert string +) + +func init() { + statusCmd.Flags().StringVar(&statusAddr, "addr", "", "server address (e.g., https://localhost:8443)") + statusCmd.Flags().StringVar(&statusCACert, "ca-cert", "", "path to CA certificate for TLS verification") + statusCmd.MarkFlagRequired("addr") + rootCmd.AddCommand(statusCmd) +} + +func runStatus(cmd *cobra.Command, args []string) error { + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + + if statusCACert != "" { + pem, err := os.ReadFile(statusCACert) + if err != nil { + return fmt.Errorf("read CA cert: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return fmt.Errorf("no valid certs in CA file") + } + tlsCfg.RootCAs = pool + } + + client := &http.Client{ + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + } + + resp, err := client.Get(statusAddr + "/v1/status") + if err != nil { + return err + } + defer resp.Body.Close() + + var status struct { + State string `json:"state"` + } + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return fmt.Errorf("decode response: %w", err) + } + fmt.Printf("State: %s\n", status.State) + return nil +} diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..e3a098e --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,24 @@ +services: + metacrypt: + build: + context: ../.. + dockerfile: Dockerfile + container_name: metacrypt + restart: unless-stopped + ports: + - "8443:8443" + volumes: + - metacrypt-data:/data + # To populate /data before first run, use an init container or + # bind-mount a host directory instead of a named volume: + # volumes: + # - ./data:/data + healthcheck: + test: ["CMD", "metacrypt", "status", "--addr", "https://localhost:8443", "--ca-cert", "/data/certs/ca.crt"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + metacrypt-data: diff --git a/deploy/examples/metacrypt-docker.toml b/deploy/examples/metacrypt-docker.toml new file mode 100644 index 0000000..06e2014 --- /dev/null +++ b/deploy/examples/metacrypt-docker.toml @@ -0,0 +1,22 @@ +# Metacrypt configuration for Docker deployment. +# Place this file at /data/metacrypt.toml inside the container volume. + +[server] +listen_addr = ":8443" +tls_cert = "/data/certs/server.crt" +tls_key = "/data/certs/server.key" + +[database] +path = "/data/metacrypt.db" + +[mcias] +server_url = "https://mcias.metacircular.net:8443" +# ca_cert = "/data/certs/mcias-ca.crt" + +[seal] +# argon2_time = 3 +# argon2_memory = 131072 +# argon2_threads = 4 + +[log] +level = "info" diff --git a/deploy/examples/metacrypt.toml b/deploy/examples/metacrypt.toml new file mode 100644 index 0000000..2e07e51 --- /dev/null +++ b/deploy/examples/metacrypt.toml @@ -0,0 +1,38 @@ +# Metacrypt production configuration +# Copy to /etc/metacrypt/metacrypt.toml and adjust for your environment. + +[server] +# Address to listen on. Use "0.0.0.0:8443" to listen on all interfaces. +listen_addr = ":8443" + +# TLS certificate and key. Metacrypt always terminates TLS. +tls_cert = "/etc/metacrypt/certs/server.crt" +tls_key = "/etc/metacrypt/certs/server.key" + +[database] +# SQLite database path. Created automatically on first run. +# The directory must be writable by the metacrypt user. +path = "/var/lib/metacrypt/metacrypt.db" + +[mcias] +# MCIAS server URL for authentication. +server_url = "https://mcias.metacircular.net:8443" + +# CA certificate for verifying the MCIAS server's TLS certificate. +# Omit if MCIAS uses a publicly trusted certificate. +# ca_cert = "/etc/metacrypt/certs/mcias-ca.crt" + +[seal] +# Argon2id parameters for key derivation. +# These are applied during initialization and stored alongside the encrypted +# master key. Changing them here after init has no effect. +# +# Defaults are tuned for server hardware (3 iterations, 128 MiB, 4 threads). +# Increase argon2_memory on machines with more RAM for stronger protection. +# argon2_time = 3 +# argon2_memory = 131072 # KiB (128 MiB) +# argon2_threads = 4 + +[log] +# Log level: debug, info, warn, error +level = "info" diff --git a/deploy/scripts/backup.sh b/deploy/scripts/backup.sh new file mode 100755 index 0000000..6cb114b --- /dev/null +++ b/deploy/scripts/backup.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# +# Create a timestamped Metacrypt database backup and prune old ones. +# +# Usage: ./backup.sh [retention_days] +# retention_days: number of days to keep backups (default: 30) +# +set -euo pipefail + +CONFIG="${METACRYPT_CONFIG:-/etc/metacrypt/metacrypt.toml}" +BACKUP_DIR="${METACRYPT_BACKUP_DIR:-/var/lib/metacrypt/backups}" +RETENTION_DAYS="${1:-30}" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/metacrypt-${TIMESTAMP}.db" + +echo "==> Creating backup: ${BACKUP_FILE}" +metacrypt snapshot --config "$CONFIG" --output "$BACKUP_FILE" + +echo "==> Pruning backups older than ${RETENTION_DAYS} days" +find "$BACKUP_DIR" -name 'metacrypt-*.db' -mtime "+${RETENTION_DAYS}" -delete -print + +echo "==> Done" +ls -lh "$BACKUP_DIR"/metacrypt-*.db 2>/dev/null | tail -5 diff --git a/deploy/scripts/install.sh b/deploy/scripts/install.sh new file mode 100755 index 0000000..950afed --- /dev/null +++ b/deploy/scripts/install.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Install Metacrypt on a systemd-based Linux system. +# +# Usage: sudo ./install.sh /path/to/metacrypt +# +set -euo pipefail + +BINARY="${1:?Usage: $0 /path/to/metacrypt}" +INSTALL_DIR="/usr/local/bin" +CONFIG_DIR="/etc/metacrypt" +DATA_DIR="/var/lib/metacrypt" +BACKUP_DIR="${DATA_DIR}/backups" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DEPLOY_DIR="$(dirname "$SCRIPT_DIR")" + +echo "==> Creating metacrypt user and group" +if ! getent group metacrypt >/dev/null 2>&1; then + groupadd --system metacrypt +fi +if ! getent passwd metacrypt >/dev/null 2>&1; then + useradd --system --gid metacrypt --home-dir "$DATA_DIR" --shell /usr/sbin/nologin metacrypt +fi + +echo "==> Installing binary" +install -m 0755 "$BINARY" "$INSTALL_DIR/metacrypt" + +echo "==> Creating directories" +install -d -m 0750 -o metacrypt -g metacrypt "$CONFIG_DIR" +install -d -m 0750 -o metacrypt -g metacrypt "$CONFIG_DIR/certs" +install -d -m 0700 -o metacrypt -g metacrypt "$DATA_DIR" +install -d -m 0700 -o metacrypt -g metacrypt "$BACKUP_DIR" + +echo "==> Installing configuration" +if [ ! -f "$CONFIG_DIR/metacrypt.toml" ]; then + install -m 0640 -o metacrypt -g metacrypt "$DEPLOY_DIR/examples/metacrypt.toml" "$CONFIG_DIR/metacrypt.toml" + echo " Installed default config to $CONFIG_DIR/metacrypt.toml" + echo " >>> Edit this file before starting the service <<<" +else + echo " Config already exists at $CONFIG_DIR/metacrypt.toml — skipping" +fi + +echo "==> Installing systemd units" +install -m 0644 "$DEPLOY_DIR/systemd/metacrypt.service" /etc/systemd/system/ +install -m 0644 "$DEPLOY_DIR/systemd/metacrypt-backup.service" /etc/systemd/system/ +install -m 0644 "$DEPLOY_DIR/systemd/metacrypt-backup.timer" /etc/systemd/system/ +systemctl daemon-reload + +echo "==> Done" +echo "" +echo "Next steps:" +echo " 1. Place TLS cert and key in $CONFIG_DIR/certs/" +echo " 2. Edit $CONFIG_DIR/metacrypt.toml" +echo " 3. Initialize: metacrypt init --config $CONFIG_DIR/metacrypt.toml" +echo " 4. Start: systemctl enable --now metacrypt" +echo " 5. Backups: systemctl enable --now metacrypt-backup.timer" diff --git a/deploy/systemd/metacrypt-backup.service b/deploy/systemd/metacrypt-backup.service new file mode 100644 index 0000000..e830922 --- /dev/null +++ b/deploy/systemd/metacrypt-backup.service @@ -0,0 +1,15 @@ +[Unit] +Description=Metacrypt database backup +After=metacrypt.service + +[Service] +Type=oneshot +User=metacrypt +Group=metacrypt +ExecStart=/usr/local/bin/metacrypt snapshot --config /etc/metacrypt/metacrypt.toml --output /var/lib/metacrypt/backups/metacrypt-%i.db + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/lib/metacrypt diff --git a/deploy/systemd/metacrypt-backup.timer b/deploy/systemd/metacrypt-backup.timer new file mode 100644 index 0000000..9c69252 --- /dev/null +++ b/deploy/systemd/metacrypt-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily Metacrypt database backup + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/metacrypt.service b/deploy/systemd/metacrypt.service new file mode 100644 index 0000000..aeb242d --- /dev/null +++ b/deploy/systemd/metacrypt.service @@ -0,0 +1,45 @@ +[Unit] +Description=Metacrypt cryptographic service +Documentation=https://git.wntrmute.dev/kyle/metacrypt +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=metacrypt +Group=metacrypt + +ExecStart=/usr/local/bin/metacrypt server --config /etc/metacrypt/metacrypt.toml +ExecReload=/bin/kill -HUP $MAINPID + +Restart=on-failure +RestartSec=5 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictRealtime=true + +# Allow write access to the database directory and log +ReadWritePaths=/var/lib/metacrypt + +# Limit file descriptor count +LimitNOFILE=65535 + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=metacrypt + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6fbf891 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module git.wntrmute.dev/kyle/metacrypt + +go 1.25.0 + +replace git.wntrmute.dev/kyle/mcias/clients/go => /Users/kyle/src/mcias/clients/go + +require ( + git.wntrmute.dev/kyle/mcias/clients/go v0.0.0-00010101000000-000000000000 + github.com/pelletier/go-toml/v2 v2.2.4 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.49.0 + golang.org/x/term v0.41.0 + modernc.org/sqlite v1.46.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5d6324d --- /dev/null +++ b/go.sum @@ -0,0 +1,109 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..f71e25a --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,125 @@ +// Package auth provides MCIAS authentication integration with token caching. +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "sync" + "time" + + mcias "git.wntrmute.dev/kyle/mcias/clients/go" +) + +var ( + ErrInvalidCredentials = errors.New("auth: invalid credentials") + ErrInvalidToken = errors.New("auth: invalid token") +) + +const tokenCacheTTL = 30 * time.Second + +// TokenInfo holds validated token information. +type TokenInfo struct { + Username string + Roles []string + IsAdmin bool +} + +// cachedClaims holds a cached token validation result. +type cachedClaims struct { + info *TokenInfo + expiresAt time.Time +} + +// Authenticator provides MCIAS-backed authentication. +type Authenticator struct { + client *mcias.Client + + mu sync.RWMutex + cache map[string]*cachedClaims // keyed by SHA-256(token) +} + +// NewAuthenticator creates a new authenticator with the given MCIAS client. +func NewAuthenticator(client *mcias.Client) *Authenticator { + return &Authenticator{ + client: client, + cache: make(map[string]*cachedClaims), + } +} + +// Login authenticates a user via MCIAS and returns the token. +func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt string, err error) { + tok, exp, err := a.client.Login(username, password, totpCode) + if err != nil { + var authErr *mcias.MciasAuthError + if errors.As(err, &authErr) { + return "", "", ErrInvalidCredentials + } + return "", "", err + } + return tok, exp, nil +} + +// ValidateToken validates a bearer token, using a short-lived cache. +func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) { + key := tokenHash(token) + + // Check cache. + a.mu.RLock() + cached, ok := a.cache[key] + a.mu.RUnlock() + if ok && time.Now().Before(cached.expiresAt) { + return cached.info, nil + } + + // Validate with MCIAS. + claims, err := a.client.ValidateToken(token) + if err != nil { + return nil, err + } + if !claims.Valid { + return nil, ErrInvalidToken + } + + info := &TokenInfo{ + Username: claims.Sub, + Roles: claims.Roles, + IsAdmin: hasAdminRole(claims.Roles), + } + + // Cache the result. + a.mu.Lock() + a.cache[key] = &cachedClaims{ + info: info, + expiresAt: time.Now().Add(tokenCacheTTL), + } + a.mu.Unlock() + + return info, nil +} + +// Logout invalidates a token via MCIAS. The client must have the token set. +func (a *Authenticator) Logout(client *mcias.Client) error { + return client.Logout() +} + +// ClearCache removes all cached token validations. +func (a *Authenticator) ClearCache() { + a.mu.Lock() + a.cache = make(map[string]*cachedClaims) + a.mu.Unlock() +} + +func tokenHash(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +func hasAdminRole(roles []string) bool { + for _, r := range roles { + if r == "admin" { + return true + } + } + return false +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..4de6230 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "testing" +) + +func TestTokenHash(t *testing.T) { + h1 := tokenHash("token-abc") + h2 := tokenHash("token-abc") + h3 := tokenHash("token-def") + + if h1 != h2 { + t.Error("same input should produce same hash") + } + if h1 == h3 { + t.Error("different inputs should produce different hashes") + } + if len(h1) != 64 { // SHA-256 hex + t.Errorf("hash length: got %d, want 64", len(h1)) + } +} + +func TestHasAdminRole(t *testing.T) { + if !hasAdminRole([]string{"user", "admin"}) { + t.Error("should detect admin role") + } + if hasAdminRole([]string{"user", "operator"}) { + t.Error("should not detect admin role when absent") + } + if hasAdminRole(nil) { + t.Error("nil roles should not be admin") + } +} + +func TestNewAuthenticator(t *testing.T) { + a := NewAuthenticator(nil) + if a == nil { + t.Fatal("NewAuthenticator returned nil") + } + if a.cache == nil { + t.Error("cache should be initialized") + } +} + +func TestClearCache(t *testing.T) { + a := NewAuthenticator(nil) + a.cache["test"] = &cachedClaims{info: &TokenInfo{Username: "test"}} + a.ClearCache() + if len(a.cache) != 0 { + t.Error("cache should be empty after clear") + } +} diff --git a/internal/barrier/barrier.go b/internal/barrier/barrier.go new file mode 100644 index 0000000..76bfec3 --- /dev/null +++ b/internal/barrier/barrier.go @@ -0,0 +1,167 @@ +// Package barrier provides an encrypted storage barrier backed by SQLite. +package barrier + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "sync" + + "git.wntrmute.dev/kyle/metacrypt/internal/crypto" +) + +var ( + ErrSealed = errors.New("barrier: sealed") + ErrNotFound = errors.New("barrier: entry not found") +) + +// Barrier is the encrypted storage barrier interface. +type Barrier interface { + // Unseal opens the barrier with the given master encryption key. + Unseal(mek []byte) error + // Seal closes the barrier and zeroizes the key material. + Seal() error + // IsSealed returns true if the barrier is sealed. + IsSealed() bool + + // Get retrieves and decrypts a value by path. + Get(ctx context.Context, path string) ([]byte, error) + // Put encrypts and stores a value at the given path. + Put(ctx context.Context, path string, value []byte) error + // Delete removes an entry by path. + Delete(ctx context.Context, path string) error + // List returns paths with the given prefix. + List(ctx context.Context, prefix string) ([]string, error) +} + +// AESGCMBarrier implements Barrier using AES-256-GCM encryption. +type AESGCMBarrier struct { + db *sql.DB + mu sync.RWMutex + mek []byte // nil when sealed +} + +// NewAESGCMBarrier creates a new AES-GCM barrier backed by the given database. +func NewAESGCMBarrier(db *sql.DB) *AESGCMBarrier { + return &AESGCMBarrier{db: db} +} + +func (b *AESGCMBarrier) Unseal(mek []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + k := make([]byte, len(mek)) + copy(k, mek) + b.mek = k + return nil +} + +func (b *AESGCMBarrier) Seal() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.mek != nil { + crypto.Zeroize(b.mek) + b.mek = nil + } + return nil +} + +func (b *AESGCMBarrier) IsSealed() bool { + b.mu.RLock() + defer b.mu.RUnlock() + return b.mek == nil +} + +func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) { + b.mu.RLock() + mek := b.mek + b.mu.RUnlock() + if mek == nil { + return nil, ErrSealed + } + + var encrypted []byte + err := b.db.QueryRowContext(ctx, + "SELECT value FROM barrier_entries WHERE path = ?", path).Scan(&encrypted) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("barrier: get %q: %w", path, err) + } + + plaintext, err := crypto.Decrypt(mek, encrypted) + if err != nil { + return nil, fmt.Errorf("barrier: decrypt %q: %w", path, err) + } + return plaintext, nil +} + +func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) error { + b.mu.RLock() + mek := b.mek + b.mu.RUnlock() + if mek == nil { + return ErrSealed + } + + encrypted, err := crypto.Encrypt(mek, value) + if err != nil { + return fmt.Errorf("barrier: encrypt %q: %w", path, err) + } + + _, err = b.db.ExecContext(ctx, ` + INSERT INTO barrier_entries (path, value) VALUES (?, ?) + ON CONFLICT(path) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + path, encrypted) + if err != nil { + return fmt.Errorf("barrier: put %q: %w", path, err) + } + return nil +} + +func (b *AESGCMBarrier) Delete(ctx context.Context, path string) error { + b.mu.RLock() + mek := b.mek + b.mu.RUnlock() + if mek == nil { + return ErrSealed + } + + _, err := b.db.ExecContext(ctx, + "DELETE FROM barrier_entries WHERE path = ?", path) + if err != nil { + return fmt.Errorf("barrier: delete %q: %w", path, err) + } + return nil +} + +func (b *AESGCMBarrier) List(ctx context.Context, prefix string) ([]string, error) { + b.mu.RLock() + mek := b.mek + b.mu.RUnlock() + if mek == nil { + return nil, ErrSealed + } + + rows, err := b.db.QueryContext(ctx, + "SELECT path FROM barrier_entries WHERE path LIKE ?", + prefix+"%") + if err != nil { + return nil, fmt.Errorf("barrier: list %q: %w", prefix, err) + } + defer rows.Close() + + var paths []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, fmt.Errorf("barrier: list scan: %w", err) + } + // Strip the prefix and return just the next segment. + remainder := strings.TrimPrefix(p, prefix) + paths = append(paths, remainder) + } + return paths, rows.Err() +} diff --git a/internal/barrier/barrier_test.go b/internal/barrier/barrier_test.go new file mode 100644 index 0000000..7bc40f0 --- /dev/null +++ b/internal/barrier/barrier_test.go @@ -0,0 +1,159 @@ +package barrier + +import ( + "context" + "path/filepath" + "testing" + + "git.wntrmute.dev/kyle/metacrypt/internal/crypto" + "git.wntrmute.dev/kyle/metacrypt/internal/db" +) + +func setupBarrier(t *testing.T) (*AESGCMBarrier, func()) { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("migrate: %v", err) + } + b := NewAESGCMBarrier(database) + return b, func() { database.Close() } +} + +func TestBarrierSealUnseal(t *testing.T) { + b, cleanup := setupBarrier(t) + defer cleanup() + + if !b.IsSealed() { + t.Fatal("new barrier should be sealed") + } + + mek, _ := crypto.GenerateKey() + if err := b.Unseal(mek); err != nil { + t.Fatalf("Unseal: %v", err) + } + if b.IsSealed() { + t.Fatal("barrier should be unsealed") + } + + if err := b.Seal(); err != nil { + t.Fatalf("Seal: %v", err) + } + if !b.IsSealed() { + t.Fatal("barrier should be sealed after Seal()") + } +} + +func TestBarrierPutGet(t *testing.T) { + b, cleanup := setupBarrier(t) + defer cleanup() + ctx := context.Background() + + mek, _ := crypto.GenerateKey() + b.Unseal(mek) + + data := []byte("test value") + if err := b.Put(ctx, "test/path", data); err != nil { + t.Fatalf("Put: %v", err) + } + + got, err := b.Get(ctx, "test/path") + if err != nil { + t.Fatalf("Get: %v", err) + } + if string(got) != string(data) { + t.Fatalf("Get: got %q, want %q", got, data) + } +} + +func TestBarrierGetNotFound(t *testing.T) { + b, cleanup := setupBarrier(t) + defer cleanup() + ctx := context.Background() + + mek, _ := crypto.GenerateKey() + b.Unseal(mek) + + _, err := b.Get(ctx, "nonexistent") + if err != ErrNotFound { + t.Fatalf("expected ErrNotFound, got: %v", err) + } +} + +func TestBarrierDelete(t *testing.T) { + b, cleanup := setupBarrier(t) + defer cleanup() + ctx := context.Background() + + mek, _ := crypto.GenerateKey() + b.Unseal(mek) + + b.Put(ctx, "test/delete-me", []byte("data")) + if err := b.Delete(ctx, "test/delete-me"); err != nil { + t.Fatalf("Delete: %v", err) + } + _, err := b.Get(ctx, "test/delete-me") + if err != ErrNotFound { + t.Fatalf("expected ErrNotFound after delete, got: %v", err) + } +} + +func TestBarrierList(t *testing.T) { + b, cleanup := setupBarrier(t) + defer cleanup() + ctx := context.Background() + + mek, _ := crypto.GenerateKey() + b.Unseal(mek) + + b.Put(ctx, "engine/ca/default/config", []byte("cfg")) + b.Put(ctx, "engine/ca/default/dek", []byte("key")) + b.Put(ctx, "engine/transit/main/config", []byte("cfg")) + + paths, err := b.List(ctx, "engine/ca/") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(paths) != 2 { + t.Fatalf("List: got %d paths, want 2", len(paths)) + } +} + +func TestBarrierSealedOperations(t *testing.T) { + b, cleanup := setupBarrier(t) + defer cleanup() + ctx := context.Background() + + if _, err := b.Get(ctx, "test"); err != ErrSealed { + t.Fatalf("Get when sealed: expected ErrSealed, got: %v", err) + } + if err := b.Put(ctx, "test", []byte("data")); err != ErrSealed { + t.Fatalf("Put when sealed: expected ErrSealed, got: %v", err) + } + if err := b.Delete(ctx, "test"); err != ErrSealed { + t.Fatalf("Delete when sealed: expected ErrSealed, got: %v", err) + } + if _, err := b.List(ctx, "test"); err != ErrSealed { + t.Fatalf("List when sealed: expected ErrSealed, got: %v", err) + } +} + +func TestBarrierOverwrite(t *testing.T) { + b, cleanup := setupBarrier(t) + defer cleanup() + ctx := context.Background() + + mek, _ := crypto.GenerateKey() + b.Unseal(mek) + + b.Put(ctx, "test/overwrite", []byte("v1")) + b.Put(ctx, "test/overwrite", []byte("v2")) + + got, _ := b.Get(ctx, "test/overwrite") + if string(got) != "v2" { + t.Fatalf("overwrite: got %q, want %q", got, "v2") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c24e612 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,101 @@ +// Package config provides TOML configuration loading and validation. +package config + +import ( + "fmt" + "os" + + "github.com/pelletier/go-toml/v2" +) + +// Config is the top-level configuration for Metacrypt. +type Config struct { + Server ServerConfig `toml:"server"` + Database DatabaseConfig `toml:"database"` + MCIAS MCIASConfig `toml:"mcias"` + Seal SealConfig `toml:"seal"` + Log LogConfig `toml:"log"` +} + +// ServerConfig holds HTTP/gRPC server settings. +type ServerConfig struct { + ListenAddr string `toml:"listen_addr"` + GRPCAddr string `toml:"grpc_addr"` + TLSCert string `toml:"tls_cert"` + TLSKey string `toml:"tls_key"` +} + +// DatabaseConfig holds SQLite database settings. +type DatabaseConfig struct { + Path string `toml:"path"` +} + +// MCIASConfig holds MCIAS integration settings. +type MCIASConfig struct { + ServerURL string `toml:"server_url"` + CACert string `toml:"ca_cert"` +} + +// SealConfig holds Argon2id parameters for the seal process. +type SealConfig struct { + Argon2Time uint32 `toml:"argon2_time"` + Argon2Memory uint32 `toml:"argon2_memory"` + Argon2Threads uint8 `toml:"argon2_threads"` +} + +// LogConfig holds logging settings. +type LogConfig struct { + Level string `toml:"level"` +} + +// Load reads and parses a TOML config file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("config: read file: %w", err) + } + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("config: parse: %w", err) + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} + +// Validate checks required fields and applies defaults. +func (c *Config) Validate() error { + if c.Server.ListenAddr == "" { + return fmt.Errorf("config: server.listen_addr is required") + } + if c.Server.TLSCert == "" { + return fmt.Errorf("config: server.tls_cert is required") + } + if c.Server.TLSKey == "" { + return fmt.Errorf("config: server.tls_key is required") + } + if c.Database.Path == "" { + return fmt.Errorf("config: database.path is required") + } + if c.MCIAS.ServerURL == "" { + return fmt.Errorf("config: mcias.server_url is required") + } + + // Apply defaults for seal parameters. + if c.Seal.Argon2Time == 0 { + c.Seal.Argon2Time = 3 + } + if c.Seal.Argon2Memory == 0 { + c.Seal.Argon2Memory = 128 * 1024 + } + if c.Seal.Argon2Threads == 0 { + c.Seal.Argon2Threads = 4 + } + + if c.Log.Level == "" { + c.Log.Level = "info" + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..707b33e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadValid(t *testing.T) { + content := ` +[server] +listen_addr = ":8443" +tls_cert = "cert.pem" +tls_key = "key.pem" + +[database] +path = "test.db" + +[mcias] +server_url = "https://mcias.example.com" +` + dir := t.TempDir() + path := filepath.Join(dir, "test.toml") + os.WriteFile(path, []byte(content), 0600) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.ListenAddr != ":8443" { + t.Errorf("ListenAddr: got %q", cfg.Server.ListenAddr) + } + if cfg.Seal.Argon2Time != 3 { + t.Errorf("Argon2Time default: got %d, want 3", cfg.Seal.Argon2Time) + } + if cfg.Seal.Argon2Memory != 128*1024 { + t.Errorf("Argon2Memory default: got %d", cfg.Seal.Argon2Memory) + } + if cfg.Log.Level != "info" { + t.Errorf("Log.Level default: got %q", cfg.Log.Level) + } +} + +func TestLoadMissingRequired(t *testing.T) { + content := ` +[server] +listen_addr = ":8443" +` + dir := t.TempDir() + path := filepath.Join(dir, "test.toml") + os.WriteFile(path, []byte(content), 0600) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for missing required fields") + } +} + +func TestLoadMissingFile(t *testing.T) { + _, err := Load("/nonexistent/path.toml") + if err == nil { + t.Fatal("expected error for missing file") + } +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..a2ee0d8 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,137 @@ +// Package crypto provides Argon2id KDF, AES-256-GCM encryption, and key helpers. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/subtle" + "errors" + "fmt" + + "golang.org/x/crypto/argon2" +) + +const ( + // KeySize is the size of AES-256 keys in bytes. + KeySize = 32 + // NonceSize is the size of AES-GCM nonces in bytes. + NonceSize = 12 + // SaltSize is the size of Argon2id salts in bytes. + SaltSize = 32 + + // BarrierVersion is the version byte prefix for encrypted barrier entries. + BarrierVersion byte = 0x01 + + // Default Argon2id parameters. + DefaultArgon2Time = 3 + DefaultArgon2Memory = 128 * 1024 // 128 MiB in KiB + DefaultArgon2Threads = 4 +) + +var ( + ErrInvalidCiphertext = errors.New("crypto: invalid ciphertext") + ErrDecryptionFailed = errors.New("crypto: decryption failed") +) + +// Argon2Params holds Argon2id KDF parameters. +type Argon2Params struct { + Time uint32 + Memory uint32 // in KiB + Threads uint8 +} + +// DefaultArgon2Params returns the default Argon2id parameters. +func DefaultArgon2Params() Argon2Params { + return Argon2Params{ + Time: DefaultArgon2Time, + Memory: DefaultArgon2Memory, + Threads: DefaultArgon2Threads, + } +} + +// DeriveKey derives a 256-bit key from password and salt using Argon2id. +func DeriveKey(password []byte, salt []byte, params Argon2Params) []byte { + return argon2.IDKey(password, salt, params.Time, params.Memory, params.Threads, KeySize) +} + +// GenerateKey generates a random 256-bit key. +func GenerateKey() ([]byte, error) { + key := make([]byte, KeySize) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("crypto: generate key: %w", err) + } + return key, nil +} + +// GenerateSalt generates a random salt for Argon2id. +func GenerateSalt() ([]byte, error) { + salt := make([]byte, SaltSize) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("crypto: generate salt: %w", err) + } + return salt, nil +} + +// Encrypt encrypts plaintext with AES-256-GCM using the given key. +// Returns: [version byte][12-byte nonce][ciphertext+tag] +func Encrypt(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: new cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: new gcm: %w", err) + } + nonce := make([]byte, NonceSize) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("crypto: generate nonce: %w", err) + } + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + + // Format: [version][nonce][ciphertext+tag] + result := make([]byte, 1+NonceSize+len(ciphertext)) + result[0] = BarrierVersion + copy(result[1:1+NonceSize], nonce) + copy(result[1+NonceSize:], ciphertext) + return result, nil +} + +// Decrypt decrypts ciphertext produced by Encrypt. +func Decrypt(key, data []byte) ([]byte, error) { + if len(data) < 1+NonceSize+aes.BlockSize { + return nil, ErrInvalidCiphertext + } + if data[0] != BarrierVersion { + return nil, fmt.Errorf("crypto: unsupported version: %d", data[0]) + } + nonce := data[1 : 1+NonceSize] + ciphertext := data[1+NonceSize:] + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: new cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: new gcm: %w", err) + } + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, ErrDecryptionFailed + } + return plaintext, nil +} + +// Zeroize overwrites a byte slice with zeros. +func Zeroize(b []byte) { + for i := range b { + b[i] = 0 + } +} + +// ConstantTimeEqual compares two byte slices in constant time. +func ConstantTimeEqual(a, b []byte) bool { + return subtle.ConstantTimeCompare(a, b) == 1 +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..e3c4cb1 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,132 @@ +package crypto + +import ( + "bytes" + "testing" +) + +func TestGenerateKey(t *testing.T) { + key, err := GenerateKey() + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + if len(key) != KeySize { + t.Fatalf("key length: got %d, want %d", len(key), KeySize) + } + // Should be random (not all zeros). + if bytes.Equal(key, make([]byte, KeySize)) { + t.Fatal("key is all zeros") + } +} + +func TestGenerateSalt(t *testing.T) { + salt, err := GenerateSalt() + if err != nil { + t.Fatalf("GenerateSalt: %v", err) + } + if len(salt) != SaltSize { + t.Fatalf("salt length: got %d, want %d", len(salt), SaltSize) + } +} + +func TestEncryptDecrypt(t *testing.T) { + key, _ := GenerateKey() + plaintext := []byte("hello, metacrypt!") + + ciphertext, err := Encrypt(key, plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Version byte should be present. + if ciphertext[0] != BarrierVersion { + t.Fatalf("version byte: got %d, want %d", ciphertext[0], BarrierVersion) + } + + decrypted, err := Decrypt(key, ciphertext) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + + if !bytes.Equal(plaintext, decrypted) { + t.Fatalf("roundtrip failed: got %q, want %q", decrypted, plaintext) + } +} + +func TestDecryptWrongKey(t *testing.T) { + key1, _ := GenerateKey() + key2, _ := GenerateKey() + plaintext := []byte("secret data") + + ciphertext, _ := Encrypt(key1, plaintext) + _, err := Decrypt(key2, ciphertext) + if err != ErrDecryptionFailed { + t.Fatalf("expected ErrDecryptionFailed, got: %v", err) + } +} + +func TestDecryptInvalidCiphertext(t *testing.T) { + key, _ := GenerateKey() + _, err := Decrypt(key, []byte("short")) + if err != ErrInvalidCiphertext { + t.Fatalf("expected ErrInvalidCiphertext, got: %v", err) + } +} + +func TestDeriveKey(t *testing.T) { + password := []byte("test-password") + salt, _ := GenerateSalt() + params := Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} + + key := DeriveKey(password, salt, params) + if len(key) != KeySize { + t.Fatalf("derived key length: got %d, want %d", len(key), KeySize) + } + + // Same inputs should produce same output. + key2 := DeriveKey(password, salt, params) + if !bytes.Equal(key, key2) { + t.Fatal("determinism: same inputs produced different keys") + } + + // Different password should produce different output. + key3 := DeriveKey([]byte("different"), salt, params) + if bytes.Equal(key, key3) { + t.Fatal("different passwords produced same key") + } +} + +func TestZeroize(t *testing.T) { + data := []byte{1, 2, 3, 4, 5} + Zeroize(data) + for i, b := range data { + if b != 0 { + t.Fatalf("byte %d not zeroed: %d", i, b) + } + } +} + +func TestConstantTimeEqual(t *testing.T) { + a := []byte("hello") + b := []byte("hello") + c := []byte("world") + + if !ConstantTimeEqual(a, b) { + t.Fatal("equal slices reported as not equal") + } + if ConstantTimeEqual(a, c) { + t.Fatal("different slices reported as equal") + } +} + +func TestEncryptProducesDifferentCiphertext(t *testing.T) { + key, _ := GenerateKey() + plaintext := []byte("same data") + + ct1, _ := Encrypt(key, plaintext) + ct2, _ := Encrypt(key, plaintext) + + if bytes.Equal(ct1, ct2) { + t.Fatal("two encryptions of same plaintext produced identical ciphertext (nonce reuse)") + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..eda3f6e --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,43 @@ +// Package db provides SQLite database access and migrations. +package db + +import ( + "database/sql" + "fmt" + "os" + + _ "modernc.org/sqlite" +) + +// Open opens or creates a SQLite database at the given path with secure +// file permissions (0600) and WAL mode enabled. +func Open(path string) (*sql.DB, error) { + // Ensure the file has restrictive permissions if it doesn't exist yet. + if _, err := os.Stat(path); os.IsNotExist(err) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil, fmt.Errorf("db: create file: %w", err) + } + f.Close() + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("db: open: %w", err) + } + + // Enable WAL mode and foreign keys. + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA foreign_keys=ON", + "PRAGMA busy_timeout=5000", + } + for _, p := range pragmas { + if _, err := db.Exec(p); err != nil { + db.Close() + return nil, fmt.Errorf("db: pragma %q: %w", p, err) + } + } + + return db, nil +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..57819f8 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,44 @@ +package db + +import ( + "path/filepath" + "testing" +) + +func TestOpenAndMigrate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.db") + + database, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer database.Close() + + if err := Migrate(database); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // Verify tables exist. + tables := []string{"seal_config", "barrier_entries", "schema_migrations"} + for _, table := range tables { + var name string + err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name) + if err != nil { + t.Errorf("table %q not found: %v", table, err) + } + } + + // Migration should be idempotent. + if err := Migrate(database); err != nil { + t.Fatalf("second Migrate: %v", err) + } + + // Check migration version. + var version int + database.QueryRow("SELECT MAX(version) FROM schema_migrations").Scan(&version) + if version != 1 { + t.Errorf("migration version: got %d, want 1", version) + } +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..04043c0 --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,70 @@ +package db + +import ( + "database/sql" + "fmt" +) + +// migrations is an ordered list of SQL DDL statements. Each index is the +// migration version (1-based). +var migrations = []string{ + // Version 1: initial schema + `CREATE TABLE IF NOT EXISTS seal_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + encrypted_mek BLOB NOT NULL, + kdf_salt BLOB NOT NULL, + argon2_time INTEGER NOT NULL, + argon2_memory INTEGER NOT NULL, + argon2_threads INTEGER NOT NULL, + initialized_at DATETIME NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS barrier_entries ( + path TEXT PRIMARY KEY, + value BLOB NOT NULL, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + updated_at DATETIME NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT (datetime('now')) + );`, +} + +// Migrate applies all pending migrations. +func Migrate(db *sql.DB) error { + // Ensure the migrations table exists (bootstrap). + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("db: create migrations table: %w", err) + } + + var current int + row := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations") + if err := row.Scan(¤t); err != nil { + return fmt.Errorf("db: get migration version: %w", err) + } + + for i := current; i < len(migrations); i++ { + version := i + 1 + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("db: begin migration %d: %w", version, err) + } + if _, err := tx.Exec(migrations[i]); err != nil { + tx.Rollback() + return fmt.Errorf("db: migration %d: %w", version, err) + } + if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil { + tx.Rollback() + return fmt.Errorf("db: record migration %d: %w", version, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("db: commit migration %d: %w", version, err) + } + } + return nil +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..3ea40f5 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,179 @@ +// Package engine defines the Engine interface and mount registry. +// Phase 1: interface and registry only, no concrete implementations. +package engine + +import ( + "context" + "errors" + "fmt" + "sync" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" +) + +// EngineType identifies a cryptographic engine type. +type EngineType string + +const ( + EngineTypeCA EngineType = "ca" + EngineTypeSSHCA EngineType = "sshca" + EngineTypeTransit EngineType = "transit" + EngineTypeUser EngineType = "user" +) + +var ( + ErrMountExists = errors.New("engine: mount already exists") + ErrMountNotFound = errors.New("engine: mount not found") + ErrUnknownType = errors.New("engine: unknown engine type") +) + +// Request is a request to an engine. +type Request struct { + Operation string + Path string + Data map[string]interface{} +} + +// Response is a response from an engine. +type Response struct { + Data map[string]interface{} +} + +// Engine is the interface that all cryptographic engines must implement. +type Engine interface { + // Type returns the engine type. + Type() EngineType + // Initialize sets up the engine for first use. + Initialize(ctx context.Context, b barrier.Barrier, mountPath string) error + // Unseal opens the engine using state from the barrier. + Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error + // Seal closes the engine and zeroizes key material. + Seal() error + // HandleRequest processes a request. + HandleRequest(ctx context.Context, req *Request) (*Response, error) +} + +// Factory creates a new engine instance of a given type. +type Factory func() Engine + +// Mount represents a mounted engine instance. +type Mount struct { + Name string `json:"name"` + Type EngineType `json:"type"` + MountPath string `json:"mount_path"` + engine Engine +} + +// Registry manages mounted engine instances. +type Registry struct { + mu sync.RWMutex + mounts map[string]*Mount + factories map[EngineType]Factory + barrier barrier.Barrier +} + +// NewRegistry creates a new engine registry. +func NewRegistry(b barrier.Barrier) *Registry { + return &Registry{ + mounts: make(map[string]*Mount), + factories: make(map[EngineType]Factory), + barrier: b, + } +} + +// RegisterFactory registers a factory for the given engine type. +func (r *Registry) RegisterFactory(t EngineType, f Factory) { + r.mu.Lock() + defer r.mu.Unlock() + r.factories[t] = f +} + +// Mount creates and initializes a new engine mount. +func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.mounts[name]; exists { + return ErrMountExists + } + + factory, ok := r.factories[engineType] + if !ok { + return fmt.Errorf("%w: %s", ErrUnknownType, engineType) + } + + eng := factory() + mountPath := fmt.Sprintf("engine/%s/%s/", engineType, name) + + if err := eng.Initialize(ctx, r.barrier, mountPath); err != nil { + return fmt.Errorf("engine: initialize %q: %w", name, err) + } + + r.mounts[name] = &Mount{ + Name: name, + Type: engineType, + MountPath: mountPath, + engine: eng, + } + return nil +} + +// Unmount removes and seals an engine mount. +func (r *Registry) Unmount(name string) error { + r.mu.Lock() + defer r.mu.Unlock() + + mount, exists := r.mounts[name] + if !exists { + return ErrMountNotFound + } + + if err := mount.engine.Seal(); err != nil { + return fmt.Errorf("engine: seal %q: %w", name, err) + } + + delete(r.mounts, name) + return nil +} + +// ListMounts returns all current mounts. +func (r *Registry) ListMounts() []Mount { + r.mu.RLock() + defer r.mu.RUnlock() + + mounts := make([]Mount, 0, len(r.mounts)) + for _, m := range r.mounts { + mounts = append(mounts, Mount{ + Name: m.Name, + Type: m.Type, + MountPath: m.MountPath, + }) + } + return mounts +} + +// HandleRequest routes a request to the appropriate engine. +func (r *Registry) HandleRequest(ctx context.Context, mountName string, req *Request) (*Response, error) { + r.mu.RLock() + mount, exists := r.mounts[mountName] + r.mu.RUnlock() + + if !exists { + return nil, ErrMountNotFound + } + + return mount.engine.HandleRequest(ctx, req) +} + +// SealAll seals all mounted engines. +func (r *Registry) SealAll() error { + r.mu.Lock() + defer r.mu.Unlock() + + for name, mount := range r.mounts { + if err := mount.engine.Seal(); err != nil { + return fmt.Errorf("engine: seal %q: %w", name, err) + } + } + return nil +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..344af3b --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,120 @@ +package engine + +import ( + "context" + "testing" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" +) + +// mockEngine implements Engine for testing. +type mockEngine struct { + engineType EngineType + initialized bool + unsealed bool +} + +func (m *mockEngine) Type() EngineType { return m.engineType } +func (m *mockEngine) Initialize(_ context.Context, _ barrier.Barrier, _ string) error { m.initialized = true; return nil } +func (m *mockEngine) Unseal(_ context.Context, _ barrier.Barrier, _ string) error { m.unsealed = true; return nil } +func (m *mockEngine) Seal() error { m.unsealed = false; return nil } +func (m *mockEngine) HandleRequest(_ context.Context, _ *Request) (*Response, error) { + return &Response{Data: map[string]interface{}{"ok": true}}, nil +} + +type mockBarrier struct{} + +func (m *mockBarrier) Unseal(_ []byte) error { return nil } +func (m *mockBarrier) Seal() error { return nil } +func (m *mockBarrier) IsSealed() bool { return false } +func (m *mockBarrier) Get(_ context.Context, _ string) ([]byte, error) { return nil, barrier.ErrNotFound } +func (m *mockBarrier) Put(_ context.Context, _ string, _ []byte) error { return nil } +func (m *mockBarrier) Delete(_ context.Context, _ string) error { return nil } +func (m *mockBarrier) List(_ context.Context, _ string) ([]string, error) { return nil, nil } + +func TestRegistryMountUnmount(t *testing.T) { + reg := NewRegistry(&mockBarrier{}) + reg.RegisterFactory(EngineTypeTransit, func() Engine { + return &mockEngine{engineType: EngineTypeTransit} + }) + + ctx := context.Background() + if err := reg.Mount(ctx, "default", EngineTypeTransit); err != nil { + t.Fatalf("Mount: %v", err) + } + + mounts := reg.ListMounts() + if len(mounts) != 1 { + t.Fatalf("ListMounts: got %d, want 1", len(mounts)) + } + if mounts[0].Name != "default" { + t.Errorf("mount name: got %q, want %q", mounts[0].Name, "default") + } + + // Duplicate mount should fail. + if err := reg.Mount(ctx, "default", EngineTypeTransit); err != ErrMountExists { + t.Fatalf("expected ErrMountExists, got: %v", err) + } + + if err := reg.Unmount("default"); err != nil { + t.Fatalf("Unmount: %v", err) + } + + mounts = reg.ListMounts() + if len(mounts) != 0 { + t.Fatalf("after unmount: got %d mounts", len(mounts)) + } +} + +func TestRegistryUnmountNotFound(t *testing.T) { + reg := NewRegistry(&mockBarrier{}) + if err := reg.Unmount("nonexistent"); err != ErrMountNotFound { + t.Fatalf("expected ErrMountNotFound, got: %v", err) + } +} + +func TestRegistryUnknownType(t *testing.T) { + reg := NewRegistry(&mockBarrier{}) + err := reg.Mount(context.Background(), "test", EngineTypeTransit) + if err == nil { + t.Fatal("expected error for unknown engine type") + } +} + +func TestRegistryHandleRequest(t *testing.T) { + reg := NewRegistry(&mockBarrier{}) + reg.RegisterFactory(EngineTypeTransit, func() Engine { + return &mockEngine{engineType: EngineTypeTransit} + }) + + ctx := context.Background() + reg.Mount(ctx, "test", EngineTypeTransit) + + resp, err := reg.HandleRequest(ctx, "test", &Request{Operation: "encrypt"}) + if err != nil { + t.Fatalf("HandleRequest: %v", err) + } + if resp.Data["ok"] != true { + t.Error("expected ok=true in response") + } + + _, err = reg.HandleRequest(ctx, "nonexistent", &Request{}) + if err != ErrMountNotFound { + t.Fatalf("expected ErrMountNotFound, got: %v", err) + } +} + +func TestRegistrySealAll(t *testing.T) { + reg := NewRegistry(&mockBarrier{}) + reg.RegisterFactory(EngineTypeTransit, func() Engine { + return &mockEngine{engineType: EngineTypeTransit} + }) + + ctx := context.Background() + reg.Mount(ctx, "eng1", EngineTypeTransit) + reg.Mount(ctx, "eng2", EngineTypeTransit) + + if err := reg.SealAll(); err != nil { + t.Fatalf("SealAll: %v", err) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..e1421b0 --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,188 @@ +// Package policy implements the Metacrypt policy engine with priority-based ACL rules. +package policy + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" +) + +const rulesPrefix = "policy/rules/" + +// Effect represents a policy decision. +type Effect string + +const ( + EffectAllow Effect = "allow" + EffectDeny Effect = "deny" +) + +// Rule is a policy rule stored in the barrier. +type Rule struct { + ID string `json:"id"` + Priority int `json:"priority"` + Effect Effect `json:"effect"` + Usernames []string `json:"usernames,omitempty"` // match specific users + Roles []string `json:"roles,omitempty"` // match roles + Resources []string `json:"resources,omitempty"` // glob patterns for engine mounts/paths + Actions []string `json:"actions,omitempty"` // e.g., "read", "write", "admin" +} + +// Request represents an authorization request. +type Request struct { + Username string + Roles []string + Resource string // e.g., "engine/transit/default/encrypt" + Action string // e.g., "write" +} + +// Engine evaluates policy rules from the barrier. +type Engine struct { + barrier barrier.Barrier +} + +// NewEngine creates a new policy engine. +func NewEngine(b barrier.Barrier) *Engine { + return &Engine{barrier: b} +} + +// Evaluate checks if the request is allowed. Admin role always allows. +// Otherwise: collect matching rules, sort by priority (lower = higher priority), +// first match wins, default deny. +func (e *Engine) Evaluate(ctx context.Context, req *Request) (Effect, error) { + // Admin bypass. + for _, r := range req.Roles { + if r == "admin" { + return EffectAllow, nil + } + } + + rules, err := e.listRules(ctx) + if err != nil { + return EffectDeny, err + } + + // Sort by priority ascending (lower number = higher priority). + sort.Slice(rules, func(i, j int) bool { + return rules[i].Priority < rules[j].Priority + }) + + for _, rule := range rules { + if matchesRule(&rule, req) { + return rule.Effect, nil + } + } + + return EffectDeny, nil // default deny +} + +// CreateRule stores a new policy rule. +func (e *Engine) CreateRule(ctx context.Context, rule *Rule) error { + data, err := json.Marshal(rule) + if err != nil { + return fmt.Errorf("policy: marshal rule: %w", err) + } + return e.barrier.Put(ctx, rulesPrefix+rule.ID, data) +} + +// GetRule retrieves a policy rule by ID. +func (e *Engine) GetRule(ctx context.Context, id string) (*Rule, error) { + data, err := e.barrier.Get(ctx, rulesPrefix+id) + if err != nil { + return nil, err + } + var rule Rule + if err := json.Unmarshal(data, &rule); err != nil { + return nil, fmt.Errorf("policy: unmarshal rule: %w", err) + } + return &rule, nil +} + +// DeleteRule removes a policy rule. +func (e *Engine) DeleteRule(ctx context.Context, id string) error { + return e.barrier.Delete(ctx, rulesPrefix+id) +} + +// ListRules returns all policy rules. +func (e *Engine) ListRules(ctx context.Context) ([]Rule, error) { + return e.listRules(ctx) +} + +func (e *Engine) listRules(ctx context.Context) ([]Rule, error) { + paths, err := e.barrier.List(ctx, rulesPrefix) + if err != nil { + return nil, fmt.Errorf("policy: list rules: %w", err) + } + + var rules []Rule + for _, p := range paths { + data, err := e.barrier.Get(ctx, rulesPrefix+p) + if err != nil { + return nil, fmt.Errorf("policy: get rule %q: %w", p, err) + } + var rule Rule + if err := json.Unmarshal(data, &rule); err != nil { + return nil, fmt.Errorf("policy: unmarshal rule %q: %w", p, err) + } + rules = append(rules, rule) + } + return rules, nil +} + +func matchesRule(rule *Rule, req *Request) bool { + // Check username match. + if len(rule.Usernames) > 0 && !containsString(rule.Usernames, req.Username) { + return false + } + + // Check role match. + if len(rule.Roles) > 0 && !hasAnyRole(rule.Roles, req.Roles) { + return false + } + + // Check resource match (glob patterns). + if len(rule.Resources) > 0 && !matchesAnyGlob(rule.Resources, req.Resource) { + return false + } + + // Check action match. + if len(rule.Actions) > 0 && !containsString(rule.Actions, req.Action) { + return false + } + + return true +} + +func containsString(haystack []string, needle string) bool { + for _, s := range haystack { + if strings.EqualFold(s, needle) { + return true + } + } + return false +} + +func hasAnyRole(required, actual []string) bool { + for _, r := range required { + for _, a := range actual { + if strings.EqualFold(r, a) { + return true + } + } + } + return false +} + +func matchesAnyGlob(patterns []string, value string) bool { + for _, p := range patterns { + if matched, _ := filepath.Match(p, value); matched { + return true + } + } + return false +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 0000000..1bc6488 --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,177 @@ +package policy + +import ( + "context" + "path/filepath" + "testing" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/crypto" + "git.wntrmute.dev/kyle/metacrypt/internal/db" +) + +func setupPolicy(t *testing.T) (*Engine, func()) { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("migrate: %v", err) + } + b := barrier.NewAESGCMBarrier(database) + mek, _ := crypto.GenerateKey() + b.Unseal(mek) + e := NewEngine(b) + return e, func() { database.Close() } +} + +func TestAdminBypass(t *testing.T) { + e, cleanup := setupPolicy(t) + defer cleanup() + + effect, err := e.Evaluate(context.Background(), &Request{ + Username: "admin-user", + Roles: []string{"admin"}, + Resource: "engine/transit/default/encrypt", + Action: "write", + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if effect != EffectAllow { + t.Fatalf("admin should always be allowed, got: %s", effect) + } +} + +func TestDefaultDeny(t *testing.T) { + e, cleanup := setupPolicy(t) + defer cleanup() + + effect, err := e.Evaluate(context.Background(), &Request{ + Username: "user1", + Roles: []string{"viewer"}, + Resource: "engine/transit/default/encrypt", + Action: "write", + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if effect != EffectDeny { + t.Fatalf("default should deny, got: %s", effect) + } +} + +func TestPolicyRuleCRUD(t *testing.T) { + e, cleanup := setupPolicy(t) + defer cleanup() + ctx := context.Background() + + rule := &Rule{ + ID: "test-rule", + Priority: 100, + Effect: EffectAllow, + Roles: []string{"operator"}, + Resources: []string{"engine/transit/*"}, + Actions: []string{"read", "write"}, + } + + if err := e.CreateRule(ctx, rule); err != nil { + t.Fatalf("CreateRule: %v", err) + } + + got, err := e.GetRule(ctx, "test-rule") + if err != nil { + t.Fatalf("GetRule: %v", err) + } + if got.Priority != 100 { + t.Errorf("priority: got %d, want 100", got.Priority) + } + + rules, err := e.ListRules(ctx) + if err != nil { + t.Fatalf("ListRules: %v", err) + } + if len(rules) != 1 { + t.Fatalf("ListRules: got %d rules, want 1", len(rules)) + } + + if err := e.DeleteRule(ctx, "test-rule"); err != nil { + t.Fatalf("DeleteRule: %v", err) + } + + rules, _ = e.ListRules(ctx) + if len(rules) != 0 { + t.Fatalf("after delete: got %d rules, want 0", len(rules)) + } +} + +func TestPolicyPriorityOrder(t *testing.T) { + e, cleanup := setupPolicy(t) + defer cleanup() + ctx := context.Background() + + // Lower priority number = higher priority. Deny should win. + e.CreateRule(ctx, &Rule{ + ID: "allow-rule", + Priority: 200, + Effect: EffectAllow, + Roles: []string{"operator"}, + Resources: []string{"engine/transit/*"}, + Actions: []string{"write"}, + }) + e.CreateRule(ctx, &Rule{ + ID: "deny-rule", + Priority: 100, + Effect: EffectDeny, + Roles: []string{"operator"}, + Resources: []string{"engine/transit/*"}, + Actions: []string{"write"}, + }) + + effect, _ := e.Evaluate(ctx, &Request{ + Username: "user1", + Roles: []string{"operator"}, + Resource: "engine/transit/default", + Action: "write", + }) + if effect != EffectDeny { + t.Fatalf("higher priority deny should win, got: %s", effect) + } +} + +func TestPolicyUsernameMatch(t *testing.T) { + e, cleanup := setupPolicy(t) + defer cleanup() + ctx := context.Background() + + e.CreateRule(ctx, &Rule{ + ID: "user-specific", + Priority: 100, + Effect: EffectAllow, + Usernames: []string{"alice"}, + Resources: []string{"engine/*"}, + Actions: []string{"read"}, + }) + + effect, _ := e.Evaluate(ctx, &Request{ + Username: "alice", + Roles: []string{"user"}, + Resource: "engine/ca", + Action: "read", + }) + if effect != EffectAllow { + t.Fatalf("alice should be allowed, got: %s", effect) + } + + effect, _ = e.Evaluate(ctx, &Request{ + Username: "bob", + Roles: []string{"user"}, + Resource: "engine/ca", + Action: "read", + }) + if effect != EffectDeny { + t.Fatalf("bob should be denied, got: %s", effect) + } +} diff --git a/internal/seal/seal.go b/internal/seal/seal.go new file mode 100644 index 0000000..4a61364 --- /dev/null +++ b/internal/seal/seal.go @@ -0,0 +1,242 @@ +// Package seal implements the seal/unseal state machine for Metacrypt. +package seal + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" + "time" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/crypto" +) + +// ServiceState represents the current state of the Metacrypt service. +type ServiceState int + +const ( + StateUninitialized ServiceState = iota + StateSealed + StateInitializing + StateUnsealed +) + +func (s ServiceState) String() string { + switch s { + case StateUninitialized: + return "uninitialized" + case StateSealed: + return "sealed" + case StateInitializing: + return "initializing" + case StateUnsealed: + return "unsealed" + default: + return "unknown" + } +} + +var ( + ErrAlreadyInitialized = errors.New("seal: already initialized") + ErrNotInitialized = errors.New("seal: not initialized") + ErrInvalidPassword = errors.New("seal: invalid password") + ErrSealed = errors.New("seal: service is sealed") + ErrNotSealed = errors.New("seal: service is not sealed") + ErrRateLimited = errors.New("seal: too many unseal attempts, try again later") +) + +// Manager manages the seal/unseal lifecycle. +type Manager struct { + db *sql.DB + barrier *barrier.AESGCMBarrier + + mu sync.RWMutex + state ServiceState + mek []byte // nil when sealed + + // Rate limiting for unseal attempts. + unsealAttempts int + lastAttempt time.Time + lockoutUntil time.Time +} + +// NewManager creates a new seal manager. +func NewManager(db *sql.DB, b *barrier.AESGCMBarrier) *Manager { + return &Manager{ + db: db, + barrier: b, + state: StateUninitialized, + } +} + +// State returns the current service state. +func (m *Manager) State() ServiceState { + m.mu.RLock() + defer m.mu.RUnlock() + return m.state +} + +// CheckInitialized checks the database for an existing seal config and +// updates the state accordingly. Should be called on startup. +func (m *Manager) CheckInitialized() error { + m.mu.Lock() + defer m.mu.Unlock() + + var count int + err := m.db.QueryRow("SELECT COUNT(*) FROM seal_config").Scan(&count) + if err != nil { + return fmt.Errorf("seal: check initialized: %w", err) + } + if count > 0 { + m.state = StateSealed + } else { + m.state = StateUninitialized + } + return nil +} + +// Initialize performs first-time setup: generates MEK, encrypts it with the +// password-derived KWK, and stores everything in seal_config. +func (m *Manager) Initialize(ctx context.Context, password []byte, params crypto.Argon2Params) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.state != StateUninitialized { + return ErrAlreadyInitialized + } + + m.state = StateInitializing + defer func() { + if m.mek == nil { + // If we failed, go back to uninitialized. + m.state = StateUninitialized + } + }() + + // Generate salt and MEK. + salt, err := crypto.GenerateSalt() + if err != nil { + return fmt.Errorf("seal: generate salt: %w", err) + } + + mek, err := crypto.GenerateKey() + if err != nil { + return fmt.Errorf("seal: generate mek: %w", err) + } + + // Derive KWK from password. + kwk := crypto.DeriveKey(password, salt, params) + defer crypto.Zeroize(kwk) + + // Encrypt MEK with KWK. + encryptedMEK, err := crypto.Encrypt(kwk, mek) + if err != nil { + crypto.Zeroize(mek) + return fmt.Errorf("seal: encrypt mek: %w", err) + } + + // Store in database. + _, err = m.db.ExecContext(ctx, ` + INSERT INTO seal_config (id, encrypted_mek, kdf_salt, argon2_time, argon2_memory, argon2_threads) + VALUES (1, ?, ?, ?, ?, ?)`, + encryptedMEK, salt, params.Time, params.Memory, params.Threads) + if err != nil { + crypto.Zeroize(mek) + return fmt.Errorf("seal: store config: %w", err) + } + + // Unseal the barrier with the MEK. + if err := m.barrier.Unseal(mek); err != nil { + crypto.Zeroize(mek) + return fmt.Errorf("seal: unseal barrier: %w", err) + } + + m.mek = mek + m.state = StateUnsealed + return nil +} + +// Unseal decrypts the MEK using the provided password and unseals the barrier. +func (m *Manager) Unseal(password []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.state == StateUninitialized { + return ErrNotInitialized + } + if m.state == StateUnsealed { + return ErrNotSealed + } + + // Rate limiting. + now := time.Now() + if now.Before(m.lockoutUntil) { + return ErrRateLimited + } + if now.Sub(m.lastAttempt) > time.Minute { + m.unsealAttempts = 0 + } + m.unsealAttempts++ + m.lastAttempt = now + if m.unsealAttempts > 5 { + m.lockoutUntil = now.Add(60 * time.Second) + m.unsealAttempts = 0 + return ErrRateLimited + } + + // Read seal config. + var ( + encryptedMEK []byte + salt []byte + argTime, argMem uint32 + argThreads uint8 + ) + err := m.db.QueryRow(` + SELECT encrypted_mek, kdf_salt, argon2_time, argon2_memory, argon2_threads + FROM seal_config WHERE id = 1`).Scan(&encryptedMEK, &salt, &argTime, &argMem, &argThreads) + if err != nil { + return fmt.Errorf("seal: read config: %w", err) + } + + params := crypto.Argon2Params{Time: argTime, Memory: argMem, Threads: argThreads} + + // Derive KWK and decrypt MEK. + kwk := crypto.DeriveKey(password, salt, params) + defer crypto.Zeroize(kwk) + + mek, err := crypto.Decrypt(kwk, encryptedMEK) + if err != nil { + return ErrInvalidPassword + } + + // Unseal the barrier. + if err := m.barrier.Unseal(mek); err != nil { + crypto.Zeroize(mek) + return fmt.Errorf("seal: unseal barrier: %w", err) + } + + m.mek = mek + m.state = StateUnsealed + m.unsealAttempts = 0 + return nil +} + +// Seal seals the service: zeroizes MEK, seals the barrier. +func (m *Manager) Seal() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.state != StateUnsealed { + return ErrNotSealed + } + + if m.mek != nil { + crypto.Zeroize(m.mek) + m.mek = nil + } + m.barrier.Seal() + m.state = StateSealed + return nil +} diff --git a/internal/seal/seal_test.go b/internal/seal/seal_test.go new file mode 100644 index 0000000..9c893a3 --- /dev/null +++ b/internal/seal/seal_test.go @@ -0,0 +1,136 @@ +package seal + +import ( + "context" + "path/filepath" + "testing" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/crypto" + "git.wntrmute.dev/kyle/metacrypt/internal/db" +) + +func setupSeal(t *testing.T) (*Manager, func()) { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("migrate: %v", err) + } + b := barrier.NewAESGCMBarrier(database) + mgr := NewManager(database, b) + return mgr, func() { database.Close() } +} + +func TestSealInitializeAndUnseal(t *testing.T) { + mgr, cleanup := setupSeal(t) + defer cleanup() + + if err := mgr.CheckInitialized(); err != nil { + t.Fatalf("CheckInitialized: %v", err) + } + if mgr.State() != StateUninitialized { + t.Fatalf("state: got %v, want Uninitialized", mgr.State()) + } + + password := []byte("test-password-123") + // Use fast params for testing. + params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} + + if err := mgr.Initialize(context.Background(), password, params); err != nil { + t.Fatalf("Initialize: %v", err) + } + if mgr.State() != StateUnsealed { + t.Fatalf("state after init: got %v, want Unsealed", mgr.State()) + } + + // Seal. + if err := mgr.Seal(); err != nil { + t.Fatalf("Seal: %v", err) + } + if mgr.State() != StateSealed { + t.Fatalf("state after seal: got %v, want Sealed", mgr.State()) + } + + // Unseal with correct password. + if err := mgr.Unseal(password); err != nil { + t.Fatalf("Unseal: %v", err) + } + if mgr.State() != StateUnsealed { + t.Fatalf("state after unseal: got %v, want Unsealed", mgr.State()) + } +} + +func TestSealWrongPassword(t *testing.T) { + mgr, cleanup := setupSeal(t) + defer cleanup() + mgr.CheckInitialized() + + params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} + mgr.Initialize(context.Background(), []byte("correct"), params) + mgr.Seal() + + err := mgr.Unseal([]byte("wrong")) + if err != ErrInvalidPassword { + t.Fatalf("expected ErrInvalidPassword, got: %v", err) + } +} + +func TestSealDoubleInitialize(t *testing.T) { + mgr, cleanup := setupSeal(t) + defer cleanup() + mgr.CheckInitialized() + + params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} + mgr.Initialize(context.Background(), []byte("password"), params) + + err := mgr.Initialize(context.Background(), []byte("password"), params) + if err != ErrAlreadyInitialized { + t.Fatalf("expected ErrAlreadyInitialized, got: %v", err) + } +} + +func TestSealCheckInitializedPersists(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + // First: initialize. + database, _ := db.Open(dbPath) + db.Migrate(database) + b := barrier.NewAESGCMBarrier(database) + mgr := NewManager(database, b) + mgr.CheckInitialized() + params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} + mgr.Initialize(context.Background(), []byte("password"), params) + database.Close() + + // Second: reopen and check. + database2, _ := db.Open(dbPath) + defer database2.Close() + b2 := barrier.NewAESGCMBarrier(database2) + mgr2 := NewManager(database2, b2) + mgr2.CheckInitialized() + if mgr2.State() != StateSealed { + t.Fatalf("state after reopen: got %v, want Sealed", mgr2.State()) + } +} + +func TestSealStateString(t *testing.T) { + tests := []struct { + state ServiceState + want string + }{ + {StateUninitialized, "uninitialized"}, + {StateSealed, "sealed"}, + {StateInitializing, "initializing"}, + {StateUnsealed, "unsealed"}, + } + for _, tt := range tests { + if got := tt.state.String(); got != tt.want { + t.Errorf("State(%d).String() = %q, want %q", tt.state, got, tt.want) + } + } +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 0000000..632d5ba --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,109 @@ +package server + +import ( + "context" + "net/http" + "strings" + "time" + + "git.wntrmute.dev/kyle/metacrypt/internal/auth" + "git.wntrmute.dev/kyle/metacrypt/internal/seal" +) + +type contextKey string + +const tokenInfoKey contextKey = "tokenInfo" + +// TokenInfoFromContext extracts the validated token info from the request context. +func TokenInfoFromContext(ctx context.Context) *auth.TokenInfo { + info, _ := ctx.Value(tokenInfoKey).(*auth.TokenInfo) + return info +} + +// loggingMiddleware logs HTTP requests, stripping sensitive headers. +func (s *Server) loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: 200} + next.ServeHTTP(sw, r) + s.logger.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "status", sw.status, + "duration", time.Since(start), + "remote", r.RemoteAddr, + ) + }) +} + +// requireUnseal rejects requests unless the service is unsealed. +func (s *Server) requireUnseal(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + state := s.seal.State() + switch state { + case seal.StateUninitialized: + http.Error(w, `{"error":"not initialized"}`, http.StatusPreconditionFailed) + return + case seal.StateSealed, seal.StateInitializing: + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + return + } + next(w, r) + } +} + +// requireAuth validates the bearer token and injects TokenInfo into context. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return s.requireUnseal(func(w http.ResponseWriter, r *http.Request) { + token := extractToken(r) + if token == "" { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + info, err := s.auth.ValidateToken(token) + if err != nil { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), tokenInfoKey, info) + next(w, r.WithContext(ctx)) + }) +} + +// requireAdmin requires the authenticated user to have admin role. +func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + info := TokenInfoFromContext(r.Context()) + if info == nil || !info.IsAdmin { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + next(w, r) + }) +} + +func extractToken(r *http.Request) string { + // Check Authorization header first. + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + // Fall back to cookie. + cookie, err := r.Cookie("metacrypt_token") + if err == nil { + return cookie.Value + } + return "" +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..6029b45 --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,532 @@ +package server + +import ( + "context" + "encoding/json" + "html/template" + "io" + "net/http" + "path/filepath" + + mcias "git.wntrmute.dev/kyle/mcias/clients/go" + + "git.wntrmute.dev/kyle/metacrypt/internal/crypto" + "git.wntrmute.dev/kyle/metacrypt/internal/policy" + "git.wntrmute.dev/kyle/metacrypt/internal/seal" +) + +func (s *Server) registerRoutes(mux *http.ServeMux) { + // Static files. + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) + + // Web UI routes. + mux.HandleFunc("/", s.handleWebRoot) + mux.HandleFunc("/init", s.handleWebInit) + mux.HandleFunc("/unseal", s.handleWebUnseal) + mux.HandleFunc("/login", s.handleWebLogin) + mux.HandleFunc("/dashboard", s.requireAuthWeb(s.handleWebDashboard)) + + // API routes. + mux.HandleFunc("/v1/status", s.handleStatus) + mux.HandleFunc("/v1/init", s.handleInit) + mux.HandleFunc("/v1/unseal", s.handleUnseal) + mux.HandleFunc("/v1/seal", s.requireAdmin(s.handleSeal)) + + mux.HandleFunc("/v1/auth/login", s.handleLogin) + mux.HandleFunc("/v1/auth/logout", s.requireAuth(s.handleLogout)) + mux.HandleFunc("/v1/auth/tokeninfo", s.requireAuth(s.handleTokenInfo)) + + mux.HandleFunc("/v1/engine/mounts", s.requireAuth(s.handleEngineMounts)) + mux.HandleFunc("/v1/engine/mount", s.requireAdmin(s.handleEngineMount)) + mux.HandleFunc("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount)) + mux.HandleFunc("/v1/engine/request", s.requireAuth(s.handleEngineRequest)) + + mux.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules)) + mux.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule)) +} + +// --- API Handlers --- + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "state": s.seal.State().String(), + }) +} + +func (s *Server) handleInit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + var req struct { + Password string `json:"password"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Password == "" { + http.Error(w, `{"error":"password is required"}`, http.StatusBadRequest) + return + } + + params := crypto.Argon2Params{ + Time: s.cfg.Seal.Argon2Time, + Memory: s.cfg.Seal.Argon2Memory, + Threads: s.cfg.Seal.Argon2Threads, + } + if err := s.seal.Initialize(r.Context(), []byte(req.Password), params); err != nil { + if err == seal.ErrAlreadyInitialized { + http.Error(w, `{"error":"already initialized"}`, http.StatusConflict) + return + } + s.logger.Error("init failed", "error", err) + http.Error(w, `{"error":"initialization failed"}`, http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "state": s.seal.State().String(), + }) +} + +func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + var req struct { + Password string `json:"password"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + if err := s.seal.Unseal([]byte(req.Password)); err != nil { + switch err { + case seal.ErrNotInitialized: + http.Error(w, `{"error":"not initialized"}`, http.StatusPreconditionFailed) + case seal.ErrInvalidPassword: + http.Error(w, `{"error":"invalid password"}`, http.StatusUnauthorized) + case seal.ErrRateLimited: + http.Error(w, `{"error":"too many attempts, try again later"}`, http.StatusTooManyRequests) + case seal.ErrNotSealed: + http.Error(w, `{"error":"already unsealed"}`, http.StatusConflict) + default: + s.logger.Error("unseal failed", "error", err) + http.Error(w, `{"error":"unseal failed"}`, http.StatusInternalServerError) + } + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "state": s.seal.State().String(), + }) +} + +func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + if err := s.engines.SealAll(); err != nil { + s.logger.Error("seal engines failed", "error", err) + } + + if err := s.seal.Seal(); err != nil { + s.logger.Error("seal failed", "error", err) + http.Error(w, `{"error":"seal failed"}`, http.StatusInternalServerError) + return + } + + s.auth.ClearCache() + writeJSON(w, http.StatusOK, map[string]interface{}{ + "state": s.seal.State().String(), + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + if s.seal.State() != seal.StateUnsealed { + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + TOTPCode string `json:"totp_code"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + token, expiresAt, err := s.auth.Login(req.Username, req.Password, req.TOTPCode) + if err != nil { + http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "token": token, + "expires_at": expiresAt, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + token := extractToken(r) + client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{ + CACertPath: s.cfg.MCIAS.CACert, + Token: token, + }) + if err == nil { + s.auth.Logout(client) + } + + // Clear cookie. + http.SetCookie(w, &http.Cookie{ + Name: "metacrypt_token", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +func (s *Server) handleTokenInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + info := TokenInfoFromContext(r.Context()) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "username": info.Username, + "roles": info.Roles, + "is_admin": info.IsAdmin, + }) +} + +func (s *Server) handleEngineMounts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + mounts := s.engines.ListMounts() + writeJSON(w, http.StatusOK, mounts) +} + +func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + var req struct { + Name string `json:"name"` + Type string `json:"type"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + // Phase 1: no engine types registered yet. + http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented) +} + +func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + var req struct { + Name string `json:"name"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if err := s.engines.Unmount(req.Name); err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + // Phase 1 stub. + http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented) +} + +func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) { + info := TokenInfoFromContext(r.Context()) + switch r.Method { + case http.MethodGet: + if !info.IsAdmin { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + rules, err := s.policy.ListRules(r.Context()) + if err != nil { + s.logger.Error("list policies", "error", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + if rules == nil { + rules = []policy.Rule{} + } + writeJSON(w, http.StatusOK, rules) + case http.MethodPost: + if !info.IsAdmin { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + var rule policy.Rule + if err := readJSON(r, &rule); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if rule.ID == "" { + http.Error(w, `{"error":"id is required"}`, http.StatusBadRequest) + return + } + if err := s.policy.CreateRule(r.Context(), &rule); err != nil { + s.logger.Error("create policy", "error", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, rule) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (s *Server) handlePolicyRule(w http.ResponseWriter, r *http.Request) { + info := TokenInfoFromContext(r.Context()) + if !info.IsAdmin { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, `{"error":"id parameter required"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + rule, err := s.policy.GetRule(r.Context(), id) + if err != nil { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, rule) + case http.MethodDelete: + if err := s.policy.DeleteRule(r.Context(), id); err != nil { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +// --- Web Handlers --- + +func (s *Server) handleWebRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + state := s.seal.State() + switch state { + case seal.StateUninitialized: + http.Redirect(w, r, "/init", http.StatusFound) + case seal.StateSealed: + http.Redirect(w, r, "/unseal", http.StatusFound) + case seal.StateInitializing: + http.Redirect(w, r, "/init", http.StatusFound) + case seal.StateUnsealed: + http.Redirect(w, r, "/dashboard", http.StatusFound) + } +} + +func (s *Server) handleWebInit(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if s.seal.State() != seal.StateUninitialized { + http.Redirect(w, r, "/", http.StatusFound) + return + } + s.renderTemplate(w, "init.html", nil) + case http.MethodPost: + r.ParseForm() + password := r.FormValue("password") + if password == "" { + s.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"}) + return + } + params := crypto.Argon2Params{ + Time: s.cfg.Seal.Argon2Time, + Memory: s.cfg.Seal.Argon2Memory, + Threads: s.cfg.Seal.Argon2Threads, + } + if err := s.seal.Initialize(r.Context(), []byte(password), params); err != nil { + s.renderTemplate(w, "init.html", map[string]interface{}{"Error": err.Error()}) + return + } + http.Redirect(w, r, "/dashboard", http.StatusFound) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) handleWebUnseal(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + state := s.seal.State() + if state == seal.StateUninitialized { + http.Redirect(w, r, "/init", http.StatusFound) + return + } + if state == seal.StateUnsealed { + http.Redirect(w, r, "/dashboard", http.StatusFound) + return + } + s.renderTemplate(w, "unseal.html", nil) + case http.MethodPost: + r.ParseForm() + password := r.FormValue("password") + if err := s.seal.Unseal([]byte(password)); err != nil { + msg := "Invalid password" + if err == seal.ErrRateLimited { + msg = "Too many attempts. Please wait 60 seconds." + } + s.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg}) + return + } + http.Redirect(w, r, "/dashboard", http.StatusFound) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) handleWebLogin(w http.ResponseWriter, r *http.Request) { + if s.seal.State() != seal.StateUnsealed { + http.Redirect(w, r, "/", http.StatusFound) + return + } + switch r.Method { + case http.MethodGet: + s.renderTemplate(w, "login.html", nil) + case http.MethodPost: + r.ParseForm() + username := r.FormValue("username") + password := r.FormValue("password") + totpCode := r.FormValue("totp_code") + token, _, err := s.auth.Login(username, password, totpCode) + if err != nil { + s.renderTemplate(w, "login.html", map[string]interface{}{"Error": "Invalid credentials"}) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "metacrypt_token", + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + http.Redirect(w, r, "/dashboard", http.StatusFound) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) handleWebDashboard(w http.ResponseWriter, r *http.Request) { + info := TokenInfoFromContext(r.Context()) + mounts := s.engines.ListMounts() + s.renderTemplate(w, "dashboard.html", map[string]interface{}{ + "Username": info.Username, + "IsAdmin": info.IsAdmin, + "Roles": info.Roles, + "Mounts": mounts, + "State": s.seal.State().String(), + }) +} + +// requireAuthWeb redirects to login for web pages instead of returning 401. +func (s *Server) requireAuthWeb(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.seal.State() != seal.StateUnsealed { + http.Redirect(w, r, "/", http.StatusFound) + return + } + token := extractToken(r) + if token == "" { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + info, err := s.auth.ValidateToken(token) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + ctx := r.Context() + ctx = context.WithValue(ctx, tokenInfoKey, info) + next(w, r.WithContext(ctx)) + } +} + +func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) { + tmpl, err := template.ParseFiles( + filepath.Join("web", "templates", "layout.html"), + filepath.Join("web", "templates", name), + ) + if err != nil { + s.logger.Error("parse template", "name", name, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { + s.logger.Error("execute template", "name", name, "error", err) + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func readJSON(r *http.Request, v interface{}) error { + defer r.Body.Close() + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit + if err != nil { + return err + } + return json.Unmarshal(body, v) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..fd10123 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,77 @@ +// Package server implements the HTTP server for Metacrypt. +package server + +import ( + "context" + "crypto/tls" + "fmt" + "log/slog" + "net/http" + "time" + + "git.wntrmute.dev/kyle/metacrypt/internal/auth" + "git.wntrmute.dev/kyle/metacrypt/internal/config" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" + "git.wntrmute.dev/kyle/metacrypt/internal/policy" + "git.wntrmute.dev/kyle/metacrypt/internal/seal" +) + +// Server is the Metacrypt HTTP server. +type Server struct { + cfg *config.Config + seal *seal.Manager + auth *auth.Authenticator + policy *policy.Engine + engines *engine.Registry + httpSrv *http.Server + logger *slog.Logger +} + +// New creates a new server. +func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenticator, + policyEngine *policy.Engine, engineRegistry *engine.Registry, logger *slog.Logger) *Server { + s := &Server{ + cfg: cfg, + seal: sealMgr, + auth: authenticator, + policy: policyEngine, + engines: engineRegistry, + logger: logger, + } + return s +} + +// Start starts the HTTPS server. +func (s *Server) Start() error { + mux := http.NewServeMux() + s.registerRoutes(mux) + + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + } + + s.httpSrv = &http.Server{ + Addr: s.cfg.Server.ListenAddr, + Handler: s.loggingMiddleware(mux), + TLSConfig: tlsCfg, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + s.logger.Info("starting server", "addr", s.cfg.Server.ListenAddr) + err := s.httpSrv.ListenAndServeTLS(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey) + if err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server: %w", err) + } + return nil +} + +// Shutdown gracefully shuts down the server. +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpSrv.Shutdown(ctx) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..4ed6ff0 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,179 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "log/slog" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/config" + "git.wntrmute.dev/kyle/metacrypt/internal/crypto" + "git.wntrmute.dev/kyle/metacrypt/internal/db" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" + "git.wntrmute.dev/kyle/metacrypt/internal/policy" + "git.wntrmute.dev/kyle/metacrypt/internal/seal" + + // auth is used indirectly via the server + "git.wntrmute.dev/kyle/metacrypt/internal/auth" +) + +func setupTestServer(t *testing.T) (*Server, *seal.Manager, *http.ServeMux) { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + db.Migrate(database) + + b := barrier.NewAESGCMBarrier(database) + sealMgr := seal.NewManager(database, b) + sealMgr.CheckInitialized() + + // Auth requires MCIAS client which we can't create in tests easily, + // so we pass nil and avoid auth-dependent routes in these tests. + authenticator := auth.NewAuthenticator(nil) + policyEngine := policy.NewEngine(b) + engineRegistry := engine.NewRegistry(b) + + cfg := &config.Config{ + Server: config.ServerConfig{ + ListenAddr: ":0", + TLSCert: "cert.pem", + TLSKey: "key.pem", + }, + Database: config.DatabaseConfig{Path: filepath.Join(dir, "test.db")}, + MCIAS: config.MCIASConfig{ServerURL: "https://mcias.test"}, + Seal: config.SealConfig{ + Argon2Time: 1, + Argon2Memory: 64 * 1024, + Argon2Threads: 1, + }, + } + + logger := slog.Default() + srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger) + + mux := http.NewServeMux() + srv.registerRoutes(mux) + return srv, sealMgr, mux +} + +func TestStatusEndpoint(t *testing.T) { + _, _, mux := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/v1/status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code: got %d, want %d", w.Code, http.StatusOK) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["state"] != "uninitialized" { + t.Errorf("state: got %q, want %q", resp["state"], "uninitialized") + } +} + +func TestInitEndpoint(t *testing.T) { + _, _, mux := setupTestServer(t) + + body := `{"password":"test-password"}` + req := httptest.NewRequest(http.MethodPost, "/v1/init", strings.NewReader(body)) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code: got %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["state"] != "unsealed" { + t.Errorf("state: got %q, want %q", resp["state"], "unsealed") + } + + // Second init should fail. + req2 := httptest.NewRequest(http.MethodPost, "/v1/init", strings.NewReader(body)) + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + if w2.Code != http.StatusConflict { + t.Errorf("double init: got %d, want %d", w2.Code, http.StatusConflict) + } +} + +func TestUnsealEndpoint(t *testing.T) { + _, sealMgr, mux := setupTestServer(t) + + // Initialize first. + params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1} + sealMgr.Initialize(context.Background(), []byte("password"), params) + sealMgr.Seal() + + // Unseal with wrong password. + body := `{"password":"wrong"}` + req := httptest.NewRequest(http.MethodPost, "/v1/unseal", strings.NewReader(body)) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("wrong password: got %d, want %d", w.Code, http.StatusUnauthorized) + } + + // Unseal with correct password. + body = `{"password":"password"}` + req = httptest.NewRequest(http.MethodPost, "/v1/unseal", strings.NewReader(body)) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("correct password: got %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String()) + } +} + +func TestStatusMethodNotAllowed(t *testing.T) { + _, _, mux := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/v1/status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("POST /v1/status: got %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestRootRedirect(t *testing.T) { + _, _, mux := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusFound { + t.Errorf("root redirect: got %d, want %d", w.Code, http.StatusFound) + } + loc := w.Header().Get("Location") + if loc != "/init" { + t.Errorf("redirect location: got %q, want /init", loc) + } +} + +func TestTokenInfoFromContext(t *testing.T) { + ctx := context.Background() + if info := TokenInfoFromContext(ctx); info != nil { + t.Error("expected nil from empty context") + } + + info := &auth.TokenInfo{Username: "test", IsAdmin: true} + ctx = context.WithValue(ctx, tokenInfoKey, info) + got := TokenInfoFromContext(ctx) + if got == nil || got.Username != "test" { + t.Error("expected token info from context") + } +} diff --git a/metacrypt.toml.example b/metacrypt.toml.example new file mode 100644 index 0000000..ef2d90e --- /dev/null +++ b/metacrypt.toml.example @@ -0,0 +1,19 @@ +[server] +listen_addr = ":8443" +tls_cert = "certs/server.crt" +tls_key = "certs/server.key" + +[database] +path = "metacrypt.db" + +[mcias] +server_url = "https://mcias.metacircular.net:8443" +# ca_cert = "certs/ca.crt" + +[seal] +# argon2_time = 3 +# argon2_memory = 131072 # 128 MiB in KiB +# argon2_threads = 4 + +[log] +level = "info" diff --git a/proto/metacrypt/v1/auth.proto b/proto/metacrypt/v1/auth.proto new file mode 100644 index 0000000..79a98d5 --- /dev/null +++ b/proto/metacrypt/v1/auth.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package metacrypt.v1; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1"; + +service AuthService { + rpc Login(LoginRequest) returns (LoginResponse); + rpc Logout(LogoutRequest) returns (LogoutResponse); + rpc TokenInfo(TokenInfoRequest) returns (TokenInfoResponse); +} + +message LoginRequest { + string username = 1; + string password = 2; + string totp_code = 3; +} + +message LoginResponse { + string token = 1; + string expires_at = 2; +} + +message LogoutRequest {} +message LogoutResponse {} + +message TokenInfoRequest {} +message TokenInfoResponse { + string username = 1; + repeated string roles = 2; + bool is_admin = 3; +} diff --git a/proto/metacrypt/v1/common.proto b/proto/metacrypt/v1/common.proto new file mode 100644 index 0000000..a47a703 --- /dev/null +++ b/proto/metacrypt/v1/common.proto @@ -0,0 +1,5 @@ +syntax = "proto3"; + +package metacrypt.v1; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1"; diff --git a/proto/metacrypt/v1/engine.proto b/proto/metacrypt/v1/engine.proto new file mode 100644 index 0000000..e309952 --- /dev/null +++ b/proto/metacrypt/v1/engine.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package metacrypt.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1"; + +service EngineService { + rpc Mount(MountRequest) returns (MountResponse); + rpc Unmount(UnmountRequest) returns (UnmountResponse); + rpc ListMounts(ListMountsRequest) returns (ListMountsResponse); + rpc Request(EngineRequest) returns (EngineResponse); +} + +message MountRequest { + string name = 1; + string type = 2; +} +message MountResponse {} + +message UnmountRequest { + string name = 1; +} +message UnmountResponse {} + +message ListMountsRequest {} +message ListMountsResponse { + repeated MountInfo mounts = 1; +} + +message MountInfo { + string name = 1; + string type = 2; + string mount_path = 3; +} + +message EngineRequest { + string mount = 1; + string operation = 2; + string path = 3; + google.protobuf.Struct data = 4; +} + +message EngineResponse { + google.protobuf.Struct data = 1; +} diff --git a/proto/metacrypt/v1/policy.proto b/proto/metacrypt/v1/policy.proto new file mode 100644 index 0000000..b4deebd --- /dev/null +++ b/proto/metacrypt/v1/policy.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package metacrypt.v1; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1"; + +service PolicyService { + rpc CreatePolicy(CreatePolicyRequest) returns (PolicyRule); + rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse); + rpc GetPolicy(GetPolicyRequest) returns (PolicyRule); + rpc DeletePolicy(DeletePolicyRequest) returns (DeletePolicyResponse); +} + +message PolicyRule { + string id = 1; + int32 priority = 2; + string effect = 3; + repeated string usernames = 4; + repeated string roles = 5; + repeated string resources = 6; + repeated string actions = 7; +} + +message CreatePolicyRequest { + PolicyRule rule = 1; +} + +message ListPoliciesRequest {} +message ListPoliciesResponse { + repeated PolicyRule rules = 1; +} + +message GetPolicyRequest { + string id = 1; +} + +message DeletePolicyRequest { + string id = 1; +} + +message DeletePolicyResponse {} diff --git a/proto/metacrypt/v1/system.proto b/proto/metacrypt/v1/system.proto new file mode 100644 index 0000000..dec12cf --- /dev/null +++ b/proto/metacrypt/v1/system.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package metacrypt.v1; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1"; + +service SystemService { + rpc Status(StatusRequest) returns (StatusResponse); + rpc Init(InitRequest) returns (InitResponse); + rpc Unseal(UnsealRequest) returns (UnsealResponse); + rpc Seal(SealRequest) returns (SealResponse); +} + +message StatusRequest {} +message StatusResponse { + string state = 1; +} + +message InitRequest { + string password = 1; +} +message InitResponse { + string state = 1; +} + +message UnsealRequest { + string password = 1; +} +message UnsealResponse { + string state = 1; +} + +message SealRequest {} +message SealResponse { + string state = 1; +} diff --git a/web/static/htmx.min.js b/web/static/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/web/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..cbed19d --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,24 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; line-height: 1.6; } +.container { max-width: 800px; margin: 0 auto; padding: 2rem; } +header h1 { margin-bottom: 2rem; } +header h1 a { color: #333; text-decoration: none; } +main { background: #fff; border-radius: 8px; padding: 2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +h2 { margin-bottom: 1rem; color: #222; } +h3 { margin: 1.5rem 0 0.5rem; color: #444; } +p { margin-bottom: 1rem; } +.form-group { margin-bottom: 1rem; } +.form-group label { display: block; margin-bottom: 0.25rem; font-weight: 600; } +.form-group input { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; } +button { padding: 0.5rem 1.5rem; background: #2563eb; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; } +button:hover { background: #1d4ed8; } +.error { background: #fee2e2; color: #991b1b; padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; } +.badge { background: #dbeafe; color: #1e40af; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.875rem; } +.status-bar { display: flex; gap: 1rem; align-items: center; padding: 0.75rem; background: #f9fafb; border-radius: 4px; margin-bottom: 1.5rem; flex-wrap: wrap; } +.status-bar a { margin-left: auto; color: #2563eb; } +table { width: 100%; border-collapse: collapse; margin: 0.5rem 0; } +th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #e5e7eb; } +th { font-weight: 600; background: #f9fafb; } +.admin-actions { margin-top: 0.5rem; } +.admin-actions button { background: #dc2626; } +.admin-actions button:hover { background: #b91c1c; } diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..404cf34 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,31 @@ +{{define "title"}} - Dashboard{{end}} +{{define "content"}} +

Dashboard

+
+ Logged in as {{.Username}} + {{if .IsAdmin}}Admin{{end}} + State: {{.State}} + Logout +
+ +

Engine Mounts

+{{if .Mounts}} + + + + {{range .Mounts}} + + {{end}} + +
NameTypePath
{{.Name}}{{.Type}}{{.MountPath}}
+{{else}} +

No engines mounted.

+{{end}} + +{{if .IsAdmin}} +

Admin Actions

+
+ +
+{{end}} +{{end}} diff --git a/web/templates/init.html b/web/templates/init.html new file mode 100644 index 0000000..cad9281 --- /dev/null +++ b/web/templates/init.html @@ -0,0 +1,17 @@ +{{define "title"}} - Initialize{{end}} +{{define "content"}} +

Initialize Metacrypt

+

Set the seal password for this Metacrypt instance. This password will be required to unseal the service after each restart.

+{{if .Error}}
{{.Error}}
{{end}} +
+
+ + +
+
+ + +
+ +
+{{end}} diff --git a/web/templates/initializing.html b/web/templates/initializing.html new file mode 100644 index 0000000..94825ab --- /dev/null +++ b/web/templates/initializing.html @@ -0,0 +1,9 @@ +{{define "title"}} - Initializing{{end}} +{{define "content"}} +

Initializing...

+

Metacrypt is being initialized. Please wait.

+
+

Checking status...

+
+{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..9274807 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,20 @@ +{{define "layout"}} + + + + + Metacrypt{{block "title" .}}{{end}} + + + + +
+
+

Metacrypt

+
+
+ {{template "content" .}} +
+
+ +{{end}} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..c3bb77b --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,21 @@ +{{define "title"}} - Login{{end}} +{{define "content"}} +

Login

+

Authenticate with your MCIAS credentials.

+{{if .Error}}
{{.Error}}
{{end}} +
+
+ + +
+
+ + +
+
+ + +
+ +
+{{end}} diff --git a/web/templates/unseal.html b/web/templates/unseal.html new file mode 100644 index 0000000..f365c98 --- /dev/null +++ b/web/templates/unseal.html @@ -0,0 +1,13 @@ +{{define "title"}} - Unseal{{end}} +{{define "content"}} +

Unseal Metacrypt

+

The service is sealed. Enter the seal password to unseal.

+{{if .Error}}
{{.Error}}
{{end}} +
+
+ + +
+ +
+{{end}}