hub v0.66.0 + ISO v1.20.0: customer self-bind (R-27 slice 1)
Let a customer bind their own freshly-installed appliance without the operator: operator "Send self-bind link" mints a 7-day tokenized capability link, emailed (Hungarian, sibling sender) to the customer, who opens a public /bind/<token> page and proves two factors — the console pairing code shown on the box screen + their retrieval passphrase — and the hub stages the bind via the same BindAppliance (provenance customer_selfbind). The box's ~30s appliance poll delivers. Viktor's three rulings verbatim: console pairing code (no appliance list ever rendered), operator-sent tokenized link, 5-attempt lockout -> "call support". Wrong code == wrong passphrase (one generic failure, no oracle, both factors compared unconditionally); expiry falls back to operator-bind unchanged. THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login gate sites via a single isPublicBindPath predicate (tight trailing-slash match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests (Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout, oracle, widened-prefix, single-active). GC verdict: no appliance GC -> the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred. Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user