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:
2026-07-17 23:56:53 +02:00
parent c6d7a69e6d
commit 592818492c
24 changed files with 1304 additions and 138 deletions
+65 -21
View File
@@ -32,6 +32,7 @@ type ApplianceRegistration struct {
CustomerID string // set at bind
InstallMode string // staged at bind (appliance|byo)
ExtraArgs string // staged at bind
PairingCode string // v0.66.0 (R-27): stable 6-char code shown on the box console + the self-bind page
FirstSeen time.Time
LastSeen time.Time
BoundAt *time.Time
@@ -46,57 +47,65 @@ type ApplianceRegistration struct {
// token yet, so it is genuinely starting over; the operator re-binds. isNew is true only on first
// insert (so the caller can log the first sighting). The token itself is never passed in — only its
// hash.
func (s *Store) RegisterAppliance(uuid, macSet, sshKeys, hwSummary, tokenHash string) (isNew bool, err error) {
// v0.66.0 (R-27 slice 1): the caller passes a candidateCode (minted via configgen — store must not
// import configgen, an import cycle); it is used ONLY on the first insert and stays STABLE across the
// idempotent re-register. The effective stored code is returned so the register response can carry it.
func (s *Store) RegisterAppliance(uuid, macSet, sshKeys, hwSummary, tokenHash, candidateCode string) (isNew bool, pairingCode string, err error) {
tx, err := s.db.Begin()
if err != nil {
return false, err
return false, "", err
}
defer tx.Rollback()
var id int64
var status string
row := tx.QueryRow(`SELECT id, status FROM appliance_registrations WHERE uuid = ? AND mac_set = ?`, uuid, macSet)
switch err := row.Scan(&id, &status); err {
var status, existingCode string
row := tx.QueryRow(`SELECT id, status, COALESCE(pairing_code,'') FROM appliance_registrations WHERE uuid = ? AND mac_set = ?`, uuid, macSet)
switch err := row.Scan(&id, &status, &existingCode); err {
case sql.ErrNoRows:
if _, err := tx.Exec(`
INSERT INTO appliance_registrations (uuid, mac_set, ssh_host_pubkeys, hw_summary, token_hash, status)
VALUES (?, ?, ?, ?, ?, 'registered')`, uuid, macSet, sshKeys, hwSummary, tokenHash); err != nil {
return false, fmt.Errorf("register appliance insert: %w", err)
INSERT INTO appliance_registrations (uuid, mac_set, ssh_host_pubkeys, hw_summary, token_hash, status, pairing_code)
VALUES (?, ?, ?, ?, ?, 'registered', ?)`, uuid, macSet, sshKeys, hwSummary, tokenHash, candidateCode); err != nil {
return false, "", fmt.Errorf("register appliance insert: %w", err)
}
if err := tx.Commit(); err != nil {
return false, err
return false, "", err
}
return true, nil
return true, candidateCode, nil
case nil:
// The code stays STABLE across re-register; a pre-v0.66.0 row (empty code) is backfilled once.
effectiveCode := existingCode
if effectiveCode == "" {
effectiveCode = candidateCode
}
// Existing record: refresh last_seen + identity + the token. Sticky-discard keeps its status;
// everything else resets to registered (a re-registering box is starting over).
if status == ApplianceDiscarded {
if _, err := tx.Exec(`UPDATE appliance_registrations
SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, last_seen = datetime('now')
WHERE id = ?`, tokenHash, sshKeys, hwSummary, id); err != nil {
return false, fmt.Errorf("register appliance (discarded) update: %w", err)
SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, pairing_code = ?, last_seen = datetime('now')
WHERE id = ?`, tokenHash, sshKeys, hwSummary, effectiveCode, id); err != nil {
return false, "", fmt.Errorf("register appliance (discarded) update: %w", err)
}
} else {
if _, err := tx.Exec(`UPDATE appliance_registrations
SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, status = 'registered',
SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, pairing_code = ?, status = 'registered',
customer_id = NULL, install_mode = NULL, extra_args = NULL,
bound_at = NULL, delivered_at = NULL, last_seen = datetime('now')
WHERE id = ?`, tokenHash, sshKeys, hwSummary, id); err != nil {
return false, fmt.Errorf("register appliance update: %w", err)
WHERE id = ?`, tokenHash, sshKeys, hwSummary, effectiveCode, id); err != nil {
return false, "", fmt.Errorf("register appliance update: %w", err)
}
}
if err := tx.Commit(); err != nil {
return false, err
return false, "", err
}
return false, nil
return false, effectiveCode, nil
default:
return false, fmt.Errorf("register appliance lookup: %w", err)
return false, "", fmt.Errorf("register appliance lookup: %w", err)
}
}
// scanAppliance scans a full appliance row (column order fixed by applianceCols).
const applianceCols = `id, uuid, mac_set, ssh_host_pubkeys, hw_summary, status,
COALESCE(customer_id,''), COALESCE(install_mode,''), COALESCE(extra_args,''),
COALESCE(customer_id,''), COALESCE(install_mode,''), COALESCE(extra_args,''), COALESCE(pairing_code,''),
first_seen, last_seen, bound_at, delivered_at, discarded_at`
func scanAppliance(sc interface{ Scan(...any) error }) (*ApplianceRegistration, error) {
@@ -104,7 +113,7 @@ func scanAppliance(sc interface{ Scan(...any) error }) (*ApplianceRegistration,
var firstSeen, lastSeen string
var boundAt, deliveredAt, discardedAt sql.NullString
if err := sc.Scan(&a.ID, &a.UUID, &a.MACSet, &a.SSHHostPubkeys, &a.HWSummary, &a.Status,
&a.CustomerID, &a.InstallMode, &a.ExtraArgs,
&a.CustomerID, &a.InstallMode, &a.ExtraArgs, &a.PairingCode,
&firstSeen, &lastSeen, &boundAt, &deliveredAt, &discardedAt); err != nil {
return nil, err
}
@@ -138,6 +147,41 @@ func (s *Store) ApplianceByToken(tokenHash string) (*ApplianceRegistration, erro
return a, err
}
// ApplianceByPairingCode resolves a console pairing code to the ONE bindable (registered, not yet
// bound/delivered/discarded) appliance carrying it — the customer-self-bind factor-1 lookup (v0.66.0,
// R-27). Returns (nil, nil) when zero OR more than one registered appliance matches (an ambiguous
// code binds nothing — the public handler maps that to the same generic failure as a wrong code, no
// oracle). code is the RAW stored form (uppercased, no separator); the caller normalizes first.
func (s *Store) ApplianceByPairingCode(code string) (*ApplianceRegistration, error) {
if code == "" {
return nil, nil
}
rows, err := s.db.Query(`SELECT `+applianceCols+` FROM appliance_registrations
WHERE pairing_code = ? AND status = 'registered'`, code)
if err != nil {
return nil, err
}
defer rows.Close()
var matches []*ApplianceRegistration
for rows.Next() {
a, err := scanAppliance(rows)
if err != nil {
return nil, err
}
matches = append(matches, a)
if len(matches) > 1 {
return nil, nil // ambiguous — bind nothing
}
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(matches) != 1 {
return nil, nil
}
return matches[0], nil
}
// GetAppliance fetches by row id (operator UI actions).
func (s *Store) GetAppliance(id int64) (*ApplianceRegistration, error) {
a, err := scanAppliance(s.db.QueryRow(`SELECT `+applianceCols+` FROM appliance_registrations WHERE id = ?`, id))
+171
View File
@@ -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
}
+22
View File
@@ -637,15 +637,37 @@ func (s *Store) migrate() error {
bound_at DATETIME,
delivered_at DATETIME,
discarded_at DATETIME,
pairing_code TEXT NOT NULL DEFAULT '',
UNIQUE(uuid, mac_set)
);
CREATE INDEX IF NOT EXISTS idx_appliance_status ON appliance_registrations(status, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_appliance_token ON appliance_registrations(token_hash);
-- selfbind_tokens (v0.66.0, R-27 slice 1 — customer self-bind): the 7-day tokenized capability
-- link the operator emails. token_hash is sha256 at rest (never the token). Single-active per
-- customer (delete-then-insert on re-mint). attempts lock at 5 (Viktor's ruling); consumed_at is
-- the one-shot flip. NO appliance data here — the code+passphrase check happens at bind time.
CREATE TABLE IF NOT EXISTS selfbind_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
attempts INTEGER NOT NULL DEFAULT 0,
locked INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
expires_at DATETIME NOT NULL,
emailed_at DATETIME,
consumed_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_selfbind_token ON selfbind_tokens(token_hash);
`)
if err != nil {
return err
}
// v0.66.0 (R-27 slice 1): pairing_code on pre-existing appliance rows (idempotent — errors if the
// column already exists, which is fine on a fresh DB where the CREATE above already added it).
s.db.Exec("ALTER TABLE appliance_registrations ADD COLUMN pairing_code TEXT NOT NULL DEFAULT ''")
// v0.51.0 dr_tier one-time legacy backfill — see the ALTER above; runs last so every table
// it touches (hosts, customer_configs) exists on a fresh DB too (where it finds nothing).
if drTierAlterErr == nil {