←Zurück zum Blog

Anmeldung in einem VR-Headset wie beim Fernseher

26.09.202610 min read

Bannermarch hat diese Woche VR-Unterstützung erhalten. Sie öffnen das Spiel im Meta Quest-Browser, drücken den Knopf und stehen in Ihrer Basis. Davor müssen Sie sich jedoch anmelden. Auf einer Quest bedeutet das, mit einem Laser auf eine schwebende Tastatur zu zielen und Ihre E-Mail-Adresse Buchstabe für Buchstabe einzugeben, dann Ihr Passwort, das sich wahrscheinlich in einem Passwortmanager auf Ihrem Telefon befindet. Also nehmen Sie das Headset ab, um nachzusehen. (Wie die VR selbst im Browser läuft, habe ich in einem eigenen Beitrag beschrieben.)

Fernseher haben das vor Jahren gelöst. Die YouTube-App auf einem Fernseher zeigt Ihnen einen Code an, Sie gehen zu einer URL auf Ihrem Telefon, geben ihn ein, und der Fernseher meldet sich selbst an. Netflix macht das, die PlayStation macht das. Es gibt sogar eine RFC dafür (RFC 8628, der OAuth-Geräteautorisierungs-Grant). Ich habe dasselbe für das Spiel gebaut. Das Headset zeigt einen Code an, und Sie genehmigen ihn unter /pair auf einem Telefon oder Laptop, auf dem Sie bereits angemeldet sind.

Ich werde durchgehen, wie es funktioniert, weil es ein schönes kleines Stück Systemdesign ist und die meisten Leute es benutzt haben, ohne darüber nachzudenken, was dahinter steckt.

Wer spricht mit wem

Das Headset und Ihr Telefon sprechen niemals miteinander. Ich habe nichts Cleveres mit Bluetooth oder dem lokalen Netzwerk gemacht. Beide sprechen mit dem Server des Spiels, und der Server hält einen ausstehenden Code zehn Minuten lang vor. Sie sind die einzige Verbindung zwischen den beiden Bildschirmen. Sie lesen acht Zeichen von einem ab und geben sie in das andere ein.

Headset                     Server                      Telefon
| | |
|-- starten ---------------->| |
|<-- Code K7MP2QXR ---------| |
| + Geheimnis in einem Cookie | |
| | |
|-- abfragen (noch wartend) -->|<-- /pair öffnen, Code eingeben -|
| |--- "Meta Quest, Miami" -->|
| |<-- genehmigen ---------------|
|-- abfragen + Geheimnis -->| |
|<-- angemeldet -------------| |

Hier ist die Reihenfolge, in der die Dinge passieren.

Das Headset bittet den Server, eine Kopplung zu starten. Der Server generiert einen kurzen Code zum Ablesen, z. B. K7MP2QXR. Er generiert auch ein langes zufälliges Geheimnis (32 Bytes) und sendet es in einem Cookie zurück an das Headset. Der Server speichert einen Hash dieses Geheimnisses, nicht das Geheimnis selbst.

Das Headset zeigt den Code auf dem Bildschirm an und beginnt alle 2,5 Sekunden den Server abzufragen, ob jemand ihn genehmigt hat. Es gibt auch einen QR-Code auf dem Bildschirm, der auf /pair#K7MP2QXR verweist. Das funktioniert gut auf einem Fernseher oder einem Laptop. Auf einem Headset ist es ziemlich nutzlos, aber dazu komme ich noch.

Sie öffnen /pair auf Ihrem Telefon und geben den Code ein. Der Server sucht ihn und zeigt Ihnen an, welches Gerät fragt und wo es sich ungefähr befindet. Er vermutet "Meta Quest" aus dem User-Agent des Browsers und ermittelt Stadt und Land anhand der IP-Adresse. Sie tippen auf Genehmigen. Der Server schreibt Ihre Konto-ID neben den ausstehenden Code.

Die nächste Abfrage des Headsets sendet den Cookie. Der Server hasht das Geheimnis, vergleicht es, sieht die Genehmigung, löscht den ausstehenden Eintrag und gibt dem Headset seine eigene Sitzung. Sie sind im Spiel.

Warum ein gestohlener Code nutzlos ist

Der Code liegt auf einem Bildschirm, daher gehe ich davon aus, dass andere ihn lesen können. Ihr Bruder auf der Couch kann das. Ebenso jeder, der einen Stream ansieht.

Das ist der Grund für das Geheimnis. Wenn Sie genehmigen, geht Ihr Konto nicht an denjenigen, der den Code kennt. Es geht an den Browser, der das Geheimnis besitzt, und dieser Browser ist das Headset. Der Cookie ist HttpOnly, sodass JavaScript auf der Seite ihn nicht lesen kann, und SameSite=Strict, sodass eine andere Website den Browser nicht dazu bringen kann, ihn zu senden. Wenn jemand den Code von Ihrem Fernseher kopiert, kann er damit nichts sammeln.

