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) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 20:43:11 -07:00
commit 4ddd32b117
60 changed files with 4644 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git:*)"
]
}
}

View File

@@ -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 "<message>"` and verify with `git log -1`

View File

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

27
.gitignore vendored Normal file
View File

@@ -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

22
CLAUDE.md Normal file
View File

@@ -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

34
Dockerfile Normal file
View File

@@ -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"]

24
Makefile Normal file
View File

@@ -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

15
PROJECT.md Normal file
View File

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

415
RUNBOOK.md Normal file
View File

@@ -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://<host>: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":"<seal-password>"}'
# Via web UI
# Navigate to https://<host>: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 <admin-token>"
```
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 <token>"
```
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.

93
cmd/metacrypt/init.go Normal file
View File

@@ -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
}

14
cmd/metacrypt/main.go Normal file
View File

@@ -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)
}
}

33
cmd/metacrypt/root.go Normal file
View File

@@ -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()
}

90
cmd/metacrypt/server.go Normal file
View File

@@ -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())
}

58
cmd/metacrypt/snapshot.go Normal file
View File

@@ -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)
}

66
cmd/metacrypt/status.go Normal file
View File

@@ -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
}

View File

@@ -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:

View File

@@ -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"

View File

@@ -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"

23
deploy/scripts/backup.sh Executable file
View File

@@ -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

56
deploy/scripts/install.sh Executable file
View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Daily Metacrypt database backup
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target

View File

@@ -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

39
go.mod Normal file
View File

@@ -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
)

109
go.sum Normal file
View File

@@ -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=

125
internal/auth/auth.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

167
internal/barrier/barrier.go Normal file
View File

@@ -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()
}

View File

@@ -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")
}
}

101
internal/config/config.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

137
internal/crypto/crypto.go Normal file
View File

@@ -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
}

View File

@@ -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)")
}
}

43
internal/db/db.go Normal file
View File

@@ -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
}

44
internal/db/db_test.go Normal file
View File

@@ -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)
}
}

70
internal/db/migrate.go Normal file
View File

@@ -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(&current); 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
}

179
internal/engine/engine.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

188
internal/policy/policy.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

242
internal/seal/seal.go Normal file
View File

@@ -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
}

136
internal/seal/seal_test.go Normal file
View File

@@ -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)
}
}
}

View File

@@ -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)
}

532
internal/server/routes.go Normal file
View File

@@ -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)
}

77
internal/server/server.go Normal file
View File

@@ -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)
}

View File

@@ -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")
}
}

19
metacrypt.toml.example Normal file
View File

@@ -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"

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
syntax = "proto3";
package metacrypt.v1;
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1";

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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;
}

1
web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

24
web/static/style.css Normal file
View File

@@ -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; }

View File

@@ -0,0 +1,31 @@
{{define "title"}} - Dashboard{{end}}
{{define "content"}}
<h2>Dashboard</h2>
<div class="status-bar">
<span>Logged in as <strong>{{.Username}}</strong></span>
{{if .IsAdmin}}<span class="badge">Admin</span>{{end}}
<span>State: <strong>{{.State}}</strong></span>
<a href="/login" onclick="fetch('/v1/auth/logout',{method:'POST'})">Logout</a>
</div>
<h3>Engine Mounts</h3>
{{if .Mounts}}
<table>
<thead><tr><th>Name</th><th>Type</th><th>Path</th></tr></thead>
<tbody>
{{range .Mounts}}
<tr><td>{{.Name}}</td><td>{{.Type}}</td><td>{{.MountPath}}</td></tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No engines mounted.</p>
{{end}}
{{if .IsAdmin}}
<h3>Admin Actions</h3>
<div class="admin-actions">
<button hx-post="/v1/seal" hx-confirm="Are you sure you want to seal the service?">Seal Service</button>
</div>
{{end}}
{{end}}

17
web/templates/init.html Normal file
View File

@@ -0,0 +1,17 @@
{{define "title"}} - Initialize{{end}}
{{define "content"}}
<h2>Initialize Metacrypt</h2>
<p>Set the seal password for this Metacrypt instance. This password will be required to unseal the service after each restart.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="POST" action="/init">
<div class="form-group">
<label for="password">Seal Password</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<div class="form-group">
<label for="confirm">Confirm Password</label>
<input type="password" id="confirm" name="confirm" required>
</div>
<button type="submit">Initialize</button>
</form>
{{end}}

View File

@@ -0,0 +1,9 @@
{{define "title"}} - Initializing{{end}}
{{define "content"}}
<h2>Initializing...</h2>
<p>Metacrypt is being initialized. Please wait.</p>
<div hx-get="/v1/status" hx-trigger="every 2s" hx-swap="none"
hx-on::after-request="if(JSON.parse(event.detail.xhr.responseText).state==='unsealed')window.location='/dashboard'">
<p>Checking status...</p>
</div>
{{end}}

20
web/templates/layout.html Normal file
View File

@@ -0,0 +1,20 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metacrypt{{block "title" .}}{{end}}</title>
<script src="/static/htmx.min.js"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1><a href="/">Metacrypt</a></h1>
</header>
<main>
{{template "content" .}}
</main>
</div>
</body>
</html>{{end}}

21
web/templates/login.html Normal file
View File

@@ -0,0 +1,21 @@
{{define "title"}} - Login{{end}}
{{define "content"}}
<h2>Login</h2>
<p>Authenticate with your MCIAS credentials.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="totp_code">TOTP Code (if enabled)</label>
<input type="text" id="totp_code" name="totp_code" autocomplete="one-time-code">
</div>
<button type="submit">Login</button>
</form>
{{end}}

13
web/templates/unseal.html Normal file
View File

@@ -0,0 +1,13 @@
{{define "title"}} - Unseal{{end}}
{{define "content"}}
<h2>Unseal Metacrypt</h2>
<p>The service is sealed. Enter the seal password to unseal.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="POST" action="/unseal">
<div class="form-group">
<label for="password">Seal Password</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<button type="submit">Unseal</button>
</form>
{{end}}