←Retour au Blog

Se connecter dans un casque VR comme on le fait sur sa TV

26 sept. 202610 min read

Bannermarch a reçu le support VR cette semaine. Vous ouvrez le jeu dans le navigateur Meta Quest, appuyez sur le bouton, et vous vous retrouvez à l'intérieur de votre jeu. Avant tout cela, cependant, vous devez vous connecter. Sur un Quest, cela signifie pointer un laser vers un clavier flottant et taper votre e-mail lettre par lettre, puis votre mot de passe, qui se trouve probablement dans un gestionnaire de mots de passe sur votre téléphone. Vous retirez donc le casque pour aller le chercher. (J'ai expliqué comment la VR fonctionne dans le navigateur dans un autre article.)

Les téléviseurs ont résolu ce problème il y a des années. L'application YouTube sur un téléviseur affiche un code, vous allez sur une URL sur votre téléphone, vous la tapez, et le téléviseur se connecte. Netflix le fait, la PlayStation le fait. Il existe même une RFC pour cela (RFC 8628, l'octroi d'autorisation de périphérique OAuth). J'ai construit la même chose pour le jeu. Le casque affiche un code, et vous l'approuvez sur /pair depuis un téléphone ou un ordinateur portable où vous êtes déjà connecté.

Je vais expliquer comment cela fonctionne, car c'est une petite partie intéressante de la conception de systèmes et la plupart des gens l'ont utilisée sans réfléchir à ce qui se cache derrière.

Qui parle à qui

Le casque et votre téléphone ne se parlent jamais. Je n'ai rien fait de particulier avec le Bluetooth ou le réseau local. Ils communiquent tous deux avec le serveur du jeu, et le serveur conserve un code en attente pendant dix minutes. Vous êtes le seul lien entre les deux écrans. Vous lisez huit caractères sur l'un et vous les tapez sur l'autre.

Casque                      Serveur                      Téléphone
| | |
|-- démarrer ---------------->| |
|<-- code K7MP2QXR ---------| |
| + secret dans un cookie| |
| | |
|-- interroger (en attente) >|<-- ouvrir /pair, taper code -|
| |--- "Meta Quest, Miami" -->|
| |<-- approuver ---------------|
|-- interroger + secret --->| |
|<-- connecté --------------| |

Voici l'ordre dans lequel les choses se déroulent.

Le casque demande au serveur de démarrer un appairage. Le serveur génère un code court à lire, comme K7MP2QXR. Il génère également un long secret aléatoire, 32 octets, et le renvoie au casque dans un cookie. Le serveur stocke un hachage de ce secret, pas le secret lui-même.

Le casque affiche le code à l'écran et commence à interroger le serveur toutes les 2,5 secondes pour savoir si quelqu'un l'a approuvé. Il y a aussi un code QR à l'écran, qui pointe vers /pair#K7MP2QXR. Cela fonctionne très bien sur un téléviseur ou un ordinateur portable. Sur un casque, c'est assez inutile, mais j'y reviendrai.

Vous ouvrez /pair sur votre téléphone et tapez le code. Le serveur le recherche et vous montre quel appareil demande et où il se trouve approximativement. Il devine "Meta Quest" à partir de l'User-Agent du navigateur et obtient une ville et un pays à partir de l'IP. Vous appuyez sur approuver. Le serveur associe votre identifiant de compte au code en attente.

La prochaine interrogation du casque envoie le cookie. Le serveur hache le secret, le compare, constate l'approbation, supprime l'entrée en attente et attribue au casque sa propre session. Vous êtes connecté au jeu.

Pourquoi un code volé est inutile

Le code est affiché à l'écran, j'imagine donc que d'autres personnes peuvent le lire. Votre frère sur le canapé peut le faire. N'importe qui regardant un flux peut aussi.

C'est la raison du secret. Lorsque vous approuvez, votre compte ne va pas à celui qui connaît le code. Il va au navigateur qui détient le secret, et ce navigateur est le casque. Le cookie est HttpOnly, donc le JavaScript de la page ne peut pas le lire, et SameSite=Strict, donc un autre site ne peut pas demander au navigateur de l'envoyer. Si quelqu'un copie le code de votre téléviseur, il ne peut rien en collecter.

La session peut être collectée une seule fois. Le serveur supprime l'entrée en attente dès que le casque la récupère, et toute interrogation ultérieure renvoie "expiré". Si personne n'approuve le code en dix minutes, il disparaît de toute façon.

