jzland docs
Riferimento tecnico

Abbinamento: come funziona

Codice a 6 cifre, cavo, canale cifrato: cosa protegge l'abbinamento e da cosa.

Una scheda vera parla con il cervello solo dopo essersi abbinata, e da lì sempre su un canale cifrato. Chi sta nella stessa rete (o in mezzo al cavo) non legge cosa passa, non si finge cervello né scheda e non può comandare la scheda. Il codice è in due posti che fanno la stessa cosa:

dovecosa
schedaui/include/jz/pair.h, ui/src/pair.clo strato tra trasporto e UI, l'archivio dei cervelli
schedaui/include/jz/cpace.h, ui/src/pair_cpace.cCPace su Monocypher
cervellopackages/pairingAbbinamenti, Sessione, ArchivioSchede, CPace su @noble
provetests/pairtest, packages/pairing/src/*.test.tsvettori della bozza, prova vera tra C e TypeScript

In breve

  • Con il codice (Wi-Fi, e qualunque collegamento): la scheda mostra 6 cifre, l'utente le scrive nel configuratore. Scheda e cervello fanno CPace (draft-irtf-cfrg-cpace-21, suite CPACE-X25519-SHA512) usando il codice come password: chi ascolta o si mette in mezzo ha un tentativo solo per indovinarlo.
  • Via cavo (USB): il cavo dimostra già che sei lì. X25519 senza codice e un tocco sulla scheda ("Abbinare a MacBook Air di Jacopo?").
  • Tutti e due finiscono con una chiave di abbinamento di 32 byte salvata dalle due parti, con le identità: la scheda tiene fino a 4 cervelli (id, nome, chiave), il cervello le schede in <cartella dati>/schede.json (0600).
  • Ricollegamento: a ogni collegamento una stretta di mano con la chiave di abbinamento e X25519 effimero. Ne escono due chiavi nuove per il canale, una per verso: chi ruba la chiave di abbinamento domani non legge le sessioni di oggi.
  • Canale: ChaCha20-Poly1305 con un contatore per verso; frame ripetuti, vecchi o toccati si scartano, e fuori dal cavo anche un frame tolto si nota (il contatore dopo salta). Dopo 2^20 frame con le stesse chiavi se ne fanno di nuove senza interrompere niente.
  • Revoca da tutte e due le parti: chi revoca, se il canale è aperto, lo dice all'altro.

Il browser (simulatore, configuratore, telefono) non passa di qui: i suoi WebSocket hanno un Origin e li autentica il cervello (vedi apps/brain/API.md). Un client senza Origin è una scheda.

Chi è chi

iddove sta
schedajz-<MAC di fabbrica> (sim-… nel simulatore), jz_platform_device_id()nel CIAO
cervellocv- + 16 cifre esadecimali casuali, creato al primo avvioschede.json, campo cervello.id

Il cervello ha anche un nome ("MacBook Air di Jacopo"), che la scheda mostra: è dentro le strette di mano (autenticato), quindi la scheda lo aggiorna a ogni ricollegamento. Gli id stanno in 39 byte e i nomi in 47 (UTF-8): il cervello taglia il nome prima di usarlo, perché i due lati devono vedere gli stessi byte.

Messaggi

Tutto sopra il framing di sempre (protocollo.md), widget 0xFF, due tipi nuovi: PAIR (17) per le strette di mano e SEALED (18) per il canale. Numeri e nomi vengono da packages/protocol/src/spec.ts (bun run gen aggiorna ui/include/jz/proto_gen.h e le tabelle qui sotto).

passonomesignificato
1CIAOscheda → cervello: chi è la scheda, il nonce per la prossima stretta di mano e se mostra un codice. In chiaro al collegamento, a ogni cambio di stato e quando la scheda chiude il canale (o riceve frame cifrati senza averlo): il cervello chiude il suo lato e ricollega. Cifrato quando la scheda vuole chiavi nuove o risponde a RINNOVA
2INIZIOcervello → scheda: comincia una stretta di mano nel modo indicato (codice, cavo, ricollega). In chiaro a canale aperto vuol dire che il cervello l'ha chiuso: la scheda chiude anche lei e risponde
3RISPOSTAscheda → cervello: il punto della scheda e la sua conferma
4CONFERMAcervello → scheda: la conferma del cervello; con il modo ricollega apre il canale
5ESITOscheda → cervello: la stretta di mano è fallita, con il motivo; segue un CIAO nuovo. In chiaro, o cifrato se il canale resta aperto (rinnovo col nonce sbagliato)
6REVOCAentrambe, solo cifrato: chi lo manda ha cancellato l'abbinamento; chi lo riceve fa lo stesso e chiude
7RINNOVAcervello → scheda, solo cifrato: il cervello vuole chiavi nuove per il canale; la scheda risponde con un CIAO cifrato
modonomesignificato
1CODICEabbinamento con il codice a 6 cifre mostrato dalla scheda (CPace)
2CAVOabbinamento via cavo: X25519 senza codice, la scheda chiede una conferma con un tocco
3RICOLLEGAstretta di mano con la chiave di abbinamento e X25519 effimero: apre il canale (o rinnova le chiavi)
esitonomesignificato
1CODICEcodice sbagliato (conta come tentativo)
2SCONOSCIUTOla scheda non conosce questo cervello: va abbinata di nuovo
3CHIAVEla conferma con la chiave di abbinamento non torna
4CHIUSOla scheda non sta mostrando un codice
5ATTESAtroppi tentativi sbagliati: il codice nuovo accetta tentativi tra poco
6NONCEil nonce dell'INIZIO non è quello dell'ultimo CIAO
7RIFIUTATOabbinamento via cavo rifiutato sulla scheda, o nessuna risposta in tempo
8NON_PERMESSOabbinamento via cavo chiesto su un collegamento che non è il cavo
9PROTOCOLLOmessaggio malformato, fuori sequenza o di una versione diversa
10TEMPOla stretta di mano non si è conclusa in tempo

Versione attuale dell'abbinamento: 1.

I campi (chiave 0 int è sempre il passo):

passocampi
CIAO1 int versione dell'abbinamento, 2 str id scheda, 3 bytes nS (16), 4 int 1 se mostra un codice, 5 int ms di vita del codice, 6 int tentativi rimasti, 7 int ms prima del prossimo tentativo, 8 int 1 se è sul cavo
INIZIO1 int modo, 2 str id cervello, 3 str nome cervello, 4 bytes nC (16), 5 bytes nS (l'ultimo del CIAO), 6 bytes punto (32: Ya, o eC)
RISPOSTA1 bytes punto (32: Yb, o eS), 2 bytes conferma (64 con il codice, 32 negli altri modi)
CONFERMA1 bytes conferma
ESITO1 int motivo
REVOCA, RINNOVAnessuno

SEALED: chiave 0 bytes = contatore u64 LE (8) ‖ cifrato ‖ tag (16). Dentro c'è il corpo di un frame qualunque (da widget all'ultimo campo), fino a 2048 byte: la busta supera di al più 30 byte il massimo dei frame in chiaro, e chi legge un collegamento cifrato accetta frame fino a 2078 byte.

Le strette di mano

Tutte cominciano da un CIAO della scheda, che porta il nonce nS. Il nonce cambia ogni volta che un INIZIO lo usa: ogni stretta di mano ha nonce nuovi da tutte e due le parti.

scheda                                         cervello
CIAO (id, nS, codice?, cavo?)  ───────────▶
                               ◀───────────  INIZIO (modo, idC, nomeC, nC, nS, punto)
RISPOSTA (punto, conferma)     ───────────▶  controlla la conferma della scheda
                               ◀───────────  CONFERMA
codice, cavo:  CIAO (nS nuovo) ───────────▶  subito un INIZIO ricollega con la chiave nuova
ricollega:     canale aperto   ◀──────────▶  SEALED…
qualcosa va storto: ESITO (motivo), poi CIAO

Codice (CPace)

ingresso CPacevalore
PRSle 6 cifre in ASCII
CIlv_cat("jz.land desk/codice/1", idC, idS): le identità, con il ruolo dato dalla posizione (sezione 10.1.1 della bozza)
sidnC ‖ nS (32 byte, metà a testa: sezione 10.9)
ADalv_cat("cervello", idC, nomeC)
ADblv_cat("scheda", idS)
ruoliil cervello è A (iniziatore), la scheda B; trascrizione transcript_ir

Il cervello manda Ya, la scheda risponde con Yb e la sua conferma Tb, il cervello con Ta: è la conferma delle chiavi della sezione 10.4 (mac_key = H("CPaceMac" ‖ sid ‖ ISK), T = HMAC-SHA-512(mac_key, lv_cat(Y, AD))). Un punto che dà il neutro (ordine piccolo) fa abortire, come chiede la bozza.

Chiave di abbinamento = HKDF-SHA-512(ISK, sale = sid, info = "jz.land desk/codice/chiave"), 32 byte.

Elligator 2 in C. CPace mappa il generatore con map_to_curve_elligator2 di RFC 9380 su un numero di 255 bit. crypto_elligator_map di Monocypher fa la stessa mappa (Z = 2) ma legge solo 254 bit. Siccome la mappa dipende solo da r², jz_cpace_map riduce r modulo p e, se r ≥ 2^254, passa p − r (che sta sotto 2^254 e dà lo stesso punto). Il vettore della bozza ha proprio il bit 254 acceso, e una prova confronta il C con @noble/curves su 1024 casi (bordi compresi: 0, p − 1, p … 2^255 − 1, 2^254 ± 1).

Cavo

th = SHA-512(lv_cat("jz.land desk/cavo/1", idC, idS, nomeC, nC, nS, eC, eS)), dh = X25519(e, e dell'altro) (neutro → abort), HKDF-SHA-512(dh, sale th, "jz.land desk/cavo/chiavi") → chiave di abbinamento ‖ chiave di conferma della scheda ‖ del cervello; conferma = HMAC-SHA-512(chiave di conferma, th), primi 32 byte. La scheda risponde solo dopo il sì sullo schermo e solo se il trasporto è il cavo (jz_pair_transport(…, cable = true)); il cervello lo chiede solo su via: "usb".

Ricollega

Stessa trascrizione con l'etichetta "jz.land desk/ricollega/1"; HKDF-SHA-512(dh ‖ chiave di abbinamento, sale th, "jz.land desk/ricollega/chiavi") → conferma scheda ‖ conferma cervello ‖ chiave cervello→scheda ‖ chiave scheda→cervello. Serve la chiave di abbinamento (autentica tutte e due le parti) e il segreto effimero (segretezza in avanti). La scheda apre il canale quando la conferma del cervello torna; il cervello quando torna quella della scheda.

Il canale

  • ChaCha20-Poly1305 di RFC 8439, nonce = 4 byte a zero ‖ contatore u64 little-endian (come WireGuard), dati associati vuoti. Una chiave e un contatore per verso.
  • Il contatore viaggia nel frame: chi riceve accetta solo contatori più grandi dell'ultimo accettato. Un frame ripetuto o vecchio si scarta senza decifrarlo, uno toccato fallisce il tag.
  • Frame che mancano. Fuori dal cavo (WebSocket su TCP) il trasporto consegna tutto e in ordine: il contatore deve essere proprio il prossimo, e un buco vuol dire che qualcuno ha tolto dei frame. Chi lo vede (dopo aver controllato il tag del frame che arriva) chiude il canale e i due lati si ricollegano (sotto). Sul cavo un frame può perdersi davvero, perché usb_link.c butta i byte quando i buffer sono pieni: lì il buco si accetta e finisce nel log (persi N frame). Il cervello sceglie con via ("usb" è il cavo), la scheda con jz_pair_transport(…, cable).
  • Rinnovo: dopo JZ_PAIR_REKEY_AFTER (2^20) frame in un verso, la scheda manda un CIAO cifrato (o il cervello un RINNOVA e la scheda risponde col CIAO), poi un ricollega dentro il canale. L'ordine dei cambi di chiave tiene i frame in viaggio: la scheda scrive con le chiavi nuove dopo la RISPOSTA, il cervello legge con le nuove appena la controlla, scrive con le nuove dopo la CONFERMA, e la scheda legge con le nuove quando la controlla. A 2 × 2^20 frame senza rinnovo ci si ferma.
  • Prima del canale passano solo i PAIR, in tutte e due le direzioni; dopo solo i SEALED, tranne i messaggi qui sotto.
  • Revoca: REVOCA cifrato, poi il canale si chiude e l'abbinamento sparisce dalle due parti. Se l'altro non è collegato lo scopre al ricollegamento (ESITO SCONOSCIUTO, o nessun tentativo dal cervello): non cancella da solo la sua copia, perché un messaggio in chiaro non basta per cancellare.

Quando un lato chiude

Ogni lato può chiudere il suo canale mentre il trasporto resta su: un rinnovo andato male (conferma sbagliata, tempo scaduto), un buco nei contatori. L'altro non lo sa, e senza fare niente resterebbero disallineati (uno manda cifrato, l'altro scarta). Per questo chi chiude lo dice, in chiaro:

chi chiudecosa mandacosa fa l'altro
la schedaCIAO in chiaro (dopo un ESITO, se c'era una stretta di mano)il cervello chiude il suo lato e ricollega con quel nonce
il cervelloINIZIO ricollega in chiaro, con il nonce dell'ultimo CIAOla scheda chiude il suo lato e lo tratta come al collegamento: col nonce ormai usato risponde ESITO NONCE e un CIAO nuovo, e il secondo tentativo va

Se il CIAO della scheda si perde, il cervello continua a mandare cifrato: la scheda, che il canale non ce l'ha più, a ogni frame cifrato ripete il CIAO (al più uno al secondo). Un ESITO in chiaro a canale aperto il cervello lo scarta: da solo non chiude niente (se è vero, il CIAO arriva subito dopo). Il cervello considera solo un CIAO della stessa scheda e della stessa versione.

Questi messaggi in chiaro non sono autenticati: chi sta in mezzo può fingerli, e ottiene un ricollegamento (il canale si chiude e si riapre, la UI rivede il cervello e manda l'HELLO). È un disservizio, che chi sta in mezzo può comunque fare buttando via i frame.

Il codice

  • 6 cifre uniformi dal generatore crittografico (jz_platform_random, rifiuto sotto 4294 × 10^6).
  • Vale 2 minuti, poi ce n'è uno nuovo. 5 tentativi: ogni INIZIO col codice ne consuma uno, comunque vada (anche se poi il cervello sparisce). Dopo 5 errori codice nuovo e 30 secondi di attesa.
  • Dopo 5 codici di fila senza abbinamento (scaduti o finiti con 5 errori) il modo si chiude e si riapre dallo schermo: al più 10 minuti, al più 25 tentativi.
  • La scheda annuncia lo stato nel CIAO (vita, tentativi, attesa): il configuratore mostra il conto alla rovescia. Il codice non viaggia mai.

Cosa protegge, e da cosa

chicosa può farecosa lo ferma
qualcuno nella stessa rete che ascoltaleggere il trafficocanale cifrato; il codice non viaggia e CPace non lo rivela
qualcuno in mezzo durante l'abbinamentoindovinare il codice, sostituirsiCPace: un tentativo per stretta di mano, 5 per codice, poi 30 s di attesa e codice nuovo; dopo 25 errori il modo si chiude. Al più 25 tentativi su un milione: 1 probabilità su 40 000
una scheda fintaabbinarsi al cervelloserve il codice di una scheda vera, scritto dall'utente per quella scheda
un cervello fintocomandare una schedala scheda accetta solo cervelli nel suo archivio (chiave di abbinamento) o chi conosce il codice mostrato
chi registra il traffico e un giorno ruba la chiave di abbinamentoleggere le sessioni passateX25519 effimero a ogni ricollegamento: le chiavi del canale non si ricavano dalla chiave di abbinamento
chi ripete, riordina o tocca i framerifare un'azione (muto, apri chat), cambiare daticontatore e tag: scartati
chi toglie un frame (un comando di blocco, di silenzio), riconoscendolo da dimensione e tempifar sparire un'azione senza che nessuno se ne accorgafuori dal cavo il contatore del frame dopo salta: chi riceve chiude e si ricollega (se ne accorge con il frame dopo, non prima). Sul cavo no (vedi i limiti)
chi taglia la connessione o inietta un CIAO, un INIZIO o un ESITO in chiarofar chiudere il canaleniente: è un disservizio; i due lati si ritrovano da soli ("Quando un lato chiude")

Limiti (cose che questo schema non copre):

  • Chiavi a riposo. Sulla scheda l'archivio sta in NVS in chiaro finché non ci sono flash e NVS cifrate e il firmware firmato (fase 1 del piano): chi ha la scheda in mano e un cavo può leggere le chiavi. Sul computer schede.json è 0600: un programma che gira già come l'utente la legge.
  • Il cavo si fida del computer. Un programma qualunque sul computer collegato può chiedere l'abbinamento via cavo: lo ferma solo il sì sullo schermo.
  • Frame tolti sul cavo. Sul cavo i buchi nei contatori si accettano (i buffer USB possono perdere byte) e vanno solo nel log: chi sta in mezzo al cavo, o un programma sul computer che si mette tra la seriale e il cervello, può togliere un frame. Ripeterlo, cambiarlo o leggerlo no.
  • Codice e utente. Se l'utente scrive il codice nella scheda sbagliata del configuratore (un attaccante che finge di mostrare un codice con lo stesso id), l'attaccante ha comunque un solo tentativo; ma il configuratore deve mostrare chiaramente quale scheda si sta abbinando.
  • Tempo costante. In C tutto è Monocypher (tempo costante, mappa compresa). Nel cervello l'aritmetica è di @noble/curves in JavaScript: non è a tempo costante garantito; i segreti lunghi (chiavi di abbinamento) passano solo da HMAC e HKDF, i segreti effimeri si usano una volta.
  • Disservizio. Chi controlla la rete può sempre impedire il collegamento.
  • Un trasporto alla volta sulla scheda. Lo strato C ha uno stato solo (variabili globali, come jz_platform_send e jz_ui_link_changed): con USB e Wi-Fi accesi insieme la piattaforma ne collega uno solo, e jz_pair_transport dell'uno chiude il canale dell'altro. Più sessioni insieme vorrebbero un contesto per trasporto (jz_pair_t *). Il cervello invece ha una Sessione per collegamento.
  • Il generatore della scheda. Il firmware prende un seme dalla sorgente vera (SAR ADC) all'avvio e lo mescola all'RNG hardware (platform_esp.c): se un giorno si usa l'ADC prima di platform_random_init, il seme non è più garantito.

Nel cervello

packages/pairing (dipende da @noble/curves, @noble/hashes e @noble/ciphers, versioni fisse; niente nativo). L'aggancio al WebSocket /ws di Bun.serve, per i client senza Origin:

import { Abbinamenti } from "@jz/pairing";
import { parser } from "@jz/protocol";

const abb = new Abbinamenti({ cartella: DIR, nome: await machineName(), log: (l, t, c) => logger("abbinamento")[l](t, c) });

// fetch: un upgrade senza Origin è una scheda; con Origin si controlla come oggi
server.upgrade(req, { data: { scheda: !req.headers.get("origin") } });

websocket: {
  open(ws) {
    if (!ws.data.scheda) return;                        // browser: come prima
    const board = nuovaScheda("wifi");                  // il Board di hub.ts
    const feed = parser((m) => suMessaggio(board, m));  // il parser di sempre, sui frame in chiaro
    const s = abb.sessione({
      via: "wifi",
      invia: (b) => ws.sendBinary(b),
      suFrame: (f) => feed(f),
      suEvento: (e) => {
        if (e.tipo === "aperto") hub.attach(board);      // da qui arriva l'HELLO, cifrato
        if (e.tipo === "chiuso" || e.tipo === "revocata") hub.detach(board);
      },
    });
    board.send = (frame) => s.invia(frame);              // tutto quello che il hub manda passa cifrato
    ws.data.sessione = s;
  },
  message(ws, data) { ws.data.sessione?.ricevi(data as Uint8Array); },
  close(ws) { ws.data.sessione?.chiudi(); },
}

La seriale USB è uguale con via: "usb" (è il cavo: permette abbinaViaCavo() e tollera i frame persi; gli altri via vogliono un trasporto che consegna tutto e in ordine). new Abbinamenti lancia un errore se schede.json c'è ma non si legge (permessi, I/O) o è di una versione più nuova: non lo sovrascrive mai. Un file rovinato finisce in schede.json.rotto-<data> (abb.archivio.messoDaParte, e nel log) e si riparte da vuoto. Per il configuratore:

cosachiamata
schede abbinate (e se sono collegate)abb.schede()
schede che mostrano un codice, con scadenza e tentativiabb.inAttesa()
abbinare con il codiceawait abb.inserisciCodice(idScheda, "123456") → { ok: true } o { ok: false, motivo, testo }
abbinare via cavoawait abb.perScheda(id)?.abbinaViaCavo()
revocareabb.revoca(idScheda)

Sessione emette presentata (ogni CIAO), abbinata, aperto, rifiutata, revocata, rinnovato, scartato, chiuso; inserisciCodice e abbinaViaCavo si risolvono quando il canale è aperto con la chiave nuova (o con il motivo del rifiuto). Le API HTTP (/api/schede…) le definisce chi scrive il cervello.

Nella scheda

jz_pair_init(&(jz_pair_cfg_t){
    .send_raw = usb_link_send, .deliver = jz_ui_feed, .link = jz_ui_link_changed,
    .on_code = …, .on_confirm = …, .on_event = …,       /* le schermate */
});
jz_pair_transport(collegata, cavo);   /* al cambio del collegamento */
jz_pair_rx(byte, n);                  /* byte dal trasporto */
jz_pair_tick();                       /* scadenze, ogni 10–100 ms */
/* jz_platform_send() → jz_pair_send(): cifrato, o scartato se il canale non c'è */

