// 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 } // 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). 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 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 }