Unverified Commit 8bc3997a authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(breakglass): 恢复模式用邮件验证码证明管理员身份,发不出或验不过走带原因审计的 OVERRIDE (#13)

parent 5099b250
Loading
Loading
Loading
Loading
+61 −57
Changes for cmd/felis/breakglass.go: 61 added lines, 57 removed lines.
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@ import (

	"felis.lolicon.best/internal/api"
	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/platform"
	"felis.lolicon.best/internal/store"

	tea "github.com/charmbracelet/bubbletea"
@@ -33,13 +34,15 @@ import (
//
// Root is necessary but NOT sufficient for accountability: root is machine
// authority, not a human identity, so the console additionally captures WHO is
// breaking the glass. When a staff account already exists it asks the operator to
// authenticate as an existing admin (the verified identity is the accountable
// actor); when none exists yet it bootstraps the first Owner from the typed
// credential and attributes the act to the OS user. The audit row records the
// difference. This attribution is best-effort, not tamper-proof — whoever runs
// this is root and can edit Postgres directly — but it produces an honest trail
// for an honest operator, which is the point.
// breaking the glass. When a staff account already exists the operator names one
// and types the one-time code the console mails to its verified address
// (breakglass_otp.go); that account is then the accountable actor. When no code can
// be sent or proven, the typed OVERRIDE proceeds as the OS user and the audit row
// says why. When no staff account exists yet it bootstraps the first Owner and
// attributes the act to the OS user. The audit row records which of these
// happened. This attribution is best-effort, not tamper-proof — whoever runs this
// is root and can edit Postgres directly — but it produces an honest trail for an
// honest operator, which is the point.
//
// When a staff account already exists the console opens on a thin top-level menu
// (menuModel) so that operations are peers, not tails of one wizard. Two account
@@ -62,7 +65,7 @@ import (
// suspension for the interactive `cloudflared tunnel login` browser consent.

// breakGlassOverrideToken is the literal an operator must type to proceed when no
// admin credential could be verified. Requiring an explicit, deliberate word (not a
// admin could be verified by a mailed code. Requiring an explicit, deliberate word (not a
// bare Enter) keeps the unverified root override from happening by reflex.
const breakGlassOverrideToken = "OVERRIDE"

@@ -142,7 +145,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
	repo := api.NewPGRepo(drv.DB())

	// Decide bootstrap (no admin yet → typed credential mints the first Owner) vs
	// recovery (an admin exists → the operator must authenticate as one) BEFORE the
	// recovery (an admin exists → the operator proves one with a mailed code) BEFORE the
	// alt-screen TUI takes over, so a database fault surfaces as a plain error.
	adminExists, err := repo.AdminExists(ctx)
	if err != nil {
@@ -150,7 +153,12 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
		return 1
	}

	res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists)
	// Recovery mails its code through [smtp]; the relay is opened only if a code is
	// asked for.
	host, _ := os.Hostname()
	recovery := recoveryConfig{open: hostRecoveryMailer(cfg.SMTP, platform.DefaultControlNamespace), host: host}

	res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists, recovery)
	if err != nil {
		fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
		return 1
@@ -174,6 +182,9 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
			fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local session sign-in is ENABLED.\n", res.username)
		}
		fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
		if res.mode == "root_override" {
			fmt.Fprintln(stdout, "No admin was proven by an email code; the audit row records this run as an unverified root override and why.")
		}
		if res.setupTokenURL != "" {
			fmt.Fprintf(stdout, "One-time setup URL (opens a lockdown session to verify email / enroll passkey):\n\n    %s\n\n", res.setupTokenURL)
		}
@@ -258,29 +269,6 @@ func newOwnerID() string {
	return "usr-" + hex.EncodeToString(b[:])
}

// authenticateAdmin resolves a typed admin username for recovery-mode attribution.
// Password verification is gone (passwordless design); Phase 3 replaces this with
// email-OTP recovery. For now it confirms the named admin exists.
func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matched string, ok bool, err error) {
	username = strings.TrimSpace(username)
	if username == "" {
		return "", false, nil
	}
	u, err := s.UserByUsername(ctx, username)
	if errors.Is(err, api.ErrNotFound) {
		return "", false, nil
	}
	if err != nil {
		return "", false, err
	}
	// Staff means admin OR owner: recovery attribution must accept the Owner (the
	// primary break-glass identity), not just plain admins.
	if u.Role != "admin" && u.Role != "owner" {
		return "", false, nil
	}
	return u.Username, true, nil
}

// provisionOwner mints or resets the single Owner account direct-to-Postgres,
// passwordless. The account is role=owner with no password — the Owner completes
// passwordless login setup via the web setup-token flow after `felis setup`.
@@ -371,6 +359,10 @@ type breakGlassOp struct {
	ownerUsername  string
	ownerEmail     string
	attemptedAdmin string // recovery / override: the admin username the operator typed
	verifiedBy     string // recovery: how the admin was proven (verifiedByEmailOTP)
	codeSentTo     string // recovery: the address the proving code went to
	otpSkipped     string // root_override: why no code proved an admin (otpSkip*)
	otpSkipDetail  string // root_override: what failed, when something did
}

// breakGlassOutcome is what performBreakGlass reports back to the TUI.
@@ -494,16 +486,7 @@ func auditSetupMCBind(ctx context.Context, s ownerStore, osUser, mcUUID, authSou
// does not fail the recovery if this write fails — and intentionally honest: it
// records attribution, it does not prove it (a malicious root can edit the row).
func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
	payload := map[string]any{
		"mode":     op.mode,
		"owner":    op.ownerUsername,
		"os_user":  op.osUser,
		"verified": op.mode == "recovery",
	}
	if op.attemptedAdmin != "" {
		payload["admin_account"] = op.attemptedAdmin
	}
	blob, err := json.Marshal(payload)
	blob, err := json.Marshal(breakGlassPayload(op, "owner"))
	if err != nil {
		return err
	}
@@ -515,6 +498,33 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
	})
}