Je ne me suis pas beaucoup inquiété des tentatives de devinettes. Les codes utilisent 31 caractères (j'ai supprimé 0, O, 1, I et L car les gens les confondent), et huit d'entre eux donnent environ 850 milliards de combinaisons. L'approbation est limitée en débit en plus de cela, et chaque code ne dure que dix minutes.

Phishing

Le secret n'aide pas si vous approuvez le mauvais code. Disons que quelqu'un démarre un appairage sur son propre ordinateur portable et vous envoie un message "entrez ce code pour confirmer votre compte". Vous l'approuvez, et maintenant son ordinateur portable a votre session. C'est ce qu'on appelle le phishing par code d'appareil. La RFC met en garde contre cela, et des attaquants l'ont utilisé contre des comptes Microsoft 365.

Je ne peux pas l'arrêter complètement, mais je peux le rendre évident. L'écran d'approbation nomme l'appareil et l'endroit d'où il se connecte, avec un avertissement de n'approuver qu'un code que vous pouvez voir sur un appareil que vous tenez. Si vous vivez à Miami et qu'il est indiqué "PC Windows" dans un pays où vous n'êtes jamais allé, vous le remarquerez probablement.

Les casques ne sont pas des téléviseurs

Environ vingt minutes après avoir déployé la première version, j'en ai déployé une seconde. Avec un téléviseur, vous pouvez regarder le code et votre téléphone en même temps. Avec un casque, vous ne pouvez pas du tout voir votre téléphone, vous finissez donc par soulever le casque pour lire le code, le taper, puis le remettre. Le code QR ne fonctionne pas non plus, car l'appareil photo de votre téléphone ne peut pas voir à travers les lentilles.

Alors maintenant, cela fonctionne aussi dans l'autre sens. Sur /pair, il y a un bouton "Obtenir un code pour votre casque". Votre téléphone demande au serveur un code lié à votre compte, vous le lisez, mettez le casque et le tapez dans l'écran de connexion. Huit caractères avec le clavier laser est toujours ennuyeux. C'est beaucoup moins ennuyeux qu'un e-mail et un mot de passe.

Cette version est moins sûre que la première. Il n'y a pas de secret derrière le code, donc celui qui le tape en premier entre. Je l'ai gardée quand même et je l'ai encadrée. Le code n'est émis qu'à un compte connecté avec un e-mail vérifié. Il fonctionne une fois et dure dix minutes, et la saisie des codes est limitée en débit.

Je garde également les deux types de codes dans des tables séparées. Si la boîte "taper un code" acceptait le code d'un casque, alors juste après que vous en ayez approuvé un, quiconque l'aurait vu sur votre téléviseur pourrait le taper ailleurs et obtenir votre session. Avec des tables séparées, le code d'un casque échoue simplement là, approuvé ou non.

Comment c'est construit

Voilà l'idée. Si vous voulez construire le vôtre, voici ce qu'il y a réellement dans le code. Le serveur est un Cloudflare Worker en TypeScript. Seule la partie stockage est spécifique à Cloudflare, et je dirai quoi remplacer.

Six points d'accès

Point d'accèsAppelé parCe qu'il fait
POST /api/auth/device/startCasqueCrée un code et un secret, définit le cookie
POST /api/auth/device/pollCasqueRépond en attente, expiré, ou connecte le casque
POST /api/auth/device/lookupTéléphone connectéAffiche quel appareil et quel endroit se cachent derrière un code
POST /api/auth/device/approveTéléphone connectéAttache votre compte au code
POST /api/auth/device/issueTéléphone connectéCrée un code pour le flux inverse
POST /api/auth/device/redeemCasqueÉchange un code tapé contre une session

Chacun d'eux est un POST et chaque réponse a Cache-Control: no-store, donc rien avec un code ou une session dedans n'est mis en cache en cours de route. Lookup et approve sont séparés à dessein. Le téléphone appelle d'abord lookup pour vous montrer le nom de l'appareil et l'emplacement, et n'appelle approve qu'après que vous l'ayez lu et appuyé sur le bouton. Tout ce qu'un téléphone connecté appelle vérifie également un e-mail vérifié et passe par une limite de débit par compte.

Créer le code

Le code provient de crypto.getRandomValues, jamais de Math.random. Il y a un petit piège. Un octet aléatoire va de 0 à 255, et si vous prenez simplement byte % 31, les premières lettres de l'alphabet apparaissent légèrement plus souvent que les autres, car 256 ne se divise pas exactement par 31. La solution consiste à ignorer tout octet de 248 ou plus et à en tirer un autre. C'est ce qu'on appelle l'échantillonnage par rejet, et cela représente trois lignes supplémentaires.

const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 symboles, pas de 0 O 1 I L

function newPairingCode(): string {
  // 256 n'est pas un multiple de 31, donc on ignore les octets supérieurs pour que chaque symbole soit également probable
  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;
}

// Les gens tapent "k7mp-2qxr" ou "K7MP 2QXR". Accepte les deux.
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;
}

Le serveur pardonne également la façon dont les gens tapent. Les minuscules, les espaces et les tirets sont tous nettoyés avant que le code ne soit vérifié, de sorte que personne ne reçoit d'erreur pour l'avoir tapé tel qu'il apparaît.

Le secret vit dans le cookie

Voici le gestionnaire de démarrage, légèrement tronqué.

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 déjà pris, en générer un autre

    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: "Essayez encore." }, { status: 503 });
}

