package auth import ( "context" "fmt" "log/slog" "os" "path/filepath" "github.com/iceBear67/simplepages/internal/store" ) // BootstrapKeyName is the name given to the first-run admin key, so an operator // listing keys can tell it apart from ones they minted themselves. const BootstrapKeyName = "bootstrap" // EnsureAdminKey mints an admin key and writes its token to tokenPath when the // server has no usable admin key at all, and reports whether it did. // // This is the only place the server ever writes a token to disk, and it exists // because a fresh install would otherwise have no way to authenticate the call // that creates the first key. The file is 0600 and the log line tells the // operator to delete it once they have copied the token out. // // "Usable" excludes revoked and expired keys, so an installation whose only // admin key was revoked recovers by restarting rather than by hand-editing the // database. func EnsureAdminKey(ctx context.Context, db *store.DB, tokenPath string, log *slog.Logger) (bool, error) { n, err := db.CountUsableAdminKeys(ctx) if err != nil { return false, fmt.Errorf("count admin keys: %w", err) } if n > 0 { // Nothing to mint. If a token file is still lying around from an earlier // bootstrap, say so: it is a live credential until that key is revoked. if _, err := os.Lstat(tokenPath); err == nil && log != nil { log.Warn("bootstrap token file still present; delete it once the token is stored elsewhere", "path", tokenPath) } return false, nil } token, keyID, hash, err := Mint() if err != nil { return false, err } if err := writeTokenFile(tokenPath, token); err != nil { return false, err } k := &store.APIKey{ ID: keyID, SecretHash: hash[:], Scope: store.ScopeAdmin, Name: BootstrapKeyName, } if err := db.CreateKey(ctx, k); err != nil { // The file names a key that does not exist. Remove it rather than leave // an operator holding a token that will never authenticate. _ = os.Remove(tokenPath) return false, fmt.Errorf("create bootstrap key: %w", err) } if log != nil { // The key id is the public half of the token and is safe to log; the // token itself is not, which is the whole reason for the file. log.Warn("no admin key found; minted a bootstrap admin key", "key_id", keyID, "path", tokenPath, "action", "read the token, then delete this file") } return true, nil } // writeTokenFile writes the token with 0600 permissions. // // It writes to a temporary file in the same directory and renames it into // place. That is not for atomicity — nothing reads this concurrently — but // because rename replaces the destination without following it. Opening the // path directly would follow a symlink someone had planted there and write a // live credential wherever it pointed. func writeTokenFile(path, token string) error { dir := filepath.Dir(path) f, err := os.CreateTemp(dir, ".bootstrap-token-*") if err != nil { return fmt.Errorf("create bootstrap token file: %w", err) } tmp := f.Name() defer func() { f.Close() os.Remove(tmp) // no-op once the rename has succeeded }() // CreateTemp already makes the file 0600, but say so explicitly: this is the // property that matters and it should not depend on a documented default. if err := f.Chmod(0o600); err != nil { return fmt.Errorf("chmod bootstrap token file: %w", err) } if _, err := f.WriteString(token + "\n"); err != nil { return fmt.Errorf("write bootstrap token: %w", err) } if err := f.Sync(); err != nil { return fmt.Errorf("sync bootstrap token: %w", err) } if err := f.Close(); err != nil { return fmt.Errorf("close bootstrap token: %w", err) } if err := os.Rename(tmp, path); err != nil { return fmt.Errorf("install bootstrap token: %w", err) } return nil }