Die Sitzung kann einmal abgerufen werden. Der Server löscht den ausstehenden Eintrag in dem Moment, in dem das Headset ihn abholt, und jede spätere Abfrage ergibt "abgelaufen". Wenn niemand den Code innerhalb von zehn Minuten genehmigt, ist er sowieso weg.

Ich habe mir keine großen Gedanken über das Raten gemacht. Die Codes verwenden 31 Zeichen (ich habe 0, O, 1, I und L weggelassen, weil die Leute sie verwechseln), und acht davon ergeben etwa 850 Milliarden Kombinationen. Die Genehmigung ist zusätzlich ratenbegrenzt, und jeder Code ist nur zehn Minuten gültig.

Phishing

Das Geheimnis hilft nicht, wenn Sie den falschen Code genehmigen. Sagen wir, jemand startet eine Kopplung auf seinem eigenen Laptop und schickt Ihnen die Nachricht: "Geben Sie diesen Code ein, um Ihr Konto zu bestätigen." Sie genehmigen ihn, und jetzt hat sein Laptop Ihre Sitzung. Das nennt man Gerätecode-Phishing. Die RFC warnt davor, und Angreifer haben es gegen Microsoft 365-Konten eingesetzt.

Das kann ich nicht vollständig verhindern, aber ich kann es offensichtlich machen. Der Genehmigungsbildschirm nennt das Gerät und den Ort, von dem aus es sich verbindet, mit einer Warnung, nur einen Code zu genehmigen, den Sie auf einem Gerät sehen, das Sie gerade in der Hand halten. Wenn Sie in Miami leben und dort "Windows PC" in einem Land steht, in dem Sie noch nie waren, werden Sie das wahrscheinlich bemerken.

Headsets sind keine Fernseher

Etwa zwanzig Minuten, nachdem ich die erste Version veröffentlicht hatte, veröffentlichte ich eine zweite. Mit einem Fernseher können Sie den Code und Ihr Telefon gleichzeitig ansehen. Mit einem aufgesetzten Headset können Sie Ihr Telefon überhaupt nicht sehen, also nehmen Sie das Headset ab, um den Code zu lesen, geben ihn ein und setzen es wieder auf. Der QR-Code funktioniert auch nicht, da die Kamera Ihres Telefons nicht durch die Linsen sehen kann.

Also geht es jetzt auch andersherum. Unter /pair gibt es einen Knopf "Code für Ihr Headset erhalten". Ihr Telefon fordert den Server nach einem Code an, der mit Ihrem Konto verknüpft ist, Sie lesen ihn, setzen das Headset auf und geben ihn auf dem Anmeldebildschirm ein. Acht Zeichen mit der Laser-Tastatur sind immer noch nervig. Es ist aber deutlich weniger nervig als eine E-Mail und ein Passwort.

Diese Version ist weniger sicher als die erste. Hinter dem Code steckt kein Geheimnis, also kommt rein, wer ihn zuerst eingibt. Ich habe sie trotzdem beibehalten und eingegrenzt. Der Code wird nur an ein angemeldetes Konto mit verifizierter E-Mail-Adresse ausgegeben. Er funktioniert einmal und ist zehn Minuten gültig, und die Eingabe von Codes ist ratenbegrenzt.

Ich halte auch die beiden Arten von Codes in getrennten Tabellen. Wenn das Feld "Code eingeben" einen Code eines Headsets akzeptieren würde, dann könnte jeder, der ihn auf Ihrem Fernseher gesehen hat, ihn sofort, nachdem Sie ihn genehmigt haben, woanders eingeben und Ihre Sitzung erhalten. Mit getrennten Tabellen schlägt ein Headset-Code dort einfach fehl, ob genehmigt oder nicht.

Wie es gebaut ist

Das ist die Idee. Wenn Sie Ihre eigene erstellen möchten, hier ist, was tatsächlich im Code steckt. Der Server ist ein Cloudflare Worker in TypeScript. Nur der Speicherteil ist Cloudflare-spezifisch, und ich werde sagen, was Sie stattdessen verwenden können.

Sechs Endpunkte

EndpunktAufgerufen vonWas er tut
POST /api/auth/device/startHeadsetErstellt einen Code und ein Geheimnis, setzt den Cookie
POST /api/auth/device/pollHeadsetAntwortet auf ausstehende, abgelaufene oder meldet das Headset an
POST /api/auth/device/lookupAngemeldetes TelefonZeigt an, welches Gerät und welcher Ort hinter einem Code steckt
POST /api/auth/device/approveAngemeldetes TelefonVerknüpft Ihr Konto mit dem Code
POST /api/auth/device/issueAngemeldetes TelefonErstellt einen Code für den umgekehrten Fluss
POST /api/auth/device/redeemHeadsetTauscht einen eingegebenen Code gegen eine Sitzung

