←Torna al Blog

Accedere con un visore VR come fa la tua TV

26 set 202610 min read

Bannermarch ha ottenuto il supporto VR questa settimana. Apri il gioco nel browser Meta Quest, premi il pulsante e ti ritrovi all'interno della tua sala d'attesa. Prima di tutto, però, devi effettuare l'accesso. Su un Quest, ciò significa puntare un laser su una tastiera fluttuante e digitare la tua email una lettera alla volta, poi la tua password, che probabilmente si trova in un gestore di password sul tuo telefono. Quindi togli il visore per andare a controllare. (Ho scritto in un altro articolo di come funziona la VR nel browser.)

Le TV hanno risolto questo problema anni fa. L'app di YouTube su una TV ti mostra un codice, vai su un URL sul tuo telefono, lo digiti e la TV effettua l'accesso. Lo fa Netflix, lo fa la PlayStation. Esiste persino un RFC per questo (RFC 8628, la concessione di autorizzazione del dispositivo OAuth). Ho costruito la stessa cosa per il gioco. Il visore mostra un codice e tu lo approvi su /pair da un telefono o laptop dove sei già connesso.

Spiegherò come funziona, perché è un piccolo e bel pezzo di progettazione di sistema e la maggior parte delle persone lo ha utilizzato senza pensare a cosa ci sia dietro.

Chi parla con chi

Il visore e il tuo telefono non comunicano mai tra loro. Non ho fatto nulla di particolare con il Bluetooth o la rete locale. Entrambi parlano con il server del gioco, e il server conserva un codice in sospeso per dieci minuti. Tu sei l'unico collegamento tra i due schermi. Leggi otto caratteri da uno e li digiti nell'altro.

Visore                      Server                      Telefono
| | |
|-- avvia ----------------->| |
|<-- codice K7MP2QXR ---------| |
| + segreto in un cookie | |
| | |
|-- poll (ancora in attesa) ->|<-- apri /pair, digita codice -|
| |--- "Meta Quest, Miami" -->|
| |<-- approva ---------------|
|-- poll + segreto -------->| |
|<-- connesso --------------| |

Ecco l'ordine in cui avvengono le cose.

Il visore chiede al server di avviare un accoppiamento. Il server genera un breve codice da leggere, come K7MP2QXR. Genera anche un lungo segreto casuale, 32 byte, e lo restituisce al visore in un cookie. Il server memorizza un hash di quel segreto, non il segreto.

Il visore visualizza il codice sullo schermo e inizia a chiedere al server ogni 2,5 secondi se qualcuno lo ha approvato. C'è anche un codice QR sullo schermo, che punta a /pair#K7MP2QXR. Questo funziona benissimo su una TV o un laptop. Su un visore è piuttosto inutile, ma ci arriverò.

Apri /pair sul tuo telefono e digita il codice. Il server lo cerca e ti mostra quale dispositivo sta chiedendo e approssimativamente dove si trova. Indovina "Meta Quest" dall'User-Agent del browser e ottiene una città e un paese dall'IP. Tocchi approva. Il server scrive il tuo ID account accanto al codice in sospeso.

Il prossimo poll del visore invia il cookie. Il server esegue l'hash del segreto, lo confronta, vede l'approvazione, elimina la voce in sospeso e assegna al visore la propria sessione. Sei dentro il gioco.

Perché un codice rubato è inutile

Il codice si trova su uno schermo, quindi presumo che altre persone possano leggerlo. Tuo fratello sul divano può. Chiunque stia guardando uno streaming può.

Questo è il motivo del segreto. Quando approvi, il tuo account non va a chiunque conosca il codice. Va al browser che detiene il segreto, e quel browser è il visore. Il cookie è HttpOnly, quindi JavaScript sulla pagina non può leggerlo, e SameSite=Strict, quindi un altro sito non può far inviare il cookie dal browser. Se qualcuno copia il codice dalla tua TV, non può raccogliere nulla con esso.

La sessione può essere raccolta una sola volta. Il server elimina la voce in sospeso nel momento in cui il visore la ritira, e qualsiasi poll successivo riceve "scaduto". Se nessuno approva il codice entro dieci minuti, è comunque sparito.

Non mi sono preoccupato molto di indovinare. I codici usano 31 caratteri (ho eliminato 0, O, 1, I e L perché le persone li confondono), e otto di questi danno circa 850 miliardi di combinazioni. L'approvazione è limitata in frequenza, e ogni codice dura solo dieci minuti.

Phishing

Il segreto non aiuta se approvi il codice sbagliato. Diciamo che qualcuno avvia un accoppiamento sul proprio laptop e ti invia un messaggio "inserisci questo codice per confermare il tuo account". Lo approvi, e ora il loro laptop ha la tua sessione. Si chiama phishing del codice del dispositivo. L'RFC lo avverte, e gli aggressori lo hanno utilizzato contro gli account Microsoft 365.

