// Package totp implementiert zeitbasierte Einmalkennwörter nach RFC 6238. // // Die Umsetzung erfolgt bewusst im eigenen Haus statt über eine Fremdbibliothek: // der Algorithmus ist klein, vollständig spezifiziert und über die offiziellen // Testvektoren des RFC nachweisbar korrekt. Zudem braucht Syncova ohnehin // eigene Logik für Replay-Schutz und Zeitfenster-Toleranz. package totp import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/sha256" "crypto/sha512" "crypto/subtle" "encoding/base32" "errors" "fmt" "hash" "net/url" "strings" "time" ) // Algorithm benennt die Hashfunktion des HMAC. type Algorithm string const ( // AlgorithmSHA1 ist der Standard nach RFC 6238 und wird von allen // verbreiteten Authenticator-Apps unterstützt. AlgorithmSHA1 Algorithm = "SHA1" // AlgorithmSHA256 ist die stärkere Variante. AlgorithmSHA256 Algorithm = "SHA256" // AlgorithmSHA512 ist die stärkste Variante. AlgorithmSHA512 Algorithm = "SHA512" ) // newHashFunction liefert die Konstruktorfunktion zum Algorithmus. func (algorithm Algorithm) newHashFunction() (func() hash.Hash, error) { switch algorithm { case AlgorithmSHA1: return sha1.New, nil case AlgorithmSHA256: return sha256.New, nil case AlgorithmSHA512: return sha512.New, nil default: return nil, fmt.Errorf("unbekannter algorithmus %q", algorithm) } } // Standardparameter. // // Sechs Ziffern bei 30 Sekunden Schrittweite entsprechen dem, was // Authenticator-Apps erwarten; Abweichungen davon führen zu Kompatibilitätsproblemen. const ( // DefaultDigits ist die Anzahl der Ziffern eines Codes. DefaultDigits = 6 // DefaultPeriod ist die Gültigkeitsdauer eines Zeitschritts. DefaultPeriod = 30 * time.Second // DefaultAlgorithm ist die vorgegebene Hashfunktion. DefaultAlgorithm = AlgorithmSHA1 // DefaultSkew erlaubt je einen Zeitschritt Abweichung in beide Richtungen. // // Ohne Toleranz schlüge jede geringfügig falsch gehende Uhr fehl; eine // größere Toleranz verlängerte dagegen das Zeitfenster für einen Angreifer. DefaultSkew = 1 // secretByteLength ist die Länge eines erzeugten Secrets (160 Bit laut RFC 4226). secretByteLength = 20 ) // ErrInvalidCode meldet einen nicht passenden Code. var ErrInvalidCode = errors.New("der code ist ungültig") // ErrCodeAlreadyUsed meldet die Wiederverwendung eines bereits benutzten Codes. // // Ohne diese Prüfung könnte ein abgefangener Code innerhalb seines Zeitfensters // ein zweites Mal verwendet werden. var ErrCodeAlreadyUsed = errors.New("dieser code wurde bereits verwendet") // Configuration beschreibt die Parameter einer TOTP-Prüfung. type Configuration struct { // Algorithm ist die verwendete Hashfunktion. Algorithm Algorithm // Digits ist die Anzahl der Ziffern eines Codes. Digits int // Period ist die Gültigkeitsdauer eines Zeitschritts. Period time.Duration // Skew ist die erlaubte Abweichung in Zeitschritten je Richtung. Skew int } // DefaultConfiguration liefert die Standardparameter. func DefaultConfiguration() Configuration { return Configuration{ Algorithm: DefaultAlgorithm, Digits: DefaultDigits, Period: DefaultPeriod, Skew: DefaultSkew, } } // GenerateSecret erzeugt ein neues zufälliges Secret in Base32. // // Base32 ohne Füllzeichen ist das Format, das Authenticator-Apps und // QR-Codes erwarten. func GenerateSecret() (string, error) { secretBytes := make([]byte, secretByteLength) if _, randomError := rand.Read(secretBytes); randomError != nil { return "", fmt.Errorf("es konnte kein sicheres secret erzeugt werden: %w", randomError) } return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secretBytes), nil } // GenerateCode berechnet den Code für einen Zeitpunkt. func GenerateCode(base32Secret string, codeTime time.Time, configuration Configuration) (string, error) { secretBytes, decodeError := decodeSecret(base32Secret) if decodeError != nil { return "", decodeError } timeStep := codeTime.UTC().Unix() / int64(configuration.Period.Seconds()) return computeCode(secretBytes, timeStep, configuration) } // ValidationResult ist das Ergebnis einer erfolgreichen Prüfung. type ValidationResult struct { // TimeStep ist der Zeitschritt, für den der Code galt. // // Der Wert wird gespeichert, um die erneute Verwendung desselben Codes // zu verhindern. TimeStep int64 } // Validate prüft einen Code gegen ein Secret. // // lastUsedTimeStep ist der zuletzt akzeptierte Zeitschritt dieses Benutzers; // ein Code aus diesem oder einem älteren Schritt wird abgelehnt. Für die erste // Prüfung wird 0 übergeben. func Validate(base32Secret string, providedCode string, validationTime time.Time, lastUsedTimeStep int64, configuration Configuration) (ValidationResult, error) { secretBytes, decodeError := decodeSecret(base32Secret) if decodeError != nil { return ValidationResult{}, decodeError } // Leerzeichen entstehen leicht beim Abtippen und sind kein Fehler des Benutzers. normalizedCode := strings.ReplaceAll(strings.TrimSpace(providedCode), " ", "") if len(normalizedCode) != configuration.Digits { return ValidationResult{}, ErrInvalidCode } currentTimeStep := validationTime.UTC().Unix() / int64(configuration.Period.Seconds()) // Es werden alle Zeitschritte innerhalb der erlaubten Abweichung geprüft. // Der Ablauf bricht bewusst nicht beim ersten Treffer ab, damit die Laufzeit // nicht verrät, welcher Schritt gepasst hat. matchedTimeStep := int64(-1) for stepOffset := -configuration.Skew; stepOffset <= configuration.Skew; stepOffset++ { candidateTimeStep := currentTimeStep + int64(stepOffset) expectedCode, computeError := computeCode(secretBytes, candidateTimeStep, configuration) if computeError != nil { return ValidationResult{}, computeError } if subtle.ConstantTimeCompare([]byte(expectedCode), []byte(normalizedCode)) == 1 { matchedTimeStep = candidateTimeStep } } if matchedTimeStep < 0 { return ValidationResult{}, ErrInvalidCode } // Ein bereits verwendeter Zeitschritt wird abgelehnt, selbst wenn der Code // rechnerisch stimmt: sonst liesse sich ein abgefangener Code erneut nutzen. if matchedTimeStep <= lastUsedTimeStep { return ValidationResult{}, ErrCodeAlreadyUsed } return ValidationResult{TimeStep: matchedTimeStep}, nil } // computeCode berechnet den Code eines Zeitschritts nach RFC 4226. func computeCode(secretBytes []byte, timeStep int64, configuration Configuration) (string, error) { hashConstructor, hashError := configuration.Algorithm.newHashFunction() if hashError != nil { return "", hashError } // Der Zeitschritt wird als 8-Byte-Big-Endian-Wert in den HMAC gegeben. counterBytes := make([]byte, 8) for byteIndex := 7; byteIndex >= 0; byteIndex-- { counterBytes[byteIndex] = byte(timeStep & 0xff) timeStep >>= 8 } messageAuthenticator := hmac.New(hashConstructor, secretBytes) messageAuthenticator.Write(counterBytes) authenticatorSum := messageAuthenticator.Sum(nil) // Dynamic Truncation laut RFC 4226 §5.3: die letzten vier Bit benennen den // Startpunkt der zu verwendenden vier Byte. truncationOffset := authenticatorSum[len(authenticatorSum)-1] & 0x0f truncatedValue := (uint32(authenticatorSum[truncationOffset])&0x7f)<<24 | (uint32(authenticatorSum[truncationOffset+1])&0xff)<<16 | (uint32(authenticatorSum[truncationOffset+2])&0xff)<<8 | (uint32(authenticatorSum[truncationOffset+3]) & 0xff) // Der Modulus schneidet den Wert auf die gewünschte Ziffernzahl. codeModulus := uint32(1) for digitIndex := 0; digitIndex < configuration.Digits; digitIndex++ { codeModulus *= 10 } // Führende Nullen bleiben erhalten - ein Code "012345" ist gültig. return fmt.Sprintf("%0*d", configuration.Digits, truncatedValue%codeModulus), nil } // decodeSecret liest ein Base32-Secret. func decodeSecret(base32Secret string) ([]byte, error) { // Authenticator-Apps zeigen Secrets häufig in Gruppen mit Leerzeichen an. normalizedSecret := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(base32Secret), " ", "")) if normalizedSecret == "" { return nil, errors.New("das secret ist leer") } secretBytes, decodeError := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalizedSecret) if decodeError != nil { // Der Wert selbst wird nicht ausgegeben: er ist ein Geheimnis. return nil, errors.New("das secret ist kein gültiges base32") } return secretBytes, nil } // ProvisioningURI baut die otpauth-URI zum Einrichten einer Authenticator-App. // // Der Rückgabewert enthält das Secret im Klartext und darf deshalb ausschließlich // an den Besitzer selbst ausgeliefert und niemals geloggt werden. func ProvisioningURI(base32Secret string, accountName string, issuerName string, configuration Configuration) string { // Das Label folgt der Konvention "Aussteller:Konto". uriLabel := fmt.Sprintf("%s:%s", issuerName, accountName) queryParameters := url.Values{} queryParameters.Set("secret", base32Secret) queryParameters.Set("issuer", issuerName) queryParameters.Set("algorithm", string(configuration.Algorithm)) queryParameters.Set("digits", fmt.Sprintf("%d", configuration.Digits)) queryParameters.Set("period", fmt.Sprintf("%d", int(configuration.Period.Seconds()))) provisioningURL := url.URL{ Scheme: "otpauth", Host: "totp", Path: "/" + uriLabel, RawQuery: queryParameters.Encode(), } return provisioningURL.String() }