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
+21
View File
@@ -245,3 +245,24 @@ func (d *Dispatcher) SendClaimEmail(kind, customerID, email, domain, code string
d.store.LogNotification(customerID, eventType, "info", subject, "sent", "", "customer")
return nil
}
// SendSelfBindEmail delivers the customer self-bind capability link (v0.66.0, R-27 slice 1) to the
// REGISTERED customer address. Sibling of SendClaimEmail — NOT routed through the claim engine. The
// link is the capability; it is logged only via the notification_log subject (which carries no
// token), never the raw link. On failure the caller (web) invalidates the just-minted token so it is
// not left silently live.
func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error {
if d.resendAPIKey == "" {
d.logger.Printf("[ERROR] self-bind link email for %s NOT sent: no Resend API key configured", customerID)
return fmt.Errorf("notify: no resend api key")
}
subject, body := FormatSelfBindEmail(customerID, link)
if err := d.sendEmailFn(email, subject, body); err != nil {
d.logger.Printf("[ERROR] self-bind link email to customer %s failed: %v", customerID, err)
d.store.LogNotification(customerID, "selfbind_link", "info", subject, "failed", err.Error(), "customer")
return err
}
d.logger.Printf("[INFO] self-bind link emailed to the registered address of %s", customerID)
d.store.LogNotification(customerID, "selfbind_link", "info", subject, "sent", "", "customer")
return nil
}
+29
View File
@@ -230,3 +230,32 @@ Felhom.eu`, code, dashboardURL)
return subject, body
}
}
// FormatSelfBindEmail builds the customer-facing Hungarian email carrying the self-bind capability
// link (v0.66.0, R-27 slice 1). The link is the ONLY secret here — the passphrase is never in the
// mail (the customer already holds it), and the console pairing code is read off the box screen. The
// copy tells the customer they will need both factors on the page. Adult tone, no emoji.
func FormatSelfBindEmail(customerID, link string) (string, string) {
subject := "[Felhom] Kösd össze a Felhom dobozodat"
body := fmt.Sprintf(`Kedves Ügyfél!
Elkészült a Felhom dobozod, és készen áll az összekötésre. Az alábbi hivatkozáson
tudod te magad összekötni a fiókoddal — nincs szükség bejelentkezésre:
%s
A hivatkozás megnyitása után két adatot kell megadnod:
1. A párosító kódot, amely a doboz képernyőjén (a monitoron) látható.
2. A visszaállító jelszavadat (az 5 szóból álló kifejezést), amelyet a
beállításkor kaptál.
A hivatkozás 7 napig érvényes. Biztonsági okból 5 sikertelen próbálkozás után
zárolódik — ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.
Ha nem te kérted ezt, hagyd figyelmen kívül ezt az e-mailt.
Üdvözlettel,
Felhom.eu`, link)
return subject, body
}