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:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.claude/skills/checkpoint/SKILL.md
Normal file
8
.claude/skills/checkpoint/SKILL.md
Normal 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`
|
||||||
8
.claude/tasks/security-audit/TASK.md
Normal file
8
.claude/tasks/security-audit/TASK.md
Normal 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
27
.gitignore
vendored
Normal 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
22
CLAUDE.md
Normal 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
34
Dockerfile
Normal 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
24
Makefile
Normal 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
15
PROJECT.md
Normal 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
415
RUNBOOK.md
Normal 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
93
cmd/metacrypt/init.go
Normal 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
14
cmd/metacrypt/main.go
Normal 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
33
cmd/metacrypt/root.go
Normal 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
90
cmd/metacrypt/server.go
Normal 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
58
cmd/metacrypt/snapshot.go
Normal 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
66
cmd/metacrypt/status.go
Normal 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
|
||||||
|
}
|
||||||
24
deploy/docker/docker-compose.yml
Normal file
24
deploy/docker/docker-compose.yml
Normal 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:
|
||||||
22
deploy/examples/metacrypt-docker.toml
Normal file
22
deploy/examples/metacrypt-docker.toml
Normal 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"
|
||||||
38
deploy/examples/metacrypt.toml
Normal file
38
deploy/examples/metacrypt.toml
Normal 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
23
deploy/scripts/backup.sh
Executable 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
56
deploy/scripts/install.sh
Executable 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"
|
||||||
15
deploy/systemd/metacrypt-backup.service
Normal file
15
deploy/systemd/metacrypt-backup.service
Normal 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
|
||||||
10
deploy/systemd/metacrypt-backup.timer
Normal file
10
deploy/systemd/metacrypt-backup.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily Metacrypt database backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 02:00:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
45
deploy/systemd/metacrypt.service
Normal file
45
deploy/systemd/metacrypt.service
Normal 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
39
go.mod
Normal 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
109
go.sum
Normal 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
125
internal/auth/auth.go
Normal 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
|
||||||
|
}
|
||||||
52
internal/auth/auth_test.go
Normal file
52
internal/auth/auth_test.go
Normal 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
167
internal/barrier/barrier.go
Normal 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()
|
||||||
|
}
|
||||||
159
internal/barrier/barrier_test.go
Normal file
159
internal/barrier/barrier_test.go
Normal 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
101
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
64
internal/config/config_test.go
Normal file
64
internal/config/config_test.go
Normal 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
137
internal/crypto/crypto.go
Normal 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
|
||||||
|
}
|
||||||
132
internal/crypto/crypto_test.go
Normal file
132
internal/crypto/crypto_test.go
Normal 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
43
internal/db/db.go
Normal 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
44
internal/db/db_test.go
Normal 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
70
internal/db/migrate.go
Normal 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(¤t); err != nil {
|
||||||
|
return fmt.Errorf("db: get migration version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := current; i < len(migrations); i++ {
|
||||||
|
version := i + 1
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: begin migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(migrations[i]); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("db: migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("db: record migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("db: commit migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
179
internal/engine/engine.go
Normal file
179
internal/engine/engine.go
Normal 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
|
||||||
|
}
|
||||||
120
internal/engine/engine_test.go
Normal file
120
internal/engine/engine_test.go
Normal 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
188
internal/policy/policy.go
Normal 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
|
||||||
|
}
|
||||||
177
internal/policy/policy_test.go
Normal file
177
internal/policy/policy_test.go
Normal 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
242
internal/seal/seal.go
Normal 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
136
internal/seal/seal_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
internal/server/middleware.go
Normal file
109
internal/server/middleware.go
Normal 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
532
internal/server/routes.go
Normal 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
77
internal/server/server.go
Normal 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)
|
||||||
|
}
|
||||||
179
internal/server/server_test.go
Normal file
179
internal/server/server_test.go
Normal 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
19
metacrypt.toml.example
Normal 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"
|
||||||
32
proto/metacrypt/v1/auth.proto
Normal file
32
proto/metacrypt/v1/auth.proto
Normal 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;
|
||||||
|
}
|
||||||
5
proto/metacrypt/v1/common.proto
Normal file
5
proto/metacrypt/v1/common.proto
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package metacrypt.v1;
|
||||||
|
|
||||||
|
option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1";
|
||||||
47
proto/metacrypt/v1/engine.proto
Normal file
47
proto/metacrypt/v1/engine.proto
Normal 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;
|
||||||
|
}
|
||||||
41
proto/metacrypt/v1/policy.proto
Normal file
41
proto/metacrypt/v1/policy.proto
Normal 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 {}
|
||||||
36
proto/metacrypt/v1/system.proto
Normal file
36
proto/metacrypt/v1/system.proto
Normal 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
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
24
web/static/style.css
Normal 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; }
|
||||||
31
web/templates/dashboard.html
Normal file
31
web/templates/dashboard.html
Normal 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
17
web/templates/init.html
Normal 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}}
|
||||||
9
web/templates/initializing.html
Normal file
9
web/templates/initializing.html
Normal 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
20
web/templates/layout.html
Normal 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
21
web/templates/login.html
Normal 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
13
web/templates/unseal.html
Normal 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}}
|
||||||
Reference in New Issue
Block a user