Plusieurs choses se passent. Le serveur ne stocke jamais que secretHash. Le secret brut est envoyé une seule fois, dans le cookie, donc même quelqu'un qui lit la base de données ne peut pas se faire passer pour le casque. Le cookie contient à la fois le code et le secret (K7MP2QXR.abc...), ce qui signifie que la requête d'interrogation n'a pas besoin de corps du tout. Le navigateur envoie le cookie et le serveur sait quel code vérifier et peut prouver que c'est le bon casque.

Le préfixe __Host- indique au navigateur que ce cookie ne fonctionne que sur HTTPS sur ce domaine exact, sans sous-domaine pouvant le définir ou le remplacer. Et il y a la boucle de nouvelle tentative. Deux casques pourraient aléatoirement obtenir le même code, et lorsque cela se produit, begin renvoie null et le gestionnaire en génère un nouveau. Avec 850 milliards de codes possibles, cela ne boucle pratiquement jamais, mais cela doit être géré.

Une ligne, quelques états

Chaque code en attente est une seule ligne.

CREATE TABLE pairing (
  id           INTEGER PRIMARY KEY CHECK (id = 1),  -- une ligne par objet, jamais
  secret_hash  TEXT NOT NULL,                       -- sha256 du secret du casque
  device_label TEXT NOT NULL,                       -- "Meta Quest"
  place        TEXT,                                -- "Miami, US"
  expires_at   INTEGER NOT NULL,
  account_key  TEXT                                 -- NULL jusqu'à ce que quelqu'un approuve
);

L'état d'un appairage provient directement de cette ligne. Si account_key est vide, il est en attente. Une fois que quelqu'un approuve, il est rempli. Lorsque le casque récupère, la ligne est supprimée. Si expires_at est dépassé, le code est considéré comme expiré, quoi qu'en dise le reste de la ligne. Il n'y a pas de colonne de statut à synchroniser, ce qui est une façon de moins de se tromper.

C'est ce que l'interrogation du casque finit par appeler.

claim(secretHash: string) {
  const row = this.pairing(); // null si manquant ou après expires_at
  if (!row || row.secret_hash !== secretHash) return { status: "expired" };
  if (!row.account_key) return { status: "pending" };

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

Regardez la première vérification. Un mauvais secret obtient la même réponse "expiré" qu'un code manquant. Le serveur ne dit jamais à un étranger "ce code existe, mais vous n'êtes pas le bon appareil". Lors d'une collecte réussie, la ligne est supprimée avant que la session ne soit envoyée, de sorte que la même approbation ne peut pas être utilisée deux fois.

Le côté casque

Le client est une petite boucle.

const poll = () => {
  timer = setTimeout(async () => {
    try {
      const result = await api.pollDevicePairing();
      if (result.authenticated) onSignedIn(result);
      else if (result.status === "expired") showExpired();
      else poll(); // toujours en attente, demander à nouveau dans 2,5s
    } catch {
      poll(); // le Wi-Fi instable du casque ne doit pas annuler l'appairage
    }
  }, 2_500);
};

Il utilise setTimeout qui se reprogramme au lieu de setInterval. Si une requête est lente, la suivante attend au lieu de s'accumuler derrière elle. Les erreurs maintiennent la boucle en cours, car le Wi-Fi du casque se coupe une seconde de temps en temps et cela ne devrait pas vous coûter le code. Lorsque l'interrogation réussit enfin, la réponse contient le cookie de session normal, comme une connexion par mot de passe, et le jeu se charge.

Où les codes sont stockés

Le jeu s'exécute sur Cloudflare Workers. Chaque code obtient son propre Objet Durable, nommé d'après le code, contenant une minuscule table SQLite avec une seule ligne.

J'ai choisi cela à cause de la règle de collecte unique. Un Objet Durable traite ses requêtes une par une, donc deux interrogations pour le même code ne peuvent pas toutes deux capturer l'approbation. Chacun définit également une alarme pour une minute après l'expiration du code, et l'alarme supprime son stockage. Je n'ai pas besoin d'un travail de nettoyage.

Si vous n'êtes pas sur Cloudflare, Redis fait le même travail. Stockez chaque code comme une clé avec un TTL de dix minutes (SET ... EX 600 NX, où NX vous donne la vérification "code déjà pris" gratuitement). Pour l'étape de collecte, vérifiez le secret, vérifiez l'approbation et supprimez la clé à l'intérieur d'un script Lua afin que tout se produise atomiquement. Une table Postgres simple fonctionne aussi si vous faites la réclamation comme un seul DELETE ... WHERE ... RETURNING et exécutez une requête de nettoyage de temps en temps.

Le code QR place le code après un # dans l'URL. Les navigateurs n'envoient pas cette partie au serveur, donc les codes n'apparaissent jamais dans les journaux d'accès. Et le casque interroge au lieu de garder un WebSocket ouvert. Dans le pire des cas, il s'agit de 240 petites requêtes sur dix minutes, ce qui n'est rien, et je ne voulais pas déboguer les WebSockets dans le navigateur Quest.

Restez Informé

Recevez les derniers articles et analyses directement dans votre boîte de réception.

Aucun spam. Désinscription à tout moment.