package setup // Admin-Password-Reset-Flow: Operator hat Login-Zugang vergessen, // aber SSH-Zugang zur Box. Auf der Box ruft er // // sudo edgeguard-ctl reset-password // // auf — der CLI schreibt ein Token-File nach // /var/lib/edgeguard/.reset-token (mode 0600 edgeguard:edgeguard) und // druckt das Token. Der Operator gibt es auf der /reset-password-Seite // ein, setzt ein neues Passwort, fertig. // // Token-Eigenschaften: // - 32 zufällige hex-chars (128 bit entropy) // - File-mode 0600 — nur edgeguard liest, nur root + edgeguard schreibt // - Expiry 30 min (im File als ISO-timestamp gespeichert) // - Single-use: bei jedem erfolgreichen Reset wird das File gelöscht // // Sicherheit gegen Brute-Force: nicht nötig — Token hat 128 bit // Entropy, das File darf existieren nur kurzzeitig nach einem expliziten // CLI-Aufruf. Login-Seite hat kein Rate-Limit, aber jeder Versuch // braucht das exakte File-Inhalt — bei 32 hex zu raten dauert 2^127 // Versuche. import ( "crypto/rand" "encoding/hex" "errors" "os" "path/filepath" "strings" "time" ) const ( resetTokenFile = ".reset-token" resetTokenTTL = 30 * time.Minute ) // GenerateResetToken erzeugt ein neues Token, schreibt das File und // gibt das Token zurück. Existiert bereits ein Token → wird // überschrieben (Operator hat das alte verworfen). Aufrufer: CLI. func (s *Store) GenerateResetToken() (string, error) { prev, err := s.Load() if err != nil { return "", err } if prev == nil || !prev.Completed { return "", errors.New("setup not completed — nothing to reset") } var raw [16]byte if _, err := rand.Read(raw[:]); err != nil { return "", err } token := hex.EncodeToString(raw[:]) expiry := time.Now().Add(resetTokenTTL).UTC().Format(time.RFC3339) content := token + "|" + expiry + "\n" path := filepath.Join(s.Dir, resetTokenFile) if err := os.MkdirAll(s.Dir, 0o755); err != nil { return "", err } if err := os.WriteFile(path, []byte(content), 0o600); err != nil { return "", err } return token, nil } // ConsumeResetToken prüft Token-Match + Expiry und löscht das File bei // Erfolg. Aufrufer: API-Handler /auth/reset-password. func (s *Store) ConsumeResetToken(token string) error { path := filepath.Join(s.Dir, resetTokenFile) b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return errors.New("no reset-token available — run `sudo edgeguard-ctl reset-password` on the box first") } return err } parts := strings.SplitN(strings.TrimSpace(string(b)), "|", 2) if len(parts) != 2 { return errors.New("reset-token file malformed") } stored := strings.TrimSpace(parts[0]) expiryStr := strings.TrimSpace(parts[1]) if !constantTimeEqual(stored, strings.TrimSpace(token)) { return errors.New("reset-token mismatch") } exp, err := time.Parse(time.RFC3339, expiryStr) if err != nil { return errors.New("reset-token expiry malformed") } if time.Now().After(exp) { _ = os.Remove(path) return errors.New("reset-token expired — generate a new one") } // success → delete file (single-use) _ = os.Remove(path) return nil } // constantTimeEqual prevents timing-attacks on the token compare. // crypto/subtle hätte's auch getan, aber für hex-strings reicht das // hier — und vermeidet einen weiteren Import. func constantTimeEqual(a, b string) bool { if len(a) != len(b) { return false } var diff byte for i := range a { diff |= a[i] ^ b[i] } return diff == 0 }