// breakGlassPayload is the who/how both account audits carry, with the account the
// run wrote under subjectKey. verified is true only for a run a mailed code proved;
// such a run names the address the code went to, and an override names why no code
// proved anyone.
func breakGlassPayload(op breakGlassOp, subjectKey string) map[string]any {
	payload := map[string]any{
		"mode":     op.mode,
		subjectKey: op.ownerUsername,
		"os_user":  op.osUser,
		"verified": op.verifiedBy != "",
	}
	if op.attemptedAdmin != "" {
		payload["admin_account"] = op.attemptedAdmin
	}
	if op.verifiedBy != "" {
		payload["verified_by"] = op.verifiedBy
		payload["code_sent_to"] = op.codeSentTo
	}
	if op.otpSkipped != "" {
		payload["otp_skipped"] = op.otpSkipped
		if op.otpSkipDetail != "" {
			payload["otp_skip_detail"] = op.otpSkipDetail
		}
	}
	return payload
}

// performAddOperator mints a NEW Operator account and records a best-effort
// accountability row. It mirrors performBreakGlass — passwordless — with two
// deliberate differences. (1) It provisions insert-only (provisionOperator), so it
@@ -538,16 +548,7 @@ func performAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) (bre
// break_glass.operator_create action, naming the new account under an "operator" key
// rather than "owner".
func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error {
	payload := map[string]any{
		"mode":     op.mode,
		"operator": op.ownerUsername,
		"os_user":  op.osUser,
		"verified": op.mode == "recovery",
	}
	if op.attemptedAdmin != "" {
		payload["admin_account"] = op.attemptedAdmin
	}
	blob, err := json.Marshal(payload)
	blob, err := json.Marshal(breakGlassPayload(op, "operator"))
	if err != nil {
		return err
	}
@@ -626,16 +627,19 @@ const (
	cloudflareAPITokenDocsURL        = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/"
)

func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
	return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass)
func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, recovery recoveryConfig) (breakGlassResult, error) {
	return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
}

// runSetupTUI never reaches recovery: setup with a staff account present lands on
// the status screen, so it has no relay to hand over.
func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
	return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup)
	return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
}

func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) {
func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) {
	rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode)
	rm.recovery = recovery
	final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
	if err != nil {
		return breakGlassResult{}, err
+252 −0
Changes for cmd/felis/breakglass_otp.go: 252 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"context"
	"crypto/rand"
	"crypto/subtle"
	"errors"
	"fmt"
	"math/big"
	"os"
	"strings"
	"time"

	"felis.lolicon.best/internal/api"
	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/platform"
)

