package crypto import ( "crypto/rand" "encoding/base64" "errors" "fmt" "net" "net/url" "strconv" "strings" ) // TokenPrefix kennzeichnet Enrollment-Tokens. const TokenPrefix = "sltn_enroll_" var ( // ErrInvalidToken: Der Token-String hat nicht das Format // sltn_enroll_.[.]. ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token") // ErrInvalidServerURL: keine reine https://host[:port]-Adresse. ErrInvalidServerURL = errors.New("crypto: ungültige Server-URL") ) const maxServerURLLen = 255 // EnrollmentToken ist die Einladung für einen neuen Agenten. // // Secret (inkl. Präfix) ist der geheime Teil: Der Server speichert davon nur // den Hash (store.AddEnrollmentToken), der Agent schickt es beim Enrollment. // Fingerprint ist nicht geheim; er sagt dem Agenten, welches // Server-Zertifikat er erwarten muss. ServerURL (optional, nicht geheim) ist // die Adresse, unter der der Agent den Server erreicht – eine falsche // Adresse führt dank Fingerprint-Prüfung höchstens zu einem // Verbindungsfehler, nie zu einem falschen Server. type EnrollmentToken struct { Secret string Fingerprint Fingerprint ServerURL string } // String liefert die Form, die der Admin an den Agenten weitergibt. Alle // Teile sind Base64url – der Token ist ohne Quoting shell-sicher. func (t EnrollmentToken) String() string { s := t.Secret + "." + string(t.Fingerprint) if t.ServerURL != "" { s += "." + base64.RawURLEncoding.EncodeToString([]byte(t.ServerURL)) } return s } // NewEnrollmentToken erzeugt einen Token mit 256 Bit Zufall für den Server // mit Fingerprint fp; serverURL ist optional ("" = nicht einbetten). func NewEnrollmentToken(fp Fingerprint, serverURL string) (EnrollmentToken, error) { if _, err := ParseFingerprint(string(fp)); err != nil { return EnrollmentToken{}, err } if serverURL != "" { u, err := ValidateServerURL(serverURL) if err != nil { return EnrollmentToken{}, err } serverURL = u } var b [32]byte if _, err := rand.Read(b[:]); err != nil { return EnrollmentToken{}, err } return EnrollmentToken{ Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]), Fingerprint: fp, ServerURL: serverURL, }, nil } // ParseEnrollmentToken zerlegt einen Token-String. Leerraum am Rand (beim // Kopieren) wird ignoriert. func ParseEnrollmentToken(s string) (EnrollmentToken, error) { parts := strings.Split(strings.TrimSpace(s), ".") if len(parts) != 2 && len(parts) != 3 { return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s.[.]", ErrInvalidToken, TokenPrefix) } secret, rawFP := parts[0], parts[1] body, ok := strings.CutPrefix(secret, TokenPrefix) if !ok { return EnrollmentToken{}, fmt.Errorf("%w: Präfix %s fehlt", ErrInvalidToken, TokenPrefix) } if b, err := base64.RawURLEncoding.DecodeString(body); err != nil || len(b) != 32 { return EnrollmentToken{}, fmt.Errorf("%w: Secret beschädigt", ErrInvalidToken) } fp, err := ParseFingerprint(rawFP) if err != nil { return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err) } tok := EnrollmentToken{Secret: secret, Fingerprint: fp} if len(parts) == 3 { raw, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil || len(raw) == 0 { return EnrollmentToken{}, fmt.Errorf("%w: Server-URL beschädigt", ErrInvalidToken) } if tok.ServerURL, err = ValidateServerURL(string(raw)); err != nil { return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err) } } return tok, nil } // ValidateServerURL prüft eine Server-Adresse und normalisiert sie: nur // https, nur Host und optional Port (kein Benutzer, Pfad, Query, Fragment), // Host klein, ohne Schrägstrich am Ende. func ValidateServerURL(raw string) (string, error) { bad := func(why string) (string, error) { return "", fmt.Errorf("%w: %q – %s (erwartet https://host[:port])", ErrInvalidServerURL, raw, why) } if len(raw) > maxServerURLLen { return bad("zu lang") } u, err := url.Parse(strings.TrimSpace(raw)) switch { case err != nil: return bad("nicht lesbar") case u.Scheme != "https": return bad("nur https") case u.Hostname() == "": return bad("Host fehlt") case u.User != nil: return bad("keine Zugangsdaten in der URL") case u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "": return bad("kein Pfad, keine Query") } host := strings.ToLower(u.Hostname()) if port := u.Port(); port != "" { if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { return bad("ungültiger Port") } return "https://" + net.JoinHostPort(host, port), nil } if strings.Contains(host, ":") { host = "[" + host + "]" } return "https://" + host, nil }