// Package claim is the customer-claim password-code engine (v0.50.0, closes DRILL-day0-vm F-4). // The hub generates a one-time claim code, emails it (Hungarian) to the REGISTERED customer // address, and stores only bcrypt(code) — the plaintext exists solely inside the email send // (the retrieval-passphrase custody rule). The controller receives the hash (+ generation) // baked into controller.yaml at Day-0 and via the report ACK for live boxes, and gates its // dashboard until the customer claims it by setting their own password. Reset rides the same // engine: a rotation bumps the generation (single active code) and NEVER clears claimed_at. package claim import ( "fmt" "log" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) // codeWords is the claim/reset code length: 3 Hungarian words (~44 bits with the ~29K list) — // dictatable over the phone, and the controller's 5-attempt/15-min lockout makes online guessing // infeasible. const codeWords = 3 // maxResetPerDay is the hub-side cap on controller-forwarded reset requests (per customer). const maxResetPerDay = 3 // EmailKind selects the Hungarian template for a code delivery. type EmailKind string const ( EmailClaim EmailKind = "claim" // first setup: "Elindult a Felhom szervered" EmailReset EmailKind = "reset" // forgotten password EmailClaimed EmailKind = "claimed" // confirmation after a successful claim (carries no code) ) // Mailer delivers a claim-arc email. The code is passed through and MUST NOT be persisted or // logged by implementations (empty for EmailClaimed). kind is one of the EmailKind constants // (plain string in the signature so implementations need no import of this package). type Mailer interface { SendClaimEmail(kind, customerID, email, domain, code string) error } // Engine composes the store, the code generator and the mailer. All methods are idempotent or // explicitly rotating; none ever stores or logs a plaintext code. type Engine struct { Store *store.Store Mailer Mailer Logger *log.Logger } func (e *Engine) logf(f string, a ...any) { if e.Logger != nil { e.Logger.Printf(f, a...) } } // newCode generates a fresh code and its bcrypt hash. func newCode() (code, hash string, err error) { code, err = configgen.RandomPassphrase(codeWords) if err != nil { return "", "", fmt.Errorf("claim: generating code: %w", err) } h, err := bcrypt.GenerateFromPassword([]byte(code), 10) if err != nil { return "", "", fmt.Errorf("claim: hashing code: %w", err) } return code, string(h), nil } // rotateAndSend rotates the code (generation+1 / create) and emails it. The email failure path // keeps the rotated hash (the gate stays armed) and is LOUD: the operator sees emailed_at unset // on the customer page and can resend. Returns the new generation. func (e *Engine) rotateAndSend(cc *store.CustomerConfig, kind EmailKind) (int, error) { code, hash, err := newCode() if err != nil { return 0, err } gen, err := e.Store.RotateClaimCode(cc.CustomerID, hash) if err != nil { return 0, fmt.Errorf("claim: storing code hash: %w", err) } if cc.Email == "" { e.logf("[ERROR] [claim] %s code generated (gen %d) but customer %s has NO registered email — deliver via resend after setting one", kind, gen, cc.CustomerID) return gen, fmt.Errorf("claim: customer %s has no registered email", cc.CustomerID) } if err := e.Mailer.SendClaimEmail(string(kind), cc.CustomerID, cc.Email, cc.Domain, code); err != nil { e.logf("[ERROR] [claim] %s code email to customer %s FAILED (gate stays armed; resend from the customer page): %v", kind, cc.CustomerID, err) return gen, fmt.Errorf("claim: sending %s email: %w", kind, err) } if err := e.Store.MarkClaimEmailed(cc.CustomerID); err != nil { e.logf("[WARN] [claim] %s code sent to customer %s but emailed_at not recorded: %v", kind, cc.CustomerID, err) } e.logf("[INFO] [claim] %s code (gen %d) emailed to the registered address of %s", kind, gen, cc.CustomerID) return gen, nil } // EnsureIssued guarantees a claim row exists for the customer, issuing + emailing the first code // when absent. An existing row (any state) is returned untouched — repeated config pulls and // reports never rotate or re-send. This is the Day-0 entry point (API config retrieve) and the // live-box entry point (first report of an upgrading fleet). func (e *Engine) EnsureIssued(cc *store.CustomerConfig) (*store.ClaimState, error) { cs, err := e.Store.GetClaim(cc.CustomerID) if err != nil { return nil, fmt.Errorf("claim: reading state: %w", err) } if cs != nil { return cs, nil } if _, err := e.rotateAndSend(cc, EmailClaim); err != nil { // The hash (if stored) still arms the gate; surface the error for the caller's log. cs, gerr := e.Store.GetClaim(cc.CustomerID) if gerr == nil && cs != nil { return cs, err } return nil, err } return e.Store.GetClaim(cc.CustomerID) } // Resend rotates the code and re-sends it — the operator "Kód újraküldése" button. An unclaimed // customer gets the claim template; a claimed one gets the reset template (the only code a // claimed box can consume is a reset). func (e *Engine) Resend(cc *store.CustomerConfig) error { cs, err := e.Store.GetClaim(cc.CustomerID) if err != nil { return fmt.Errorf("claim: reading state: %w", err) } kind := EmailClaim if cs.Claimed() { kind = EmailReset } _, err = e.rotateAndSend(cc, kind) return err } // RequestReset handles a controller-forwarded "Elfelejtett jelszó": rate-limited hub-side // (maxResetPerDay per customer), then rotates + emails a reset code. The neutral customer-facing // response ("ha az e-mail cím regisztrálva van…") is the CALLER's job — this returns real errors // for the operator log. func (e *Engine) RequestReset(cc *store.CustomerConfig) error { cs, err := e.Store.GetClaim(cc.CustomerID) if err != nil { return fmt.Errorf("claim: reading state: %w", err) } if cs == nil { // No claim row yet (pre-arc box asking for a reset): issue the first code instead. _, err := e.EnsureIssued(cc) return err } count, err := e.Store.BumpResetCount(cc.CustomerID) if err != nil { return fmt.Errorf("claim: reset limiter: %w", err) } if count > maxResetPerDay { e.logf("[WARN] [claim] reset request for %s REFUSED: daily cap reached (%d/%d)", cc.CustomerID, count, maxResetPerDay) return fmt.Errorf("claim: reset cap reached for %s (%d/day)", cc.CustomerID, maxResetPerDay) } _, err = e.rotateAndSend(cc, EmailReset) return err } // ReissueForReenroll handles the clean-slate reinstall of a CLAIMED customer (F2, v0.57.0): the box // (host + in-guest controller) was wiped and re-enrolls, so the fresh controller has NO password // while the hub-side claim is set. This rotates + emails a RESET code (rides Resend's rotation // semantics — a single generation bump, single active code) so the customer gets a fresh code // automatically instead of hunting for the manual "request new code" button. The new hash reaches // the fresh controller through the existing report ACK. // // No-op for an UNCLAIMED customer — that is the first-provision path where EnsureIssued already // owns the first code; re-enrolling before the first claim must NOT rotate. The CALLER guarantees // single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path, // which fires exactly once per reinstall). Returns (generation, reissued, error). func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) { cs, err := e.Store.GetClaim(cc.CustomerID) if err != nil { return 0, false, fmt.Errorf("claim: reading state: %w", err) } if cs == nil || !cs.Claimed() { return 0, false, nil // unclaimed → first-provision path; nothing to re-issue } gen, err = e.rotateAndSend(cc, EmailReset) if err != nil { return gen, true, err // reissued=true so the caller records the attempt even on email failure } e.logf("[INFO] [claim] reset code re-issued (gen %d) for %s on box re-enrollment (clean-slate reinstall)", gen, cc.CustomerID) return gen, true, nil } // ResetToUnclaimed returns a customer to the pre-first-install (unclaimed, no active code) state — the // customer-RESET leg (v0.61.0). It DELETES the claim row so the engine's EnsureIssued mints a FRESH // first code (generation reset, unclaimed) on the next onboarding: no parallel "revoked" flag, no stale // generation. The active code is invalidated (the row is gone → the gate has no hash) and claimed_at is // cleared (gone). Idempotent (a missing row is a no-op). func (e *Engine) ResetToUnclaimed(cc *store.CustomerConfig) error { if err := e.Store.DeleteClaim(cc.CustomerID); err != nil { return fmt.Errorf("claim: reset to unclaimed: %w", err) } e.logf("[INFO] [claim] reset to unclaimed for %s (customer RESET) — next onboarding mints a fresh code", cc.CustomerID) return nil } // defaultSeedEvents is the enabled_events set seeded at claim (v0.71.0, audit F12) — critical-only: // no node_stale (too chatty), no *_recovered (the recovery pairing gate handles those and never // consults enabled_events). Viktor may adjust the list at review. var defaultSeedEvents = []string{ "node_down", "backup_failed", "disk_critical", "host_disk_critical", "storage_fill_critical", "offbox_repo_orphaned", } // MarkClaimed records a controller-reported successful claim and sends the one-time confirmation // email on the unclaimed→claimed transition (idempotent — repeated reports are no-ops). On the // transition it also seeds notification prefs from the registered email (v0.71.0, audit F12) so a // claimed customer can no longer be silently unnotifiable — insert-if-absent, never overwriting a // customer-edited row, and never failing the claim (notification plumbing must not gate claiming). func (e *Engine) MarkClaimed(cc *store.CustomerConfig) error { transitioned, err := e.Store.MarkClaimed(cc.CustomerID) if err != nil { return fmt.Errorf("claim: marking claimed: %w", err) } if !transitioned { return nil } e.logf("[INFO] [claim] customer %s CLAIMED its dashboard (password set by the customer)", cc.CustomerID) if seeded, err := e.Store.SeedNotificationPrefs(cc.CustomerID, cc.Email, defaultSeedEvents); err != nil { e.logf("[WARN] [claim] notification-prefs seed for %s failed (claim unaffected): %v", cc.CustomerID, err) } else if seeded { e.logf("[INFO] [claim] notification prefs seeded for %s from the registered email (default critical set)", cc.CustomerID) } else { e.logf("[INFO] [claim] notification prefs for %s left untouched (row exists or no registered email)", cc.CustomerID) } if cc.Email != "" { if err := e.Mailer.SendClaimEmail(string(EmailClaimed), cc.CustomerID, cc.Email, cc.Domain, ""); err != nil { e.logf("[WARN] [claim] claimed-confirmation email to %s failed: %v", cc.CustomerID, err) } } return nil }