// Recovery mode proves who is breaking the glass (#13). Naming a staff account is
// where it starts: the console then mails a one-time code to that account's verified
// address, and only that code, typed within recoveryCodeTTL, makes the run a
// recovery attributed to the account. Every other ending — no such account, no
// verified address, no relay, a send that fails, a wrong or late code, or the
// operator giving up on the mail — leads to the typed OVERRIDE, which the audit row
// records as an unverified root_override together with the reason (otp_skipped).
// The code goes through the same [smtp] relay as the panel's login codes, so with
// that relay down recovery still works, as an override that says why.

const (
	recoveryCodeTTL      = 10 * time.Minute
	recoveryCodeAttempts = 5
)

// The reasons a run fell back to the override, recorded as otp_skipped.
const (
	otpSkipUnknownAdmin    = "unknown_admin"
	otpSkipNoVerifiedEmail = "no_verified_email"
	otpSkipNoRelay         = "no_relay"
	otpSkipSendFailed      = "send_failed"
	otpSkipCodeExpired     = "code_expired"
	otpSkipCodeRejected    = "code_rejected"
	otpSkipByOperator      = "operator_skipped"
)

// verifiedByEmailOTP is the audit's verified_by for a recovery the mailed code proved.
const verifiedByEmailOTP = "email_otp"

// recoveryMailer is the one relay call a recovery code needs; *mail.SMTP has it.
type recoveryMailer interface {
	SendNotice(ctx context.Context, email, subject, body string) error
}

// recoveryConfig is what the console needs to mail a recovery code. open resolves
// the relay only when a code is about to go out, so a console used to halt a server
// never touches [smtp] or the cluster; its error says why no relay is available.
// host names this machine in the mail. The zero value has no relay.
type recoveryConfig struct {
	open func(ctx context.Context) (recoveryMailer, error)
	host string
	now  func() time.Time
}

func (r recoveryConfig) clock() time.Time {
	if r.now != nil {
		return r.now()
	}
	return time.Now()
}

// recoveryCode is one mailed code: its value, when it stops working, and how many
// wrong codes were typed against it.
type recoveryCode struct {
	value    string
	expires  time.Time
	failures int
}

func newRecoveryCode(now time.Time) (*recoveryCode, error) {
	n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
	if err != nil {
		return nil, fmt.Errorf("generate recovery code: %w", err)
	}
	return &recoveryCode{value: fmt.Sprintf("%06d", n.Int64()), expires: now.Add(recoveryCodeTTL)}, nil
}

type codeVerdict int

const (
	codeAccepted codeVerdict = iota
	codeWrong
	codeExpired
	codeExhausted
)

// check compares a typed code in constant time. Each wrong code counts; the one
// that reaches recoveryCodeAttempts exhausts the code, which then accepts nothing,
// and neither does an expired one.
func (c *recoveryCode) check(typed string, now time.Time) codeVerdict {
	if c.failures >= recoveryCodeAttempts {
		return codeExhausted
	}
	if !now.Before(c.expires) {
		return codeExpired
	}
	if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(typed)), []byte(c.value)) == 1 {
		return codeAccepted
	}
	c.failures++
	if c.failures >= recoveryCodeAttempts {
		return codeExhausted
	}
	return codeWrong
}

func (c *recoveryCode) attemptsLeft() int { return recoveryCodeAttempts - c.failures }

// recoveryStart is where naming an admin led: a code on its way to that admin, or
// the reason the run has to fall back to the override.
type recoveryStart struct {
	admin  *api.StaffUser // the named staff account; nil when none matched
	code   *recoveryCode  // set when the code went out
	skip   string         // otpSkip* when it did not
	detail string         // what failed, for the override screen and the audit row
}

// resolveAdmin loads the staff account (admin or owner) a typed username names, or
// nil when there is none.
func resolveAdmin(ctx context.Context, s ownerStore, username string) (*api.StaffUser, error) {
	username = strings.TrimSpace(username)
	if username == "" {
		return nil, nil
	}
	u, err := s.UserByUsername(ctx, username)
	if errors.Is(err, api.ErrNotFound) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	// Staff means admin or owner: the Owner is the primary break-glass identity.
	if u.Role != "admin" && u.Role != "owner" {
		return nil, nil
	}
	return u, nil
}

