Files
mcias/cmd/mciasdb/main.go
Kyle Isom b0afe3b993 Align with engineering standards (steps 1-5)
- Rename dist/ -> deploy/ with subdirs examples/, scripts/,
  systemd/ per standard repository layout
- Update .gitignore: gitignore all of dist/ (build output only)
- Makefile: all target is now vet->lint->test->build; add vet,
  proto-lint, devserver targets; CGO_ENABLED=0 for builds
  (modernc.org/sqlite is pure-Go, no C toolchain needed);
  CGO_ENABLED=1 retained for tests (race detector)
- Dockerfile: builder -> golang:1.26-alpine, runtime ->
  alpine:3.21; drop libc6 dep; add /srv/mcias/certs and
  /srv/mcias/backups to image
- deploy/systemd/mcias.service: add RestrictSUIDSGID=true
- deploy/systemd/mcias-backup.service: new oneshot backup unit
- deploy/systemd/mcias-backup.timer: daily 02:00 UTC, 5m jitter
- deploy/scripts/install.sh: install backup units and enable
  timer; create certs/ and backups/ subdirs in /srv/mcias
- buf.yaml: add proto linting config for proto-lint target
- internal/db: add Snapshot and SnapshotDir methods (VACUUM INTO)
- cmd/mciasdb: add snapshot subcommand; no master key required
2026-03-16 20:26:43 -07:00

294 lines
8.7 KiB
Go

