// 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 /etc/mcias/mcias.toml [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 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", "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:] // 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) 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 [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 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) 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. `) }