Jeder von ihnen ist ein POST, und jede Antwort hat Cache-Control: no-store, sodass nichts mit einem Code oder einer Sitzung darin zwischengespeichert wird. Lookup und Approve sind absichtlich getrennt. Das Telefon ruft zuerst lookup auf, um Ihnen den Gerätenamen und den Ort anzuzeigen, und ruft erst approve auf, nachdem Sie es gelesen und auf den Knopf gedrückt haben. Alles, was ein angemeldetes Telefon aufruft, prüft auch auf eine verifizierte E-Mail-Adresse und durchläuft eine Ratenbegrenzung pro Konto.

Den Code erstellen

Der Code stammt von crypto.getRandomValues, niemals von Math.random. Es gibt eine kleine Falle. Ein zufälliger Byte reicht von 0 bis 255, und wenn Sie einfach byte % 31 nehmen, kommen die ersten Buchstaben des Alphabets etwas häufiger vor als die anderen, da 256 nicht gleichmäßig durch 31 teilbar ist. Die Lösung besteht darin, jeden Byte von 248 oder höher zu verwerfen und einen neuen zu ziehen. Das nennt man Rejection Sampling und sind drei zusätzliche Zeilen.

const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 Symbole, keine 0 O 1 I L

function newPairingCode(): string {
  // 256 ist kein Vielfaches von 31, also verwerfen Sie die oberen Bytes, um jedes Symbol gleich wahrscheinlich zu halten
  const limit = 256 - (256 % alphabet.length); // 248
  let code = "";
  while (code.length < 8) {
    for (const byte of crypto.getRandomValues(new Uint8Array(16))) {
      if (byte < limit && code.length < 8) code += alphabet[byte % alphabet.length];
    }
  }
  return code;
}

// Leute geben "k7mp-2qxr" oder "K7MP 2QXR" ein. Beides wird akzeptiert.
function normalizeCode(input: string): string | null {
  const code = input.toUpperCase().replace(/[\s-]/g, "");
  return code.length === 8 && [...code].every((c) => alphabet.includes(c)) ? code : null;
}

Der Server verzeiht auch, wie die Leute tippen. Kleinbuchstaben, Leerzeichen und Bindestriche werden alle bereinigt, bevor der Code überprüft wird, sodass niemand eine Fehlermeldung erhält, weil er ihn so eingegeben hat, wie er aussieht.

Das Geheimnis lebt im Cookie

Hier ist der Start-Handler, etwas gekürzt.

async function startPairing(request: Request, env: Env): Promise<Response> {
  const secret = base64Url(crypto.getRandomValues(new Uint8Array(32)));
  const secretHash = await sha256Hex(secret);

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const code = newPairingCode();
    const expiresAt = await env.DEVICE_PAIRING
      .getByName(`device-pairing:${code}`)
      .begin(secretHash, deviceLabel(request), devicePlace(request));
    if (expiresAt === null) continue; // Code bereits vergeben, neuen generieren

    const headers = new Headers({ "Cache-Control": "no-store" });
    headers.append("Set-Cookie",
      `__Host-bm_device_pairing=${code}.${secret}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=600`);
    return Response.json({ code, expiresAt }, { headers });
  }
  return Response.json({ error: "Versuchen Sie es erneut." }, { status: 503 });
}

Ein paar Dinge geschehen hier. Der Server speichert immer nur secretHash. Das rohe Geheimnis wird einmal im Cookie ausgegeben, sodass selbst jemand, der die Datenbank liest, nicht vorgeben kann, das Headset zu sein. Der Cookie enthält sowohl den Code als auch das Geheimnis (K7MP2QXR.abc...), was bedeutet, dass die Poll-Anfrage überhaupt keinen Body benötigt. Der Browser sendet den Cookie, und der Server weiß, welchen Code er überprüfen muss, und kann beweisen, dass es das richtige Headset ist.

Das __Host- Präfix teilt dem Browser mit, dass dieser Cookie nur über HTTPS auf dieser exakten Domain funktioniert und kein Subdomain ihn setzen oder überschreiben kann. Und da ist die Wiederholungsschleife. Zwei Headsets könnten zufällig denselben Code erhalten, und wenn das passiert, gibt begin null zurück und der Handler generiert einen neuen. Bei 850 Milliarden möglichen Codes wird es praktisch nie eine Schleife geben, aber es muss behandelt werden.

Eine Zeile, wenige Zustände

Jeder ausstehende Code ist eine einzelne Zeile.