// beginRecovery resolves the named admin and mails it a recovery code. Only a
// datastore or entropy fault is an error; every other way the code cannot go out is
// a recoveryStart with skip set.
func beginRecovery(ctx context.Context, s ownerStore, rc recoveryConfig, username, osUser string, op bgOperation) (recoveryStart, error) {
	admin, err := resolveAdmin(ctx, s, username)
	if err != nil {
		return recoveryStart{}, err
	}
	if admin == nil {
		return recoveryStart{skip: otpSkipUnknownAdmin}, nil
	}
	st := recoveryStart{admin: admin}
	// An address nobody ever proved vouches for nobody.
	email := strings.TrimSpace(admin.Email)
	if email == "" || !admin.EmailVerified {
		st.skip = otpSkipNoVerifiedEmail
		return st, nil
	}
	if rc.open == nil {
		st.skip, st.detail = otpSkipNoRelay, "this console has no mail relay"
		return st, nil
	}
	relay, err := rc.open(ctx)
	if err != nil {
		st.skip, st.detail = otpSkipNoRelay, err.Error()
		return st, nil
	}
	code, err := newRecoveryCode(rc.clock())
	if err != nil {
		return recoveryStart{}, err
	}
	sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	subject, body := recoveryMail(code.value, rc.host, osUser, admin.Username, op)
	if err := relay.SendNotice(sendCtx, email, subject, body); err != nil {
		st.skip, st.detail = otpSkipSendFailed, err.Error()
		return st, nil
	}
	st.code = code
	return st, nil
}

// recoveryMail words the code mail. It says where, by whom and for what the console
// was opened, so an admin who did not ask for it learns that root on that machine is
// in someone else's hands.
func recoveryMail(code, host, osUser, admin string, op bgOperation) (subject, body string) {
	what, whatZH := "reset the Owner account", "重置 Owner 账号"
	if op == bgAddOperator {
		what, whatZH = "add an Operator account", "添加 Operator 账号"
	}
	if host == "" {
		host = "the Felis host"
	}
	minutes := int(recoveryCodeTTL / time.Minute)
	subject = "Felis break-glass recovery code / 紧急恢复验证码"
	body = fmt.Sprintf(`Someone with root on %[1]s (OS user %[2]s) opened felis breakGlass and named your staff account %[3]q to %[4]s.

Recovery code: %[6]s
It works for %[7]d minutes.

If this was not you, root on that machine is in someone else's hands: change its credentials and read the audit log for break_glass entries.

有人在 %[1]s 上以 root 身份(系统用户 %[2]s)打开了 felis breakGlass,指名你的管理员账号 %[3]q 来%[5]s。

恢复验证码:%[6]s
%[7]d 分钟内有效。

如果不是你本人,这台机器的 root 已落入他人之手:请更换它的凭据,并查看审计日志中的 break_glass 记录。
`, host, osUser, admin, what, whatZH, code, minutes)
	return subject, body
}

// maskEmail keeps the first character of the local part and the domain, enough for
// the operator to recognise the address without putting it on screen whole.
func maskEmail(email string) string {
	at := strings.LastIndex(email, "@")
	if at <= 0 {
		return "***"
	}
	return email[:1] + strings.Repeat("*", max(at-1, 3)) + email[at:]
}

// hostRecoveryMailer opens the [smtp] relay from the host the way the watchdog does:
// the password is the env var password_ref names when that is set, else the
// felis-smtp Secret, whose absence means a relay without AUTH.
func hostRecoveryMailer(c config.SMTPConfig, controlNS string) func(context.Context) (recoveryMailer, error) {
	return func(ctx context.Context) (recoveryMailer, error) {
		if strings.TrimSpace(c.Host) == "" {
			return nil, errors.New("[smtp] is not configured in felis.toml")
		}
		if ref := c.PasswordRef; ref != "" && os.Getenv(ref) != "" {
			return smtpRelay(c, os.Getenv(ref)), nil
		}
		cl, err := buildSystemServerClient()
		if err != nil {
			return nil, fmt.Errorf("reach the cluster for the relay password: %w", err)
		}
		ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
		defer cancel()
		password, err := smtpSecretPassword(ctx, cl, controlNS)
		if err != nil {
			return nil, fmt.Errorf("read the relay password from %s/%s: %w", controlNS, platform.SMTPSecretName, err)
		}
		return smtpRelay(c, password), nil
	}
}
+498 −0

File added.

Preview size limit exceeded, changes collapsed.

+56 −40

File changed.

Preview size limit exceeded, changes collapsed.

+144 −25

File changed.

Preview size limit exceeded, changes collapsed.

Loading