Hallo! An einem anderen Abend, auf dem Heimweg, beschloss ich, den Briefkasten zu überprüfen. Ich meine nicht mein E-Mail-Postfach, sondern den altmodischen Briefkasten, in den der Postbote die physischen Briefe legt. Und zu meiner großen Überraschung fand ich dort einen Umschlag mit etwas darin! Während ich es öffnete, hoffte ich einige Augenblicke, dass es sich um den um Jahrzehnte verspäteten Brief aus Hogwarts handelte. Doch dann musste ich wieder auf den Boden der Tatsachen zurück, als mir auffiel, dass es sich um einen langweiligen „Erwachsenen“-Brief von der Bank handelte. Ich überflog den Text und stellte fest, dass meine „nur digitale“ Bank für coole Kinder vom größten Player auf dem lokalen Markt übernommen worden war. Und als Zeichen des Neuanfangs fügten sie dem Umschlag Folgendes bei:
Neben der Gebrauchsanweisung.
Wenn Sie wie ich sind und noch nie auf eine solche technische Innovation gestoßen sind, lassen Sie mich Ihnen mitteilen, was ich aus dem Brief gelernt habe: Neue Eigentümer haben beschlossen, die Sicherheitsrichtlinien ihres Unternehmens durchzusetzen, was bedeutet, dass alle Benutzer davon betroffen sind Konten werden von nun an die MFA aktiviert haben (übrigens ein großes Lob dafür). Und das Gerät, das Sie oben sehen können, generiert einmalige, 6-stellige Token, die als zweiter Faktor beim Anmelden bei Ihrem Bankkonto verwendet werden. Im Prinzip so, wie Apps wie Authy, Google Authenticator oder 2FAS funktionieren, allerdings in physischer Form.
Also habe ich es ausprobiert und der Anmeldevorgang verlief reibungslos: Das Gerät zeigte mir einen 6-stelligen Code an, ich gab ihn in meine Banking-App ein und schon war ich dabei. Hurra! Aber dann fiel mir etwas auf: Wie funktioniert das Ding? Es besteht keine Möglichkeit, dass es irgendwie mit dem Internet verbunden ist, aber es schafft es, korrekte Codes zu generieren, die von meinem Bankserver akzeptiert werden. Hm... Könnte es eine SIM-Karte oder etwas Ähnliches darin haben? Auf keinen Fall!
Als mir klar wurde, dass mein Leben nie mehr das gleiche sein wird, begann ich mich über die Apps zu wundern, die ich oben erwähnt habe (Authy und Freunde)? Mein innerer Forscher wurde geweckt, also schaltete ich mein Telefon in den Flugmodus und stellte zu meiner großen Überraschung fest, dass sie offline einwandfrei funktionieren: Sie generieren weiterhin Codes, die von den Servern der Apps akzeptiert werden. Interessant!
Bei Ihnen bin ich mir nicht sicher, aber ich habe den einmaligen Token-Flow immer als selbstverständlich angesehen und nie wirklich darüber nachgedacht (insbesondere aufgrund der Tatsache, dass es heutzutage selten vorkommt, dass mein Telefon kein Internet hat, es sei denn Ich mache einige Outdoor-Abenteuer. Das war also der Grund für meine Überraschung. Ansonsten ist es aus Sicherheitsgründen absolut sinnvoll, auf diese Weise zu arbeiten, da der Generierungsprozess rein lokal erfolgt und daher vor externen Akteuren geschützt ist. Aber wie funktioniert es?
Nun, moderne Technologien wie Google oder ChatGPT machen es einfach, die Antwort leicht zu finden. Aber dieses technische Problem schien mir Spaß zu machen, also beschloss ich, es auszuprobieren und es zunächst selbst zu lösen.
Beginnen wir mit dem, was wir haben:
Der Servervalidierungsteil weist darauf hin, dass der Server in der Lage sein muss, denselben Code wie das Offline-Gerät zu generieren, um sie zu vergleichen. Hm...das kann hilfreich sein.
Meine weiteren Beobachtungen meines neuen „Spielzeugs“ brachten noch mehr Entdeckungen:
Die einzige logische Erklärung, die mir einfällt, ist, dass diese Codes eine bestimmte Lebensdauer haben. Ich würde gerne eine Geschichte darüber erzählen, wie ich versucht habe, die Dauer nach „1-2-3-...-N“ zu zählen, aber das stimmt nicht: Ich habe einen großen Hinweis von den Apps bekommen, z Authy und Co, wo ich die 30-Sekunden-TTL gesehen habe. Guter Fund, fügen wir das zur Liste der bekannten Fakten hinzu.
Lassen Sie uns die Anforderungen zusammenfassen, die wir bisher haben:
In Ordnung, aber die Hauptfrage bleibt unbeantwortet: Wie kommt es, dass die Offline-App den Wert generieren kann, der mit dem Wert der anderen App übereinstimmt? Was haben sie gemeinsam?
Wenn Sie sich für das Herr der Ringe-Universum interessieren, erinnern Sie sich vielleicht daran, wie Bilbo mit Gollum ein Rätselspiel gespielt hat und dieses Rätsel lösen musste:
Dieses Ding verschlingt alles:
Vögel, Tiere, Bäume, Blumen;
Nagt Eisen, beißt Stahl;
Mahlt harte Steine zu Mehl;
Tötet den König, ruiniert die Stadt,
Und schlägt hohen Berg hinunter.
Spoiler-Alarm, aber Herr Beutlin hatte Glück und kam aus Versehen auf die richtige Antwort: „Zeit!“. Ob Sie es glauben oder nicht, aber genau das ist auch die Antwort auf unser Rätsel: Zwei beliebige (oder mehr) Apps haben Zugriff auf die gleiche Zeit, solange sie über eine eingebettete Uhr verfügen. Letzteres ist heutzutage kein Problem mehr und das betreffende Gerät ist groß genug, um darauf Platz zu finden. Schauen Sie sich um, und die Chancen stehen gut, dass die Uhrzeit auf Ihrer Armbanduhr, Ihrem Mobiltelefon, Ihrem Fernseher, Ihrem Ofen und der Uhr an der Wand gleich ist. Wir sind hier an etwas interessiert, es scheint, als hätten wir die Basis für die OTP-Berechnung (Einmalpasswort) gefunden!
Sich auf die Zeit zu verlassen, bringt seine eigenen Herausforderungen mit sich:
Lassen Sie uns sie einzeln ansprechen:
Okay, das ist geklärt, also versuchen wir, die allererste Version unseres Algorithmus auf der Grundlage der Zeit zu implementieren. Da wir an einem 6-stelligen Ergebnis interessiert sind, scheint es eine kluge Entscheidung zu sein, sich auf einen Zeitstempel statt auf das für Menschen lesbare Datum zu verlassen. Fangen wir dort an:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Laut den Go-Dokumenten gibt .Unix() zurück
die Anzahl der Sekunden, die seit dem 1. Januar 1970 UTC vergangen sind.
Das wurde am Terminal ausgegeben:
Current timestamp: 1733691162
Das ist ein guter Anfang, aber wenn wir diesen Code erneut ausführen, ändert sich der Zeitstempelwert, während wir ihn gerne 30 Sekunden lang stabil halten würden. Nun, ganz einfach, teilen wir es durch 30 und verwenden diesen Wert als Basis:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Lass es uns ausführen:
Current timestamp: 1733691545 Base: 57789718
Und noch einmal:
Current timestamp: 1733691552 Base: 57789718
Der Grundwert bleibt gleich. Warten wir einen Moment und führen Sie es erneut aus:
Current timestamp: 1733691571 Base: 57789719
Der Basiswert hat sich geändert, da das 30-Sekunden-Fenster abgelaufen ist – gute Arbeit!
Wenn die Logik „durch 30 dividieren“ keinen Sinn ergibt, lassen Sie es mich anhand eines einfachen Beispiels erklären:
Ich hoffe, dass es jetzt mehr Sinn ergibt.
Allerdings sind noch nicht alle Anforderungen erfüllt, da wir ein 6-stelliges Ergebnis benötigen, während die aktuelle Basis heute 8 Ziffern hat, aber irgendwann in der Zukunft möglicherweise 9 Ziffern erreicht werden, und so weiter . Nun, nutzen wir einen weiteren einfachen mathematischen Trick: Teilen Sie die Basis durch 1.000.000 und erhalten Sie den Rest, der immer genau 6 Ziffern hat, da die Erinnerung eine beliebige Zahl von 0 bis 999.999 sein kann, jedoch niemals größer:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Der Teil fmt.Sprintf(" d", code) hängt führende Nullen an, falls unser Codewert weniger als 6 Ziffern hat. Beispielsweise wird 1234 in 001234 umgewandelt.
Den gesamten Code für diesen Beitrag finden Sie hier.
Lassen Sie uns diesen Code ausführen:
Current timestamp: 1733691162
Alles klar, wir bekommen unseren 6-stelligen Code, Hurra! Aber irgendetwas fühlt sich hier nicht richtig an, oder? Wenn ich Ihnen diesen Code gebe und Sie ihn gleichzeitig mit mir ausführen, erhalten Sie denselben Code wie ich. Das macht es aber nicht zu einem sicheren Einmalpasswort, oder? Hier kommt eine neue Anforderung:
Natürlich sind einige Kollisionen unvermeidlich, wenn wir mehr als 1 Million Benutzer haben, da dies die maximal möglichen eindeutigen Werte pro 6 Ziffern sind. Aber das sind seltene und technisch unvermeidbare Kollisionen, nicht der Algorithmus-Designfehler, wie wir ihn jetzt haben.
Ich glaube nicht, dass uns hier irgendwelche cleveren mathematischen Tricks helfen werden: Wenn wir separate Ergebnisse pro Benutzer benötigen, brauchen wir einen benutzerspezifischen Status, um dies zu ermöglichen. Als Ingenieure und gleichzeitig Benutzer vieler Dienste wissen wir, dass Dienste für die Gewährung des Zugriffs auf ihre APIs auf private Schlüssel angewiesen sind, die für jeden Benutzer einzigartig sind. Lassen Sie uns auch für unseren Anwendungsfall einen privaten Schlüssel einführen, um zwischen den Benutzern zu unterscheiden.
Eine einfache Logik zum Generieren privater Schlüssel als Ganzzahlen zwischen 1.000.000 und 999.999.999:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Wir verwenden die pkDb-Zuordnung, um Duplikate zwischen privaten Schlüsseln zu verhindern, und wenn das Duplikat erkannt wurde, führen wir die Generierungslogik noch einmal aus, bis wir ein eindeutiges Ergebnis erhalten.
Lassen Sie uns diesen Code ausführen, um das Beispiel des privaten Schlüssels zu erhalten:
Current timestamp: 1733691545 Base: 57789718
Lassen Sie uns diesen privaten Schlüssel in unserer Codegenerierungslogik verwenden, um sicherzustellen, dass wir für jeden privaten Schlüssel unterschiedliche Ergebnisse erhalten. Da unser privater Schlüssel vom Typ Ganzzahl ist, können wir ihn am einfachsten zum Basiswert hinzufügen und den verbleibenden Algorithmus unverändert lassen:
Current timestamp: 1733691552 Base: 57789718
Stellen wir sicher, dass es für verschiedene private Schlüssel unterschiedliche Ergebnisse liefert:
Current timestamp: 1733691571 Base: 57789719
Das Ergebnis sieht so aus, wie wir es wollten und erwartet haben:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
Funktioniert wie ein Zauber! Das bedeutet, dass der private Schlüssel in das Gerät eingeschleust werden sollte, das Codes generiert, bevor er an Benutzer wie mich gesendet wird: Das sollte für die Banken überhaupt kein Problem darstellen.
Sind wir jetzt fertig? Nun ja, nur, wenn wir mit dem künstlichen Szenario, das wir verwendet haben, zufrieden sind. Wenn Sie die MFA jemals für Dienste/Websites aktiviert haben, bei denen Sie ein Konto haben, ist Ihnen möglicherweise aufgefallen, dass die Webressource Sie auffordert, den QR-Code mit der Zweitfaktor-App Ihrer Wahl (Authy, Google Authenticator, 2FAS usw.) zu scannen. ), das den Geheimcode in Ihre App eingibt und von diesem Moment an mit der Generierung eines 6-stelligen Codes beginnt. Alternativ besteht die Möglichkeit, den Code manuell einzugeben.
Ich erwähne dies, um zu erwähnen, dass es möglich ist, einen Blick auf das Format der echten privaten Schlüssel zu werfen, die in der Branche verwendet werden. Normalerweise handelt es sich um 16–32 Zeichen lange Base32-codierte Zeichenfolgen, die wie folgt aussehen:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Wie Sie sehen, unterscheidet sich dies erheblich von den ganzzahligen privaten Schlüsseln, die wir verwendet haben, und die aktuelle Implementierung unseres Algorithmus wird nicht funktionieren, wenn wir zu diesem Format wechseln. Wie können wir unsere Logik anpassen?
Beginnen wir mit einem einfachen Ansatz: Unser Code lässt sich aufgrund dieser Zeile nicht kompilieren:
Current timestamp: 1733691162
as pk ist von nun an vom Typ string. Warum wandeln wir es also nicht in eine Ganzzahl um? Es gibt zwar weitaus elegantere und leistungsfähigere Möglichkeiten, dies zu tun, aber hier ist die einfachste Sache, die ich mir ausgedacht habe:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Dies ist stark von der Java-hashCode()-Implementierung für den String-Datentyp inspiriert, was es für unser Szenario gut genug macht.
Hier ist die angepasste Logik:
Current timestamp: 1733691545 Base: 57789718
Hier ist die Terminalausgabe:
Current timestamp: 1733691552 Base: 57789718
Schöner 6-stelliger Code, gute Arbeit. Warten wir, bis wir zum nächsten Zeitfenster gelangen, und führen Sie es erneut aus:
Current timestamp: 1733691571 Base: 57789719
Hm...es funktioniert, aber der Code ist im Grunde die Erhöhung des vorherigen Werts, was nicht gut ist, da OTPs auf diese Weise vorhersehbar sind und es sehr wichtig ist, seinen Wert zu haben und zu wissen, zu welcher Zeit er gehört Es ist einfach, mit der Generierung derselben Werte zu beginnen, ohne den privaten Schlüssel kennen zu müssen. Hier ist der einfache Pseudocode für diesen Hack:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
wobei keepWithinSixDigits dafür sorgt, dass nach 999 999 der nächste Wert 000 000 ist usw., um den Wert innerhalb der 6-stelligen Grenzmöglichkeiten zu halten.
Wie Sie sehen, handelt es sich um eine schwerwiegende Sicherheitslücke. Warum passiert es? Wenn wir uns die Basisberechnungslogik ansehen, werden wir feststellen, dass sie auf zwei Faktoren beruht:
Der Hash erzeugt denselben Wert für denselben Schlüssel, daher ist sein Wert konstant. Der aktuelle / 30 hat 30 Sekunden lang den gleichen Wert, aber sobald das Fenster verstrichen ist, ist der nächste Wert die Erhöhung des vorherigen. Dann verhält sich Basis % 1_000_000 so, wie wir es sehen. Unsere vorherige Implementierung (mit privaten Schlüsseln als Ganzzahlen) hatte die gleiche Schwachstelle, aber wir haben das nicht bemerkt – schuld daran waren mangelnde Tests.
Wir müssen den Strom / 30 in etwas umwandeln, um die Änderung seines Wertes deutlicher zu machen.
Es gibt mehrere Möglichkeiten, dies zu erreichen, und es gibt einige coole Mathe-Tricks, aber zu Bildungszwecken legen wir Wert auf die Lesbarkeit der Lösung, die wir verwenden werden: Extrahieren wir current / 30 in eine separate Variablenbasis und schließen wir sie ein es in die Hash-Berechnungslogik:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Auf diese Weise erhöht sich das Gewicht des Diffs aufgrund der Reihe durchgeführter Multiplikationen, obwohl sich die Basis alle 30 Sekunden um 1 ändert, nachdem sie in der Hash()-Funktionslogik verwendet wurde.
Hier ist das aktualisierte Codebeispiel:
Current timestamp: 1733691162
Lass es uns ausführen:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Boom! Wie kommt es, dass wir hier den Minuswert erhalten haben? Nun, es scheint, als wären uns die int64-Bereiche ausgegangen, also haben wir die Werte auf das Minus begrenzt und von vorne begonnen – meine Java-Kollegen kennen das aus dem Verhalten von hashCode(). Die Lösung ist einfach: Nehmen wir den absoluten Wert aus dem Ergebnis, dann wird das Minuszeichen ignoriert:
Current timestamp: 1733691545 Base: 57789718
Hier ist das gesamte Codebeispiel mit dem Fix:
Current timestamp: 1733691552 Base: 57789718
Lass es uns ausführen:
Current timestamp: 1733691571 Base: 57789719
Lass es uns noch einmal ausführen, um sicherzustellen, dass die OTP-Werte jetzt verteilt werden:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
Schön, endlich eine vernünftige Lösung!
Eigentlich war das der Moment, in dem ich meinen manuellen Implementierungsprozess beendet habe, da ich viel Spaß hatte und etwas Neues gelernt habe. Es ist jedoch weder die beste Lösung noch die, mit der ich leben würde. Es hat unter anderem einen großen Fehler: Wie Sie sehen, verarbeitet unsere Logik aufgrund der Hashing-Logik und der Zeitstempelwerte immer große Zahlen, was bedeutet, dass es höchst unwahrscheinlich ist, dass wir Ergebnisse generieren können, die mit 1 oder beginnen mehr Nullen: z. B. 012345, 001234 usw., obwohl sie vollständig gültig sind. Aus diesem Grund fehlen uns 100.000 mögliche Werte, was 10 % der möglichen Anzahl von Ergebnissen des Algorithmus entspricht – die Wahrscheinlichkeit von Kollisionen ist auf diese Weise höher. Nicht cool!
Ich werde nicht näher auf die Implementierung eingehen, die in den realen Anwendungen verwendet wird, aber für diejenigen, die neugierig sind, werde ich zwei RFCs vorstellen, die einen Blick wert sind:
Und hier ist die Pseudocode-Implementierung, die basierend auf den oben genannten RFCs wie beabsichtigt funktioniert:
Current timestamp: 1733692423 Base: 57789747 Code: 789747
Wie Sie sehen, sind wir dem schon sehr nahe gekommen, aber der ursprüngliche Algorithmus verwendet ein fortgeschritteneres Hashing (HMAC-SHA1 in diesem Beispiel) und führt einige bitweise Operationen aus, um die Ausgabe zu normalisieren.
Bevor wir Schluss machen, möchte ich jedoch noch auf eine Sache eingehen: die Sicherheit. Ich würde Ihnen dringend davon abraten, die Logik der Generierung von OTPs selbst zu implementieren, da es viele Bibliotheken gibt, die das für uns erledigen. Der Spielraum für Fehler ist riesig und der Weg zur Schwachstelle, die von den bösen Akteuren da draußen entdeckt und ausgenutzt wird, ist kurz.
Selbst wenn Sie die Generierungslogik richtig hinbekommen und sie mit Tests abdecken, gibt es noch andere Dinge, die schief gehen können. Wie viel wird Ihrer Meinung nach beispielsweise nötig sein, um den 6-stelligen Code brutal zu erzwingen? Lass uns experimentieren:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Lassen Sie uns diesen Code ausführen:
Current timestamp: 1733691162
Und noch einmal:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Wie Sie sehen, dauert es etwa 70 ms, den Code mithilfe einer einfachen Brute-Forcing-For-Schleife zu erraten. Das ist 400-mal schneller als die Lebensdauer des OTP! Der Server der App/Website, die den OTP-Mechanismus verwendet, muss dies verhindern, indem er beispielsweise nach drei fehlgeschlagenen Versuchen in den nächsten 5 oder 10 Sekunden keine neuen Codes akzeptiert. Auf diese Weise erhält der Angreifer innerhalb des 30-Sekunden-Fensters nur 18 bzw. entsprechend 9 Versuche, was für den Pool von 1 Million möglichen Werten nicht ausreicht.
Und es gibt noch andere Dinge wie diese, die man leicht übersieht. Lassen Sie mich also wiederholen: Bauen Sie nicht von Grund auf auf, sondern verlassen Sie sich auf die vorhandenen Lösungen.
Wie auch immer, ich hoffe, dass Sie heute etwas Neues gelernt haben und die OTP-Logik von nun an kein Rätsel mehr für Sie sein wird. Auch wenn Sie irgendwann im Leben Ihr Offline-Gerät dazu bringen müssen, mithilfe eines reproduzierbaren Algorithmus einige Werte zu generieren, wissen Sie, wo Sie anfangen sollen.
Vielen Dank für die Zeit, die Sie mit dem Lesen dieses Beitrags verbracht haben, und viel Spaß! =)
P.S. Erhalten Sie eine E-Mail, sobald ich einen neuen Beitrag veröffentliche – abonnieren Sie ihn hier
P.P.S. Wie die anderen coolen Kids habe ich in letzter Zeit ein Bluesky-Konto erstellt, also helfen Sie mir bitte, meinen Feed unterhaltsamer zu gestalten =)
Das obige ist der detaillierte Inhalt vonOTPs entmystifizieren: die Logik hinter der Offline-Generierung von Token. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!