CREATE TABLE pairing (
  id           INTEGER PRIMARY KEY CHECK (id = 1),  -- eine Zeile pro Objekt, immer
  secret_hash  TEXT NOT NULL,                       -- sha256 des Geheimnisses des Headsets
  device_label TEXT NOT NULL,                       -- "Meta Quest"
  place        TEXT,                                -- "Miami, US"
  expires_at   INTEGER NOT NULL,
  account_key  TEXT                                 -- NULL, bis jemand genehmigt
);

Der Zustand einer Kopplung ergibt sich direkt aus dieser Zeile. Wenn account_key leer ist, ist sie ausstehend. Sobald jemand genehmigt, wird sie gefüllt. Wenn das Headset sie abholt, wird die Zeile gelöscht. Wenn expires_at überschritten ist, gilt der Code als abgelaufen, unabhängig davon, was die Zeile sonst sagt. Es gibt keine Statusspalte, die synchron gehalten werden muss, was eine Fehlerquelle weniger bedeutet.

Das ist es, was der Poll des Headsets am Ende aufruft.

claim(secretHash: string) {
  const row = this.pairing(); // null, wenn fehlend oder expires_at überschritten
  if (!row || row.secret_hash !== secretHash) return { status: "expired" };
  if (!row.account_key) return { status: "pending" };

  this.ctx.storage.sql.exec("DELETE FROM pairing"); // einmal abholen
  return { status: "approved", accountKey: row.account_key };
}

Betrachten Sie die erste Prüfung. Ein falsches Geheimnis erhält dieselbe "abgelaufen"-Antwort wie ein fehlender Code. Der Server teilt einem Fremden niemals mit: "Dieser Code existiert, aber Sie sind nicht das richtige Gerät." Bei einer erfolgreichen Abholung wird die Zeile gelöscht, bevor die Sitzung ausgegeben wird, sodass dieselbe Genehmigung nicht zweimal verwendet werden kann.

Die Seite des Headsets

Der Client ist eine kleine Schleife.

const poll = () => {
  timer = setTimeout(async () => {
    try {
      const result = await api.pollDevicePairing();
      if (result.authenticated) onSignedIn(result);
      else if (result.status === "expired") showExpired();
      else poll(); // noch ausstehend, in 2,5s erneut fragen
    } catch {
      poll(); // instabiles Headset-WLAN sollte die Kopplung nicht beenden
    }
  }, 2_500);
};

Es verwendet setTimeout, das sich selbst neu plant, anstatt setInterval. Wenn eine Anfrage langsam ist, wartet die nächste auf sie, anstatt sich dahinter anzureihen. Fehler halten die Schleife am Laufen, da das WLAN des Headsets ab und zu für eine Sekunde ausfällt und das Sie nicht den Code kosten sollte. Wenn der Poll schließlich erfolgreich ist, trägt die Antwort den normalen Sitzungs-Cookie, genau wie bei einer Passwortanmeldung, und das Spiel wird geladen.

Wo die Codes gespeichert werden

Das Spiel läuft auf Cloudflare Workers. Jeder Code erhält sein eigenes Durable Object, benannt nach dem Code, das eine winzige SQLite-Tabelle mit einer Zeile enthält.

Ich habe mich dafür entschieden, wegen der Regel "einmal abholen". Ein Durable Object bearbeitet seine Anfragen nacheinander, sodass zwei Polls für denselben Code nicht beide die Genehmigung erhalten können. Jeder von ihnen setzt auch einen Alarm für eine Minute nach Ablauf des Codes, und der Alarm löscht seinen Speicher. Ich brauche keinen Bereinigungsjob.

Wenn Sie nicht auf Cloudflare sind, erledigt Redis dieselbe Aufgabe. Speichern Sie jeden Code als Schlüssel mit einem Zehn-Minuten-TTL (SET ... EX 600 NX, wobei NX Ihnen die "Code bereits vergeben"-Prüfung kostenlos gibt). Für den Abholschritt prüfen Sie das Geheimnis, prüfen Sie die Genehmigung und löschen Sie den Schlüssel innerhalb eines Lua-Skripts, sodass alles atomar geschieht. Eine einfache Postgres-Tabelle funktioniert ebenfalls, wenn Sie den Anspruch als einzelnes DELETE ... WHERE ... RETURNING durchführen und gelegentlich eine Bereinigungsabfrage ausführen.

Der QR-Code platziert den Code nach einem # in der URL. Browser senden diesen Teil nicht an den Server, daher erscheinen Codes nie in Zugriffsprotokollen. Und das Headset fragt ab, anstatt eine WebSocket-Verbindung offen zu halten. Im schlimmsten Fall sind das 240 kleine Anfragen über zehn Minuten, was nichts ist, und ich wollte keine WebSockets im Quest-Browser debuggen.

Auf dem Laufenden bleiben

Erhalten Sie die neuesten Beiträge und Einblicke direkt in Ihren Posteingang.

Kein Spam. Jederzeit abbestellbar.