Non posso fermarlo completamente, ma posso renderlo ovvio. La schermata di approvazione indica il nome del dispositivo e il luogo da cui si sta connettendo, con un avviso di approvare solo un codice che puoi vedere su un dispositivo che stai tenendo in mano. Se vivi a Miami e dice "PC Windows" in un paese in cui non sei mai stato, probabilmente te ne accorgerai.

I visori non sono TV

Circa venti minuti dopo aver pubblicato la prima versione, ne ho pubblicata una seconda. Con una TV puoi guardare il codice e il tuo telefono contemporaneamente. Con un visore indosso non puoi vedere affatto il tuo telefono, quindi finisci per sollevare il visore per leggere il codice, digitarlo e rimetterlo. Anche il codice QR non funziona, perché la fotocamera del tuo telefono non può vedere all'interno delle lenti.

Quindi ora va anche nell'altro verso. Su /pair c'è un pulsante "Ottieni un codice per il tuo visore". Il tuo telefono chiede al server un codice collegato al tuo account, lo leggi, indossi il visore e lo digiti nella schermata di accesso. Otto caratteri con la tastiera laser sono ancora fastidiosi. È molto meno fastidioso di un'email e una password.

Questa versione è meno sicura della prima. Non c'è un segreto dietro il codice, quindi chiunque lo digiti per primo entra. L'ho comunque tenuta e recintata. Il codice viene emesso solo a un account connesso con un'email verificata. Funziona una volta e dura dieci minuti, e la digitazione dei codici è limitata in frequenza.

Tengo anche i due tipi di codici in tabelle separate. Se la casella "digita un codice" accettasse il codice di un visore, allora subito dopo averne approvato uno, chiunque l'avesse visto sulla tua TV potrebbe digitarlo altrove e ottenere la tua sessione. Con tabelle separate, il codice di un visore fallisce semplicemente lì, approvato o meno.

Come è costruito

Questa è l'idea. Se vuoi costruirne uno tuo, ecco cosa c'è effettivamente nel codice. Il server è un Cloudflare Worker in TypeScript. Solo la parte di archiviazione è specifica di Cloudflare, e dirò cosa sostituire.

Sei endpoint

EndpointChiamato daCosa fa
POST /api/auth/device/startVisoreCrea un codice e un segreto, imposta il cookie
POST /api/auth/device/pollVisoreRisponde in sospeso, scaduto o connette il visore
POST /api/auth/device/lookupTelefono connessoMostra quale dispositivo e luogo si trovano dietro un codice
POST /api/auth/device/approveTelefono connessoCollega il tuo account al codice
POST /api/auth/device/issueTelefono connessoCrea un codice per il flusso inverso
POST /api/auth/device/redeemVisoreScambia un codice digitato per una sessione

Ognuno di essi è un POST e ogni risposta ha Cache-Control: no-store, quindi nulla con un codice o una sessione al suo interno viene memorizzato nella cache lungo il percorso. Lookup e approve sono separati apposta. Il telefono chiama prima lookup per mostrarti il nome del dispositivo e il luogo, e chiama approve solo dopo che l'hai letto e toccato il pulsante. Tutto ciò che un telefono connesso chiama controlla anche un'email verificata e passa attraverso un limite di frequenza per account.

Creazione del codice

Il codice proviene da crypto.getRandomValues, mai da Math.random. C'è una piccola insidia. Un byte casuale va da 0 a 255, e se prendi semplicemente byte % 31, le prime lettere dell'alfabeto appaiono leggermente più spesso delle altre, perché 256 non è divisibile per 31. La soluzione è scartare qualsiasi byte da 248 in su e prenderne un altro. Questo si chiama campionamento per rifiuto, e sono tre righe in più.

const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 simboli, no 0 O 1 I L

function newPairingCode(): string {
  // 256 non è un multiplo di 31, quindi scarta i byte superiori per mantenere ogni simbolo ugualmente probabile
  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;
}

// Le persone digitano "k7mp-2qxr" o "K7MP 2QXR". Accetta entrambi.
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;
}

Il server perdona anche come digitano le persone. Minuscole, spazi e trattini vengono tutti ripuliti prima che il codice venga controllato, in modo che nessuno riceva un errore per averlo digitato come appare.

Il segreto vive nel cookie

Ecco l'handler di avvio, leggermente troncato.

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; // codice già preso, ne genera un altro

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

Ci sono diverse cose in gioco. Il server memorizza sempre e solo secretHash. Il segreto grezzo viene inviato una sola volta, nel cookie, quindi anche chi legge il database non può fingere di essere il visore. Il cookie contiene sia il codice che il segreto (K7MP2QXR.abc...), il che significa che la richiesta di poll non ha bisogno di un corpo. Il browser invia il cookie e il server sa quale codice controllare e può dimostrare che è il visore giusto.