// Command mciasdb is the MCIAS database maintenance tool.
//
// It operates directly on the SQLite file, bypassing the mciassrv API.
// Use it for break-glass recovery, offline inspection, schema verification,
// and maintenance tasks when the server is unavailable.
//
// mciasdb requires the same master key configuration as mciassrv (passphrase
// environment variable or keyfile) to decrypt secrets at rest.
//
// Usage:
//
// mciasdb --config /srv/mcias/mcias.toml <command> [subcommand] [flags]
//
// Commands:
//
// schema verify
// schema migrate
// schema force --version N
//
// account list
// account get --id UUID
// account create --username NAME --type human|system
// account set-password --id UUID
// account set-status --id UUID --status active|inactive|deleted
// account reset-totp --id UUID
//
// role list --id UUID
// role grant --id UUID --role ROLE
// role revoke --id UUID --role ROLE
//
// token list --id UUID
// token revoke --jti JTI
// token revoke-all --id UUID
//
// prune tokens
//
// audit tail [--n N]
// audit query [--account UUID] [--type TYPE] [--since RFC3339] [--json]
//
// pgcreds get --id UUID
// pgcreds set --id UUID --host H --port P --db D --user U
//
// snapshot [--retain-days N]
package main
import (
"errors"
"flag"
"fmt"
"os"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
func main() {
configPath := flag.String("config", "/srv/mcias/mcias.toml", "path to TOML configuration file")
flag.Usage = usage
flag.Parse()
args := flag.Args()
if len(args) == 0 {
usage()
os.Exit(1)
}
command := args[0]
subArgs := args[1:]
// snapshot loads only the config (no master key needed — VACUUM INTO does
// not access encrypted columns) and must be handled before openDB, which
// requires the master key passphrase env var.
if command == "snapshot" {
runSnapshot(*configPath, subArgs)
return
}
// schema subcommands manage migrations themselves and must not trigger
// auto-migration on open (a dirty database would prevent the tool from
// opening at all, blocking recovery operations like "schema force").
var (
database *db.DB
masterKey []byte
err error
)
if command == "schema" {
database, masterKey, err = openDBRaw(*configPath)
} else {
database, masterKey, err = openDB(*configPath)
}
if err != nil {
fatalf("%v", err)
}
defer func() {
_ = database.Close()
// Zero the master key when done to reduce the window of in-memory exposure.
for i := range masterKey {
masterKey[i] = 0
}
}()
tool := &tool{db: database, masterKey: masterKey}
switch command {
case "schema":
tool.runSchema(subArgs)
case "account":
tool.runAccount(subArgs)
case "role":
tool.runRole(subArgs)
case "token":
tool.runToken(subArgs)
case "prune":
tool.runPrune(subArgs)
case "audit":
tool.runAudit(subArgs)
case "pgcreds":
tool.runPGCreds(subArgs)
case "webauthn":
tool.runWebAuthn(subArgs)
case "rekey":
tool.runRekey(subArgs)
default:
fatalf("unknown command %q; run with no args for usage", command)
}
}
// tool holds shared state for all subcommand handlers.
type tool struct {
db *db.DB
masterKey []byte
}
// openDB loads the config, derives the master key, opens and migrates the DB.
//
// Security: Master key derivation uses the same logic as mciassrv so that
// the same passphrase always yields the same key and encrypted secrets remain
// readable. The passphrase env var is unset immediately after reading.
func openDB(configPath string) (*db.DB, []byte, error) {
database, masterKey, err := openDBRaw(configPath)
if err != nil {
return nil, nil, err
}
if err := db.Migrate(database); err != nil {
_ = database.Close()
return nil, nil, fmt.Errorf("migrate database: %w", err)
}
return database, masterKey, nil
}
// openDBRaw opens the database without running migrations. Used by schema
// subcommands so they remain operational even when the database is in a dirty
// migration state (e.g. to allow "schema force" to clear a dirty flag).
func openDBRaw(configPath string) (*db.DB, []byte, error) {
cfg, err := config.Load(configPath)
if err != nil {
return nil, nil, fmt.Errorf("load config: %w", err)
}
database, err := db.Open(cfg.Database.Path)
if err != nil {
return nil, nil, fmt.Errorf("open database %q: %w", cfg.Database.Path, err)
}
masterKey, err := deriveMasterKey(cfg, database)
if err != nil {
_ = database.Close()
return nil, nil, fmt.Errorf("derive master key: %w", err)
}
return database, masterKey, nil
}
// deriveMasterKey derives or loads the AES-256-GCM master key from config,
// using identical logic to mciassrv so that encrypted DB secrets are readable.
//
// Security: Key file must be exactly 32 bytes (AES-256). Passphrase is read
// from the environment variable named in cfg.MasterKey.PassphraseEnv and
// cleared from the environment immediately after. The Argon2id KDF salt is
// loaded from the database; if absent the DB has no encrypted secrets yet.
func deriveMasterKey(cfg *config.Config, database *db.DB) ([]byte, error) {
if cfg.MasterKey.KeyFile != "" {
data, err := os.ReadFile(cfg.MasterKey.KeyFile)
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
if len(data) != 32 {
return nil, fmt.Errorf("key file must be exactly 32 bytes, got %d", len(data))
}
key := make([]byte, 32)
copy(key, data)
for i := range data {
data[i] = 0
}
return key, nil
}
passphrase := os.Getenv(cfg.MasterKey.PassphraseEnv)
if passphrase == "" {
return nil, fmt.Errorf("environment variable %q is not set or empty", cfg.MasterKey.PassphraseEnv)
}
_ = os.Unsetenv(cfg.MasterKey.PassphraseEnv)
salt, err := database.ReadMasterKeySalt()
if errors.Is(err, db.ErrNotFound) {
// No salt means the database has no encrypted secrets yet.
// Generate a new salt so future writes are consistent.
salt, err = crypto.NewSalt()
if err != nil {
return nil, fmt.Errorf("generate master key salt: %w", err)
}
if err := database.WriteMasterKeySalt(salt); err != nil {
return nil, fmt.Errorf("store master key salt: %w", err)
}
} else if err != nil {
return nil, fmt.Errorf("read master key salt: %w", err)
}
key, err := crypto.DeriveKey(passphrase, salt)
if err != nil {
return nil, fmt.Errorf("derive master key: %w", err)
}
return key, nil
}
// fatalf prints an error message to stderr and exits with code 1.
func fatalf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "mciasdb: "+format+"\n", args...)
os.Exit(1)
}
// exitCode1 exits with code 1 without printing any message.
// Used when the message has already been printed.
func exitCode1() {
os.Exit(1)
}
func usage() {
fmt.Fprint(os.Stderr, `mciasdb - MCIAS database maintenance tool
Usage: mciasdb --config PATH <command> [subcommand] [flags]
Global flags:
--config Path to TOML config file (default: mcias.toml)
Commands:
schema verify Check schema version; exit 1 if migrations pending
schema migrate Apply any pending schema migrations
schema force --version N Force schema version (clears dirty state)
account list List all accounts
account get --id UUID
account create --username NAME --type human|system
account set-password --id UUID (prompts interactively)
account set-status --id UUID --status active|inactive|deleted
account reset-totp --id UUID
account reset-webauthn --id UUID
webauthn list --id UUID
webauthn delete --id UUID --credential-id N
webauthn reset --id UUID
role list --id UUID
role grant --id UUID --role ROLE
role revoke --id UUID --role ROLE
token list --id UUID
token revoke --jti JTI
token revoke-all --id UUID
prune tokens Delete expired token_revocation rows
audit tail [--n N] (default 50)
audit query [--account UUID] [--type TYPE] [--since RFC3339] [--json]
pgcreds get --id UUID
pgcreds set --id UUID --host H [--port P] --db D --user U
(password is prompted interactively)
rekey Re-encrypt all secrets under a new master passphrase
(prompts interactively; requires server to be stopped)
snapshot Write a timestamped VACUUM INTO backup to
<db-dir>/backups/; prune backups older than
--retain-days days (default 30, 0 = keep all).
Does not require the master key passphrase.
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
file. Use it only when the server is unavailable or for break-glass recovery.
All write operations are recorded in the audit log.
`)
}