Files
felhom.eu/hub/internal/store/selfbind.go
T
admin b6d537d86c hub v0.67.0 — auto-minted self-bind link, post-RESET staleness, unprovisioned-offsite warning
Four small items, each a case where the hub already knew something and said
nothing. Green: build, vet, tests all pass.

(a) Self-bind link is minted automatically at customer creation AND at RESET
    completion (R-36 sub-item). The console banner tells the customer to open
    "az e-mailben kapott link"; until now that email existed only once the
    operator remembered the button, so the banner could point at something that
    did not exist — during the 2026-07-18 rehearsal the box waited ~11.7 min on
    exactly that. handleSelfBindLinkSend's body was extracted into a shared
    mintAndSendSelfBindLink core so the button and the auto-mint callers cannot
    drift apart on the honesty rules: F1 (no address -> mint nothing) and F2
    (send failed -> delete the token, never leave it live). The wrapper NEVER
    fails the operation it rides on — a create that provisioned Cloudflare,
    offsite and PBS must not 500 over a courtesy email.

    Gap found and closed while wiring it: PurgeCustomerResetDBState does NOT
    clear selfbind_tokens, so a link minted BEFORE a reset would have stayed
    live across it. A successful mint already replaces it (delete-then-insert,
    single-active); the skip paths would not have, so they now clear stale
    tokens too. Invariant: after auto-mint runs the only live link is one it
    just issued, or none.

(b) Post-RESET staleness banner (R-37). When a RESET COMPLETED after the newest
    report, every health figure on the page describes a lifecycle that no longer
    exists, and the page kept showing pre-RESET warnings as current. Narrow on
    purpose: an in-flight reset does not trigger it, and it clears itself when a
    report arrives. Ties resolve to STALE — SQLite timestamps are second-
    resolution and a same-second report almost certainly predates the reset;
    erring the other way would hide the banner exactly when it matters.

(c) Unprovisioned-offsite warning (R-36 interim). enabled==true with type=="" is
    a real, stable, silent state: provisioning is Save-triggered and the
    re-enroll auto-re-issue deliberately skips an unprovisioned target, so
    nothing self-heals it. Reuses the exact predicate the offsite re-issue
    handler already refuses on.

(d) pbsdr_reissued rendered an EMPTY flash box — the key had no template branch,
    so re-issuing PBS credentials showed a success box with no words (observed
    live 2026-07-18). Now describes what was staged plus the R-39 caveat:
    confirm `pvesm status` shows the entry active, because a converged agent can
    report `applied` while the storage still 401s.

New .flash-warn (amber, --warn tokens) for the deviation tier between success
and error — exception-color principle: only on deviation, never on a healthy
page.

Tests assert each banner is ABSENT in the nominal cases as well as present in
the deviating one — a banner that always renders is worse than none. Both
red-proofed: deleting the pbsdr_reissued branch reproduces the original empty
box; neutering the staleness predicate fails the banner assertion. New
read-only store accessor CountSelfBindTokens makes the single-active invariant
assertable.

NOT in this train: the R-39 hub-side generation-bump fix the pre-travel task
made conditional. Its condition was REFUTED (SetHostDesired bumps
unconditionally; applyPBSDR is idempotent as documented) — the real mechanism is
the agent's descriptor-hash convergence and needs its own spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 21:45:11 +02:00

183 lines
7.8 KiB
Go

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
}