Il prefisso __Host- dice al browser che questo cookie funziona solo su HTTPS sullo stesso dominio esatto, senza sottodomini in grado di impostarlo o sovrascriverlo. E c'è il ciclo di ripetizione. Due visori potrebbero casualmente ottenere lo stesso codice, e quando ciò accade begin restituisce null e l'handler ne genera uno nuovo. Con 850 miliardi di codici possibili, praticamente non si ripete mai, ma deve essere gestito.

Una riga, alcuni stati

Ogni codice in sospeso è una singola riga.

CREATE TABLE pairing (
  id           INTEGER PRIMARY KEY CHECK (id = 1),  -- una riga per oggetto, sempre
  secret_hash  TEXT NOT NULL,                       -- sha256 del segreto del visore
  device_label TEXT NOT NULL,                       -- "Meta Quest"
  place        TEXT,                                -- "Miami, US"
  expires_at   INTEGER NOT NULL,
  account_key  TEXT                                 -- NULL finché qualcuno non approva
);

Lo stato di un accoppiamento proviene direttamente da quella riga. Se account_key è vuoto, è in sospeso. Una volta che qualcuno approva, viene compilato. Quando il visore ritira, la riga viene eliminata. Se expires_at è passato, il codice è considerato scaduto indipendentemente da ciò che dice la riga. Non c'è una colonna di stato da mantenere sincronizzata, il che è un modo in meno per sbagliare.

Questo è ciò che il poll del visore finisce per chiamare.

claim(secretHash: string) {
  const row = this.pairing(); // null se mancante o scaduto
  if (!row || row.secret_hash !== secretHash) return { status: "expired" };
  if (!row.account_key) return { status: "pending" };

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

Guarda il primo controllo. Un segreto errato ottiene la stessa risposta "scaduto" di un codice mancante. Il server non dice mai a uno sconosciuto "quel codice esiste, ma non sei il dispositivo giusto". In caso di raccolta riuscita, la riga viene eliminata prima che la sessione venga inviata, quindi la stessa approvazione non può essere utilizzata due volte.

Il lato del visore

Il client è un piccolo ciclo.

const poll = () => {
  timer = setTimeout(async () => {
    try {
      const result = await api.pollDevicePairing();
      if (result.authenticated) onSignedIn(result);
      else if (result.status === "expired") showExpired();
      else poll(); // ancora in sospeso, chiedi di nuovo tra 2,5 secondi
    } catch {
      poll(); // il Wi-Fi instabile del visore non dovrebbe interrompere l'accoppiamento
    }
  }, 2_500);
};

Utilizza setTimeout che si rischedula invece di setInterval. Se una richiesta è lenta, la successiva attende prima di accumularsi dietro di essa. Gli errori mantengono attivo il ciclo, perché il Wi-Fi del visore cade per un secondo ogni tanto e ciò non dovrebbe costarti il codice. Quando il poll finalmente ha successo, la risposta contiene il normale cookie di sessione, come un login con password, e il gioco si carica.

Dove sono archiviati i codici

Il gioco viene eseguito su Cloudflare Workers. Ogni codice ottiene il proprio Durable Object, chiamato con il nome del codice, che contiene una piccola tabella SQLite con una riga.

Ho scelto questa soluzione a causa della regola "raccogli una volta". Un Durable Object gestisce le sue richieste una alla volta, quindi due poll per lo stesso codice non possono entrambi ottenere l'approvazione. Ognuno imposta anche un allarme per un minuto dopo la scadenza del codice, e l'allarme elimina la sua archiviazione. Non ho bisogno di un processo di pulizia.

Se non sei su Cloudflare, Redis fa lo stesso lavoro. Archivia ogni codice come chiave con un TTL di dieci minuti (SET ... EX 600 NX, dove NX ti dà il controllo "codice già preso" gratuitamente). Per il passaggio di raccolta, controlla il segreto, controlla l'approvazione ed elimina la chiave all'interno di uno script Lua in modo che tutto avvenga atomicamente. Funziona anche una tabella Postgres normale se fai la raccolta come un singolo DELETE ... WHERE ... RETURNING ed esegui una query di pulizia ogni tanto.

Il codice QR inserisce il codice dopo un # nell'URL. I browser non inviano quella parte al server, quindi i codici non compaiono mai nei log di accesso. E il visore effettua poll invece di mantenere aperta una WebSocket. Nel peggiore dei casi, si tratta di 240 piccole richieste in dieci minuti, che non è nulla, e non volevo fare il debug delle WebSocket nel browser Quest.

Rimani Aggiornato

Ricevi gli ultimi articoli e approfondimenti direttamente nella tua casella di posta.

Niente spam. Puoi annullare in qualsiasi momento.