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:
@@ -4,6 +4,8 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
@@ -126,3 +128,55 @@ func RandomHex(n int) (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// pairingAlphabet excludes visually ambiguous characters (0/O, 1/I/L) so a customer can read the code
|
||||
// off the box's console banner and type it into the self-bind page without confusion.
|
||||
const pairingAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// RandomPairingCode returns a 6-char appliance pairing code from the unambiguous alphabet (rendered
|
||||
// "ABC-DEF" for display; stored/compared without the dash). It is NOT a secret on its own — the
|
||||
// self-bind flow also requires the customer's retrieval passphrase.
|
||||
func RandomPairingCode() (string, error) {
|
||||
b := make([]byte, 6)
|
||||
max := big.NewInt(int64(len(pairingAlphabet)))
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[i] = pairingAlphabet[idx.Int64()]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// FormatPairingCode renders a stored 6-char code as "ABC-DEF" for the console banner + the operator UI.
|
||||
func FormatPairingCode(code string) string {
|
||||
if len(code) == 6 {
|
||||
return code[:3] + "-" + code[3:]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// NormalizePairingCode strips separators/whitespace and upper-cases (the customer may type "abc-def",
|
||||
// "abc def", or "ABCDEF") so the compare is against a canonical form.
|
||||
func NormalizePairingCode(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(s) {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// NormalizePassphrase canonicalizes a diceware passphrase for the constant-time compare: trim,
|
||||
// lower-case, and collapse any run of dashes/whitespace to a single dash. The WORDS themselves are
|
||||
// compared exactly (no accent-folding — the Hungarian wordlist is the source of truth), so a mistyped
|
||||
// word fails.
|
||||
func NormalizePassphrase(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
fields := strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == '-' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
return strings.Join(fields, "-")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user