Le schermate (altro lavoro) chiamano jz_pair_code_start() / jz_pair_code_stop() e ricevono il codice con on_code; alla domanda via cavo (on_confirm(nome)) rispondono con jz_pair_confirm(sì); jz_pair_list() e jz_pair_revoke(id) per l'elenco dei cervelli. Un trasporto alla volta (vedi i limiti).

Nel firmware lo strato è spento per ora (CONFIG_JZ_PAIRING, menu "JZ Companion" di idf.py menuconfig, predefinito no): finché il cervello non aggancia packages/pairing, una scheda con lo strato acceso non parlerebbe più con il companion di oggi. Spento, il collegamento resta in chiaro come prima. Acceso, firmware/main/main.c è collegato come sopra e, finché le schermate non ci sono:

  • senza cervelli nell'archivio, a ogni collegamento della USB la scheda apre un codice e lo scrive nel log (la porta UART, idf.py monitor): codice di abbinamento 123456 (120 s, 5 tentativi);
  • alla domanda via cavo la scheda mostra un avviso breve e scrive la domanda nel log; si risponde con il tasto BOOT (breve sì, tenuto premuto no), entro un minuto.

I simulatori compilano lo strato ma non lo usano: parlano con il cervello come un browser.

Prove

bun test packages/pairing                 # vettori in TypeScript, canale, archivio, e la prova con il C
cmake -G Ninja -B tests/pairtest/build tests/pairtest && ninja -C tests/pairtest/build
./tests/pairtest/build/jz_pairtest --vettori
  • I vettori della bozza (appendice B.1: generatore, messaggi, K, ISK nei due modi, sid_output, punti di ordine piccolo e coordinate non canoniche) e di RFC 8439 passano in TypeScript e in C.
  • interop.test.ts compila tests/pairtest (la scheda senza schermo, stessi sorgenti del firmware) e la fa parlare con il cervello vero: codice giusto e sbagliato, 5 tentativi e attesa, codice scaduto, cavo sì/no/non permesso, ricollegamento dopo il riavvio di tutti e due, scheda azzerata, scambio cifrato nei due versi, frame manomessi, ripetuti e in chiaro, rinnovo delle chiavi ogni 5 frame, revoca da tutte e due le parti. E i casi in cui un lato chiude: CIAO, ESITO e INIZIO in chiaro finti a canale aperto, un frame tolto nei due versi (fuori dal cavo si chiude e si ricollega, sul cavo si va avanti), il CIAO della scheda che si perde, un rinnovo che non arriva.
  • archivio.test.ts: schede.json che non si legge, di una versione più nuova o rovinato.

On this page