- Web UI admin password reset now enforces admin role server-side (was cookie-auth + CSRF only; any logged-in user could previously reset any account's password) - Added self-service password change UI at GET/PUT /profile: current_password + new_password + confirm_password; server-side equality check; lockout + Argon2id verification; revokes all other sessions on success - password_change_form.html fragment and profile.html page - Nav bar actor name now links to /profile - policy: ActionChangePassword + default rule -7 allowing human accounts to change their own password - openapi.yaml: built-in rules count updated to -7 Migration recovery: - mciasdb schema force --version N: new subcommand to clear dirty migration state without running SQL (break-glass) - schema subcommands bypass auto-migration on open so the tool stays usable when the database is dirty - Migrate(): shim no longer overrides schema_migrations when it already has an entry; duplicate-column error on the latest migration is force-cleaned and treated as success (handles columns added outside the runner) Security: - Admin role is now validated in handleAdminResetPassword before any DB access; non-admin receives 403 - handleSelfChangePassword follows identical lockout + constant-time Argon2id path as the REST self-service handler; current password required to prevent token-theft account takeover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
267 lines
7.7 KiB
Go
267 lines
7.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 /etc/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
|
|
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 <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
|
|
|
|
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.
|
|
`)
|
|
}
|