package store import ( "database/sql" "fmt" "time" ) // selfbind.go — the customer self-bind capability-token store (v0.66.0, R-27 slice 1). // // The operator mints one token per customer and the hub emails a 7-day tokenized capability link. // The customer opens it (public, no login — the token IS the capability), enters the box's console // pairing code + their retrieval passphrase, and the hub stages the bind. Only sha256(token) is // stored here (never the token); attempts lock at 5 (Viktor's ruling: "call support"); consumed_at // is the one-shot flip. There is NO appliance data on this table — the two-factor check happens at // bind time in web/selfbind.go, so a leaked token row reveals nothing about any box. // // Custody rules honoured here: single-active per customer (a re-mint deletes the prior row, so an old // link dies the moment a new one is sent), and the token hash is the only representation at rest. // SelfBindMaxAttempts is the lockout threshold: the 5th failed factor-check locks the token and the // customer is told to call support. Distinct from the controller's claim lockout — this is the // hub-side public-surface guard against online guessing of the pairing code + passphrase. const SelfBindMaxAttempts = 5 // SelfBindToken is a minted capability link's state. type SelfBindToken struct { ID int64 CustomerID string Attempts int Locked bool CreatedAt time.Time ExpiresAt time.Time EmailedAt *time.Time ConsumedAt *time.Time } // Expired reports whether the link is past its TTL (falls back to operator-bind, unchanged). func (t *SelfBindToken) Expired(now time.Time) bool { return now.After(t.ExpiresAt) } // Consumed reports whether the link was already used (one-shot). func (t *SelfBindToken) Consumed() bool { return t.ConsumedAt != nil } // Usable reports whether a POST may attempt a factor-check: not locked, not consumed, not expired. func (t *SelfBindToken) Usable(now time.Time) bool { return !t.Locked && !t.Consumed() && !t.Expired(now) } const selfBindCols = `id, customer_id, attempts, locked, created_at, expires_at, emailed_at, consumed_at` func scanSelfBindToken(sc interface{ Scan(...any) error }) (*SelfBindToken, error) { var t SelfBindToken var locked int var createdAt, expiresAt string var emailedAt, consumedAt sql.NullString if err := sc.Scan(&t.ID, &t.CustomerID, &t.Attempts, &locked, &createdAt, &expiresAt, &emailedAt, &consumedAt); err != nil { return nil, err } t.Locked = locked != 0 t.CreatedAt = parseSQLiteTime(createdAt) t.ExpiresAt = parseSQLiteTime(expiresAt) if emailedAt.Valid && emailedAt.String != "" { e := parseSQLiteTime(emailedAt.String) t.EmailedAt = &e } if consumedAt.Valid && consumedAt.String != "" { c := parseSQLiteTime(consumedAt.String) t.ConsumedAt = &c } return &t, nil } // MintSelfBindToken creates a fresh capability token for the customer, single-active: any prior token // for the same customer is DELETED in the same transaction, so an earlier link stops working the // instant a new one is minted (no parallel-link enumeration). tokenHash is sha256(token) — the // plaintext token exists only inside the email send. The token is valid for ttl from now. func (s *Store) MintSelfBindToken(customerID, tokenHash string, ttl time.Duration) error { if customerID == "" || tokenHash == "" { return fmt.Errorf("selfbind: customer_id and token hash are required") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM selfbind_tokens WHERE customer_id = ?`, customerID); err != nil { return err } expiresAt := time.Now().Add(ttl).UTC().Format("2006-01-02 15:04:05") if _, err := tx.Exec(`INSERT INTO selfbind_tokens (customer_id, token_hash, expires_at) VALUES (?, ?, ?)`, customerID, tokenHash, expiresAt); err != nil { return err } return tx.Commit() } // DeleteSelfBindTokens removes every self-bind token for a customer. Used on the email-failure path // (F2): a minted token whose delivery failed is not left silently live — the plaintext link is gone // from memory, so the row is unusable dead weight; deleting it keeps the operator page honest (no // stale "active link" with no delivery). Idempotent. func (s *Store) DeleteSelfBindTokens(customerID string) error { _, err := s.db.Exec(`DELETE FROM selfbind_tokens WHERE customer_id = ?`, customerID) return err } // CountSelfBindTokens reports how many capability tokens exist for a customer (v0.67.0). Minting is // single-active (delete-then-insert), so this is 0 or 1 in practice; it exists so callers can assert // the "after this runs, the only live link is one we just issued — or none" invariant that the // auto-mint at customer-create / RESET-completion depends on. Read-only, no oracle risk: it is keyed // by customer id, which the operator already knows. func (s *Store) CountSelfBindTokens(customerID string) (int, error) { var n int err := s.db.QueryRow(`SELECT COUNT(*) FROM selfbind_tokens WHERE customer_id = ?`, customerID).Scan(&n) return n, err } // SelfBindTokenByHash resolves sha256(token) to its row, or (nil, nil) when unknown — the public GET // maps that to the same generic "invalid link" as an expired one: no oracle for "was this ever real". func (s *Store) SelfBindTokenByHash(tokenHash string) (*SelfBindToken, error) { if tokenHash == "" { return nil, nil } t, err := scanSelfBindToken(s.db.QueryRow(`SELECT `+selfBindCols+` FROM selfbind_tokens WHERE token_hash = ?`, tokenHash)) if err == sql.ErrNoRows { return nil, nil } return t, err } // MarkSelfBindEmailed records that the capability link was delivered (operator "emailed_at" honesty // on the customer page). Best-effort: a failure to record does not un-send the mail. func (s *Store) MarkSelfBindEmailed(tokenHash string) error { _, err := s.db.Exec(`UPDATE selfbind_tokens SET emailed_at = datetime('now') WHERE token_hash = ?`, tokenHash) return err } // RecordSelfBindAttempt increments the failed-attempt counter for a token and locks it at the 5th // failure. Called on EVERY failed factor-check (wrong code OR wrong passphrase — indistinguishable). // Returns the post-increment attempt count and whether the token is now locked. A missing/consumed // token is a no-op that reports locked=true (the caller already refuses those; belt-and-suspenders). func (s *Store) RecordSelfBindAttempt(tokenHash string) (attempts int, locked bool, err error) { tx, err := s.db.Begin() if err != nil { return 0, false, err } defer tx.Rollback() t, err := scanSelfBindToken(tx.QueryRow(`SELECT `+selfBindCols+` FROM selfbind_tokens WHERE token_hash = ?`, tokenHash)) if err == sql.ErrNoRows { return SelfBindMaxAttempts, true, nil } if err != nil { return 0, false, err } attempts = t.Attempts + 1 locked = attempts >= SelfBindMaxAttempts lockedInt := 0 if locked { lockedInt = 1 } if _, err := tx.Exec(`UPDATE selfbind_tokens SET attempts = ?, locked = ? WHERE id = ?`, attempts, lockedInt, t.ID); err != nil { return 0, false, err } if err := tx.Commit(); err != nil { return 0, false, err } return attempts, locked, nil } // ConsumeSelfBindToken flips consumed_at EXACTLY ONCE on a successful bind. ok=true for the single // winning caller; ok=false for a replay or a lost race (already consumed). The two-factor check has // already passed when this is called — this is the one-shot gate against a double-bind. It refuses a // locked or expired token defensively (WHERE guards), though the handler checks Usable() first. func (s *Store) ConsumeSelfBindToken(tokenHash string) (ok bool, err error) { res, err := s.db.Exec(`UPDATE selfbind_tokens SET consumed_at = datetime('now') WHERE token_hash = ? AND consumed_at IS NULL AND locked = 0 AND expires_at > datetime('now')`, tokenHash) if err != nil { return false, err } n, _ := res.RowsAffected() return n == 1, nil }