terminal-smart-cli 0.98.1 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,789 @@
1
+ # Protocolo do hub do Terminal Smart — versão 1
2
+
3
+ Especificação do canal entre o **nó** (`ts hub`, no PC ou na VPS dos agentes), o **aparelho**
4
+ (PWA no celular) e o **relay** (`ts-relay`, cego). A implementação de referência é
5
+ `ts-proto.js`, na mesma pasta; os testes ficam em `cli/test/hub-proto.test.js`. Onde este
6
+ texto e o código divergirem, é defeito: corrigir os dois juntos.
7
+
8
+ Palavras normativas: **DEVE**, **NÃO DEVE**, **PODE**.
9
+
10
+ ---
11
+
12
+ ## 0. Visão geral
13
+
14
+ ```
15
+ PC / VPS (nó) ts-relay (cego) celular (aparelho)
16
+ ───────────── ─────────────── ──────────────────
17
+ WSS de saída ───────────────► /ws ◄─────────────────────── WSS
18
+ relay.auth (desafio) confere assinatura relay.auth (desafio)
19
+ relay.lista (assinada) guarda IDs + chaves públicas
20
+ rota{para, dados} ◄──────────── repassa `dados` opaco ──────► rota{de, dados}
21
+ └──────── fim a fim: aperto de mão ECDH + AES-256-GCM ────────┘
22
+ ```
23
+
24
+ - O aparelho nunca fala direto com o nó. O nó só abre conexão **de saída**.
25
+ - Tudo o que importa vai cifrado de ponta a ponta. O relay vê: IDs, tipo dos quadros de
26
+ controle, tamanhos e horários. Não vê nomes, métodos, parâmetros nem conteúdo de terminal.
27
+ - Duas chaves de longo prazo por par: a do nó e a do aparelho. Roubar a chave do nó permite
28
+ se passar pelo nó para o celular, mas **não** permite mandar comandos ao hub: isso exige a
29
+ chave do aparelho, que não sai do aparelho.
30
+
31
+ ### 0.1 Ameaças consideradas
32
+
33
+ | Quem | Pode | O protocolo garante |
34
+ |---|---|---|
35
+ | Relay hostil ou invadido | ler, reter, repetir, reordenar, descartar, injetar e forjar quadros | não lê nem forja conteúdo; repetição/reordenação derruba a sessão; não reinsere aparelho revogado (lista assinada) |
36
+ | Quem fotografou o QR | usar o segredo nos 5 min | o convite é de uso único; o PC mostra a impressão do aparelho que chegou e só grava com confirmação humana |
37
+ | Rede entre as pontas e o relay | o mesmo que o relay | TLS (WSS) por cima; o conteúdo continua cifrado de ponta a ponta |
38
+ | Agente com a chave do nó | se passar pelo nó | não consegue assinar comandos do aparelho (o envelope ainda leva o ID do nó, §6.1) |
39
+ | Aparelho perdido | — | revogar no PC: nova lista sem ele; o hub corta as sessões na hora |
40
+ | Quem fotografou o QR e chegou depois | mandar um segundo pedido | recebe `usado` assinado; o celular legítimo que chegar depois vê "Outro aparelho usou este convite — recuse no PC" e o PC é avisado (§3.6) |
41
+ | Agente (outro usuário) na VPS | ler/escrever arquivos dele, tmux dele | o hub não fala com o tmux (lê instantâneos já mascarados, §6.2) e o "Parar tudo" só vale com confirmação de root (§6.2) |
42
+
43
+ Fora do escopo desta etapa: aparelho desbloqueado nas mãos de terceiros (entrega seguinte:
44
+ biometria/WebAuthn para ações de risco), negação de serviço pelo relay (ele sempre pode
45
+ descartar tudo).
46
+
47
+ **Limites que o protocolo NÃO resolve (registrados de propósito):**
48
+
49
+ - **Quem controla a origem que serve a PWA controla o cliente.** A chave do aparelho é não
50
+ exportável, mas a página a USA: quem trocar o JavaScript servido (servidor da PWA, conta da
51
+ hospedagem/CDN, Cloudflare com injeção de script — Rocket Loader, Zaraz, analytics) assina
52
+ comandos e lê telas como se fosse o celular. CSP e esquema fechado não protegem contra quem
53
+ serve a própria página. Mitigação: origem dedicada, sem script de terceiros, deploy
54
+ controlado e os requisitos de Cloudflare do `cli/relay/README.md`. WebAuthn (entrega
55
+ seguinte) amarra ações de risco a um gesto no aparelho, mas também confia na página da
56
+ mesma origem.
57
+ - **No PC (entrega 2), qualquer processo do mesmo usuário alcança o controle local** (lê
58
+ `controle.json` com a ficha e conecta no pipe/socket): pode abrir convite e confirmar um
59
+ pareamento. Por isso, antes de o PC exportar terminais (entrega 2), a confirmação do
60
+ pareamento no PC exigirá uma interação do sistema (Windows Hello / verificação de
61
+ consentimento do usuário) que um processo não consegue simular. Na entrega 1 o PC não tem
62
+ terminais nem "Parar tudo" (`pararDisponivel:false`).
63
+ - **Backup antigo de `aparelhos.json`** traria de volta um aparelho revogado (a lista local é a
64
+ fonte da verdade, e o hub publica versão `max(anterior+1, relógio)`, que o relay aceita). O
65
+ hub guarda as revogações em `revogados.jsonl` e a maior versão publicada em
66
+ `lista-versao.json`, fora do `aparelhos.json`: revogado depois do próprio pareamento continua
67
+ revogado e a restauração é registrada na auditoria. Quem restaura o diretório inteiro desfaz
68
+ isso também; depois de qualquer restauração, conferir `ts hub aparelhos` e revogar de novo.
69
+
70
+ ---
71
+
72
+ ## 1. Convenções
73
+
74
+ - **Texto**: UTF-8. Texto com substituto UTF-16 solitário é inválido.
75
+ - **Bytes em JSON**: base64url **sem preenchimento** (RFC 4648 §5). Decodificação estrita:
76
+ recusa `=`, espaços, caracteres fora do alfabeto, comprimento ≡ 1 (mod 4) e bits de sobra
77
+ diferentes de zero (só existe uma codificação válida para cada sequência de bytes).
78
+ - **Tempo**: milissegundos desde 1970-01-01 UTC, inteiro (`ts`, `exp`, `emitidaEm`).
79
+ - **JSON canônico** (o que se assina e o que entra em hash): objetos com chaves ordenadas
80
+ por unidade de código UTF-16 (a ordem padrão de `Array.prototype.sort`), sem espaços,
81
+ strings como `JSON.stringify`, números **só inteiros seguros** (|n| ≤ 2⁵³−1; `-0` vira
82
+ `0`), `true`/`false`/`null`, arrays na ordem dada; nada de `undefined`, `NaN`, objetos não
83
+ simples; profundidade máxima 16. Exemplo:
84
+ `{"b":1,"a":[true,null,"x"],"c":{"z":"é","y":-0}}` → `{"a":[true,null,"x"],"b":1,"c":{"y":0,"z":"é"}}`.
85
+ - **Esquema fechado**: todo objeto recebido tem um conjunto exato de campos. Campo a mais,
86
+ campo faltando ou tipo errado = recusa (`E_FORMATO`).
87
+ - **Rótulo de domínio**: toda assinatura e todo AAD começam com
88
+ `rotulo(nome) = UTF-8("ts-hub/1/" + nome) || 0x00`. Toda derivação HKDF usa
89
+ `info = UTF-8("ts-hub/1/" + nome)`. Um valor produzido para um fim nunca vale para outro.
90
+
91
+ | Rótulo | Uso |
92
+ |---|---|
93
+ | `parear/id` | HKDF: identificador público do convite (`pid`) |
94
+ | `parear/a2n`, `parear/n2a` | HKDF: chaves AES do pareamento, uma por direção |
95
+ | `parear/pedido`, `parear/resposta` | AAD dos quadros de pareamento e assinaturas do texto claro |
96
+ | `sessao` | campo `t` do transcript (`"ts-hub/1/sessao"`) |
97
+ | `sessao/no`, `sessao/aparelho` | assinatura do hash do transcript por cada lado |
98
+ | `sessao/a2n`, `sessao/n2a` | HKDF: chaves AES da sessão, uma por direção |
99
+ | `dados` | AAD dos quadros cifrados de sessão |
100
+ | `comando` | assinatura do envelope de comando |
101
+ | `relay/auth` | assinatura da resposta ao desafio do relay |
102
+ | `lista-aparelhos` | assinatura da lista de aparelhos autorizados |
103
+
104
+ Primitivas (somente WebCrypto): ECDSA P-256 com SHA-256 (assinatura no formato IEEE P1363,
105
+ `r‖s`, 64 bytes), ECDH P-256, HKDF-SHA256, AES-256-GCM (IV 96 bits, etiqueta 128 bits),
106
+ SHA-256, `getRandomValues`.
107
+
108
+ ---
109
+
110
+ ## 2. Identidades
111
+
112
+ - Nó e aparelho têm, cada um, um par **ECDSA P-256** de longo prazo.
113
+ - **SPKI**: DER da chave pública, **sempre** na forma de ponto não comprimido: exatamente
114
+ 91 bytes, começando por `3059301306072a8648ce3d020106082a8648ce3d03010703420004`. Outra
115
+ forma é recusada (`E_CHAVE`); assim cada chave tem um único SPKI e um único ID. O ponto
116
+ comprimido (33 bytes) só aparece dentro do convite v2 (§3.1); o aparelho o expande para
117
+ este SPKI antes de qualquer outro uso.
118
+ - **ID** = `base64url(SHA-256(SPKI))` truncado nos primeiros **22 caracteres** (132 bits).
119
+ Formato: `^[A-Za-z0-9_-]{22}$`.
120
+ - **Impressão digital** (para humanos) = primeiros 16 dígitos hexadecimais de
121
+ `SHA-256(SPKI)`, em maiúsculas, em grupos de 4: `XXXX-XXXX-XXXX-XXXX` (64 bits).
122
+ - Vetor: SPKI do ponto público do RFC 6979 A.2.5 → ID `Wnp4zKSg9CDZvGK7Zpw8J1`, impressão
123
+ `5A7A-78CC-A4A0-F420`.
124
+ - Quem recebe um par (ID, SPKI) **DEVE** recalcular o ID a partir do SPKI (`E_ID`).
125
+
126
+ **Guarda das chaves privadas**
127
+
128
+ - **Aparelho**: gerada com `extractable:false`. Guardada como `CryptoKey` no IndexedDB
129
+ (clone estruturado); nunca existe em bytes fora do motor de criptografia do navegador.
130
+ `identidadeDeChaves` recusa restaurar aparelho com chave exportável
131
+ (`E_CHAVE_EXPORTAVEL`).
132
+ - **Nó**: exportável, porque o processo Node precisa gravá-la: PKCS#8 cifrado com DPAPI no
133
+ Windows; na VPS, arquivo `0600` do usuário `tshub`. O módulo só exporta/importa
134
+ (`exportarIdentidadeNo` / `importarIdentidadeNo`); a cifra em disco é da etapa do hub.
135
+
136
+ ---
137
+
138
+ ## 3. Pareamento
139
+
140
+ Começa **sempre no PC**. Nunca pelo celular.
141
+
142
+ ### 3.1 Convite (QR)
143
+
144
+ O nó gera:
145
+
146
+ - `segredo`: 32 bytes aleatórios (256 bits), **uso único**;
147
+ - `exp` = agora + 5 min (máximo 300 000 ms).
148
+
149
+ O QR é uma URL. O hub gera o formato **v2** (compacto); o **v1** (JSON) só é lido, para
150
+ compatibilidade com QR antigos — nenhum hub atual o gera.
151
+
152
+ ```
153
+ v2 (gerado): https://<origem da PWA>/parear#v2.<base64url(71 bytes)>
154
+ v1 (leitura): https://<origem da PWA>/parear#v1.<base64url(JSON canônico da carga)>
155
+ ```
156
+
157
+ A origem da PWA é, por padrão, a do próprio relay (`https://` + host de `relay`); o hub pode
158
+ apontar para outra (`TS_HUB_PWA_URL`, `criarConvite({ pwa })`), quando a PWA é servida por
159
+ outra origem (o relay então não a serve: `TS_RELAY_SERVIR_PWA=0`).
160
+
161
+ **Carga v2** — concatenação fixa de **exatamente 71 bytes** (qualquer outro tamanho =
162
+ `E_CONVITE_INVALIDO`), em base64url sem preenchimento (95 caracteres):
163
+
164
+ | Bytes | Conteúdo |
165
+ |---|---|
166
+ | 0–32 (33) | chave pública ECDSA P-256 do nó em ponto **comprimido** SEC1: `0x02`/`0x03` (paridade de y) ‖ X |
167
+ | 33–64 (32) | `segredo` do convite |
168
+ | 65–70 (6) | `exp`: vencimento em ms, inteiro sem sinal big-endian (48 bits) |
169
+
170
+ - O `nodeId` **não** vai no QR: o aparelho descomprime o ponto (y = (x³ − 3x + b)^((p+1)/4)
171
+ mod p, com a paridade do prefixo; recusa prefixo ≠ `0x02`/`0x03`, x ≥ p e x sem raiz,
172
+ conferindo y² ≡ x³ − 3x + b), monta o SPKI (prefixo DER fixo da P-256 + `0x04` ‖ X ‖ Y),
173
+ confere com `validarSpki`, importa com o WebCrypto (validação final do ponto) e **calcula**
174
+ `nodeId` e impressão digital a partir dele (§2).
175
+ - O `relay` **não** vai no QR: o aparelho informa o seu (`lerConvite(url, { relay })`, a
176
+ origem `wss://` com que já conecta — na PWA, a do `config.json`). Sem essa opção, vale a
177
+ origem WebSocket equivalente à da URL (`https` → `wss`, mesmo host; `http` → `ws` só com
178
+ `permitirLocal`). O objeto devolvido continua com o campo `relay` (esse valor).
179
+ - Tamanho típico: `https://celular.terminalsmart.com.br/parear#v2.…` tem **142 caracteres**
180
+ (o v1 equivalente tinha ~440), e o QR cai de 73 para 45 módulos (versão 7, correção L).
181
+
182
+ **Por que tirar o relay e o `nodeId` não enfraquece nada.** O relay é transporte **não
183
+ confiável** (§0.1): ele só repassa quadros cifrados e não consegue se passar pelo nó. A
184
+ autenticidade do nó vem **só** da chave pública que está no QR — o pedido é cifrado com
185
+ chaves derivadas do `segredo` e do SPKI do nó (§3.2), e a resposta é assinada por essa chave
186
+ (§3.4). Um relay errado (ou malicioso) consegue no máximo atrasar ou derrubar o pareamento.
187
+ O `nodeId` é, por definição, derivado dessa chave (§2); no v1 ele vinha junto e era só
188
+ conferido (`E_ID`), então calculá-lo no aparelho dá o mesmo resultado com menos bytes. Trocar
189
+ a paridade do ponto produz OUTRA chave válida — ou seja, outro `nodeId` e outra impressão;
190
+ não há como apontar para o nó certo com um ponto adulterado.
191
+
192
+ **Carga v1** (só leitura; exatamente estes campos):
193
+
194
+ | Campo | Tipo | Conteúdo |
195
+ |---|---|---|
196
+ | `relay` | texto | origem WebSocket canônica do relay: `wss://host[:porta]` (sem caminho; porta padrão omitida) |
197
+ | `nodeId` | ID | ID do nó |
198
+ | `nodePubSpki` | base64url | SPKI do nó |
199
+ | `segredo` | base64url (32 bytes) | segredo do convite |
200
+ | `exp` | inteiro | vencimento (ms) |
201
+
202
+ No v1 a opção `relay` de `lerConvite` é ignorada (o relay vem do QR), e a PWA exige que ele
203
+ seja o relay da sua configuração (`config.json`; senão `E_RELAY_DIFERENTE`, código local).
204
+
205
+ Regras comuns às duas versões:
206
+
207
+ - O fragmento (`#…`) **não vai ao servidor** quando o navegador abre a URL; a PWA lê o
208
+ fragmento localmente e o apaga do histórico (`history.replaceState`).
209
+ - Sem origem de PWA informada, o host da URL **DEVE** ser o host do relay (`https` ↔ `wss`):
210
+ no v1, o `relay` da carga; no v2, o `relay` informado (ou, sem ele, a própria origem da
211
+ URL). Com a PWA em outra origem, o aparelho passa `origemPwa` (a própria origem) a
212
+ `lerConvite`, e a URL **DEVE** ser dessa origem.
213
+ - Modo local (só testes e etapa 6): `ws://` e `http://` para `localhost`, `127.0.0.1`,
214
+ `[::1]` e `*.localhost`, apenas com a opção `permitirLocal`.
215
+ - A URL contém o segredo: **NÃO DEVE** ir para log, telemetria, área de transferência ou
216
+ arquivo. Só para a tela do QR.
217
+
218
+ O aparelho, ao ler (`lerConvite`), **DEVE** recusar: esquema que não seja `https`, caminho
219
+ diferente de `/parear`, consulta (`?`), credenciais na URL, prefixo diferente de `#v1.` e
220
+ `#v2.` (`E_VERSAO` se for `#vN.` com N ∉ {1, 2}), carga que não decodifica, com tamanho
221
+ (v2) ou campos (v1) diferentes, ponto que não descomprime ou chave que o WebCrypto não
222
+ importa, `nodeId` (v1) que não é o ID de `nodePubSpki` (`E_ID`), convite claramente vencido
223
+ (`agora > exp + 120 s`) e `exp` absurdo (`exp − agora > 5 min + 120 s`). O relógio que
224
+ manda é o do nó; a checagem no aparelho é só de bom senso. A opção `relay` inválida é erro de
225
+ quem chama (`E_ARGUMENTO`).
226
+
227
+ ### 3.2 Derivação
228
+
229
+ ```
230
+ salt = SHA-256(SPKI do nó)
231
+ pid = primeiros 22 caracteres de base64url(HKDF(segredo, salt, "ts-hub/1/parear/id", 16))
232
+ K_a2n = HKDF(segredo, salt, "ts-hub/1/parear/a2n", 32) → AES-256-GCM, aparelho → nó
233
+ K_n2a = HKDF(segredo, salt, "ts-hub/1/parear/n2a", 32) → AES-256-GCM, nó → aparelho
234
+ ```
235
+
236
+ `pid` é o único identificador do convite que o relay vê. Ele não revela o segredo (HKDF
237
+ unidirecional) e é diferente a cada convite.
238
+
239
+ ### 3.3 Pedido (aparelho → nó)
240
+
241
+ Texto claro (JSON), com assinatura do aparelho como prova de posse da chave:
242
+
243
+ ```
244
+ { t:"parear.pedido", pid, no:<nodeId>, aparelhoSpki, nome, ts, assinatura }
245
+ assinatura = ECDSA(chave do aparelho, rotulo("parear/pedido") || canonico(objeto sem "assinatura"))
246
+ ```
247
+
248
+ - `nome`: rótulo do aparelho para a tela do PC. O aparelho normaliza (NFC, espaços
249
+ colapsados, aparado). Regras: 1 a 40 caracteres; proibidos caracteres de controle, de
250
+ formatação invisível e de sobreposição bidirecional (U+0000–001F, U+007F–009F, U+00AD,
251
+ U+061C, U+180E, U+200B–200F, U+2028–202E, U+2060–206F, U+FEFF, U+FFF9–FFFB). Inválido =
252
+ `E_NOME`.
253
+
254
+ Quadro no fio (o que o relay vê):
255
+
256
+ ```
257
+ { t:"parear.pedido", v:1, pid, iv:<12 bytes aleatórios>, ct:<AES-GCM(K_a2n, iv, texto claro, AAD)> }
258
+ AAD = rotulo("parear/pedido") || UTF-8(pid)
259
+ ```
260
+
261
+ O convite também é de uso único **no aparelho**: depois de montar o pedido, o segredo é
262
+ apagado da memória do convite.
263
+
264
+ ### 3.4 Processamento no nó (ordem exata)
265
+
266
+ 1. Esquema do quadro (`E_FORMATO`), `v` = 1 (`E_VERSAO`).
267
+ 2. `pid` conhecido (`E_CONVITE_DESCONHECIDO`); não usado (`E_CONVITE_USADO`); não vencido
268
+ pelo relógio do nó (`E_CONVITE_EXPIRADO`, e o convite é descartado). Pedido para convite
269
+ **já usado** enquanto a decisão do primeiro está aberta: `responderUsado` decifra com
270
+ `K_a2n`, confere o texto claro como no passo 5 e devolve ao aparelho que mandou ESTE pedido
271
+ a resposta assinada com `estado:"usado"` (§3.5), pelo canal dele; no máximo
272
+ `PAREAR_USADO_MAX` (4) tentativas por convite; o mesmo aparelho do primeiro pedido não
273
+ recebe `usado`. Nada muda no convite.
274
+ 3. Decifrar com `K_a2n`. Falha = `E_DECIFRAR` e conta uma tentativa; **5 tentativas que não
275
+ decifram descartam o convite** (proteção contra adivinhação e contra quem só quer
276
+ gastar o convite: sem o segredo ninguém produz um pedido que decifre).
277
+ 4. **Consumir o convite** (marcar usado) — no primeiro pedido que decifra, sem `await`
278
+ entre a checagem e a marcação: dois pedidos simultâneos nunca consomem o mesmo convite.
279
+ 5. Esquema do texto claro; `pid` e `no` conferem; `ts` dentro de ±120 s (`E_TEMPO`); `nome`
280
+ válido (`E_NOME`); SPKI válido (`E_CHAVE`); assinatura (`E_ASSINATURA`). Qualquer falha
281
+ aqui **encerra** o convite (já consumido).
282
+ 6. Devolver à etapa do hub `{ id, spki, nome, impressao }` do aparelho.
283
+
284
+ ### 3.5 Resposta (nó → aparelho)
285
+
286
+ ```
287
+ texto claro = { t:"parear.resposta", pid, no:<nodeId>, aparelho:<ID do aparelho>, estado, ts, assinatura }
288
+ assinatura = ECDSA(chave do nó, rotulo("parear/resposta") || canonico(objeto sem "assinatura"))
289
+ quadro = { t:"parear.resposta", v:1, pid, iv, ct } AAD = rotulo("parear/resposta") || UTF-8(pid)
290
+ ```
291
+
292
+ `estado`: `pendente` (opcional, no máximo uma vez, sempre primeiro) → `aceito` ou
293
+ `recusado` (final; depois disso o nó apaga as chaves do convite); ou `usado` sozinho (final:
294
+ outro aparelho mandou pedido antes com este convite; a PWA mostra "Outro aparelho usou este
295
+ convite — recuse no PC"). O aparelho confere a assinatura com a chave do nó que veio **no
296
+ QR** (canal autêntico: a tela do PC), que `aparelho` é ele mesmo, e aceita só essa ordem
297
+ (`E_PAREAMENTO_ESTADO`).
298
+
299
+ ### 3.6 Confirmação humana
300
+
301
+ - Os dois lados calculam a **impressão digital do aparelho**
302
+ (`impressaoDeSpki(aparelhoSpki)`): o celular, da própria chave (e a mostra); o PC, da
303
+ chave que chegou no pedido (e a usa para conferir o código digitado). O aparelho também
304
+ pode mostrar a impressão do nó.
305
+ - O PC **não mostra** a impressão do aparelho. A pessoa lê a impressão **no celular** e
306
+ **digita no PC** os 8 primeiros caracteres (comparação sem hífen e sem diferenciar
307
+ maiúsculas; código errado = `E_CONFIRMACAO` no controle local, e no terceiro erro o
308
+ pedido é recusado). Nada de "s/N": confirmar exige ter o celular na mão. Só então o PC
309
+ **grava** o aparelho (e publica nova lista, seção 7.4) e o hub chama
310
+ `responder(pid, "aceito")`. Na entrega 2 (PC com terminais), a confirmação também exigirá
311
+ interação do sistema (§0.1).
312
+ - Se alguém que fotografou o QR chegar primeiro, o código que ele mostra não é o do celular
313
+ da pessoa; o celular legítimo, ao mandar o pedido, recebe `usado` (§3.4) e mostra "Outro
314
+ aparelho usou este convite — recuse no PC"; o PC também avisa que outro aparelho tentou.
315
+ A pessoa recusa e gera outro QR.
316
+ - Cada pareamento aceito gera o evento `hub.aparelhoNovo { nome, impressao }` para os
317
+ aparelhos já pareados (na hora, para quem tem sessão; na próxima sessão, para quem não tem,
318
+ enquanto o hub não reiniciar). A PWA mostra o aviso até a pessoa dispensar.
319
+ - Prazo para decidir: **até o vencimento do convite (`exp`)**. O nó encerra a decisão em
320
+ `exp`, com ou sem pedido aguardando (nada é gravado; decidir depois = `E_PAREAMENTO_ESTADO`).
321
+ Motivo: o pedido só entra até `exp`, e a conexão anônima do celular vive 6 min a partir do
322
+ primeiro `parear.enviar` no relay (§7.2), então qualquer resposta dada até `exp` ainda
323
+ encontra o celular (folga ≥ 1 min). `LIMITES.DECISAO_MS` (5 min) fica só como teto do
324
+ protocolo e tolerância de relógio: o registro do nó e o aparelho não aceitam resposta depois
325
+ de `exp + DECISAO_MS`.
326
+ - No máximo 4 convites vigentes ao mesmo tempo por nó (`E_LIMITE`).
327
+
328
+ ### 3.7 Transporte do pareamento pelo relay
329
+
330
+ O aparelho ainda não tem identidade registrada; ele usa uma conexão **anônima** (seção 7.2).
331
+
332
+ ```
333
+ nó → relay { t:"relay.parear.abrir", pid, exp } abre o pid (exp ≤ agora + 5 min)
334
+ aparelho → relay { t:"parear.enviar", pid, dados:<quadro parear.pedido em JSON> }
335
+ relay → nó { t:"parear.chegou", pid, canal, dados } canal = ID aleatório do relay
336
+ nó → relay { t:"parear.responder", canal, dados:<quadro parear.resposta> }
337
+ relay → aparelho{ t:"parear.chegou", pid, dados }
338
+ nó → relay { t:"relay.parear.fechar", pid } ao terminar ou cancelar
339
+ ```
340
+
341
+ O relay só aceita `parear.enviar` para `pid` aberto e não vencido, no máximo
342
+ `PAREAR_FALHAS_MAX` (5) por `pid`, e fecha o `pid` no vencimento.
343
+
344
+ ---
345
+
346
+ ## 4. Sessão: aperto de mão
347
+
348
+ Três mensagens, iniciadas pelo aparelho, levadas em `rota.dados`. Ambos os lados usam chaves
349
+ ECDH P-256 **efêmeras** e assinam o mesmo transcript com a chave de longo prazo.
350
+
351
+ ```
352
+ 1. aparelho → nó { t:"sessao.ola", v:1, sid, aparelho:<ID>, no:<ID>, eA }
353
+ 2. nó → aparelho { t:"sessao.resposta", v:1, sid, eN, assinatura:Sig_nó(rotulo("sessao/no") || th) }
354
+ 3. aparelho → nó { t:"sessao.confirma", v:1, sid, assinatura:Sig_aparelho(rotulo("sessao/aparelho") || th) }
355
+ ```
356
+
357
+ - `sid`: 16 bytes aleatórios do aparelho (22 caracteres base64url).
358
+ - `eA`, `eN`: chave pública ECDH efêmera, formato `raw` não comprimido (65 bytes, começa
359
+ por `0x04`), em base64url. Ponto fora da curva = `E_CHAVE`.
360
+ - Transcript:
361
+
362
+ ```
363
+ th = SHA-256(canonico({ t:"ts-hub/1/sessao", v:1, sid,
364
+ aparelho, aparelhoSpki, no, noSpki, eA, eN }))
365
+ ```
366
+
367
+ Inclui os **SPKI completos** dos dois lados (não só os IDs truncados): a sessão fica
368
+ amarrada às duas identidades e às duas efêmeras. Rótulos distintos por signatário impedem
369
+ refletir a assinatura de um lado como se fosse do outro.
370
+
371
+ **Nó, ao receber `sessao.ola`**: balde de fichas **global** do hub para olás (10/s, rajada
372
+ 20; cada olá custa ECDH + assinatura antes de o aparelho provar a posse da chave): sem ficha,
373
+ descarta em silêncio (log agregado); esquema e versão; `no` é o próprio ID (`E_SESSAO`); se o
374
+ relay informou o remetente (`rota.de`), ele **DEVE** ser igual a `aparelho` (`E_SESSAO`);
375
+ aparelho na lista local de autorizados (`E_APARELHO_NAO_AUTORIZADO`); ID confere com o SPKI
376
+ guardado (`E_ID`); `eA` válida. Gera `eN`, calcula o segredo ECDH, `th`, assina, deriva as
377
+ chaves (4.1) e descarta a efêmera e o segredo ECDH.
378
+
379
+ **Aparelho, ao receber `sessao.resposta`**: no máximo 30 s depois do olá
380
+ (`E_HANDSHAKE_EXPIRADO`); `sid` confere; assinatura do nó com a chave gravada no
381
+ pareamento (`E_ASSINATURA`). Deriva as chaves, assina `th`, manda `sessao.confirma` e já
382
+ pode cifrar.
383
+
384
+ **Nó, ao receber `sessao.confirma`**: no máximo 30 s depois da resposta; `sid` confere;
385
+ aparelho **ainda** autorizado (pode ter sido revogado no meio); assinatura do aparelho
386
+ (`E_ASSINATURA`). Só então a sessão existe do lado do nó. O nó **NÃO DEVE** aceitar nem
387
+ enviar quadro `dados` antes disso.
388
+
389
+ Os estados intermediários são de uso único (repetir a mesma resposta ou confirmação =
390
+ `E_SESSAO`). Um olá repetido pelo relay gera nova `eN`, novo `th`, e a confirmação antiga
391
+ deixa de valer: não há cache de repetição a manter.
392
+
393
+ ### 4.1 Chaves da sessão
394
+
395
+ ```
396
+ z = ECDH(efêmera própria, efêmera do outro) 32 bytes
397
+ K_a2n = HKDF(ikm=z, salt=th, info="ts-hub/1/sessao/a2n", 32) aparelho → nó
398
+ K_n2a = HKDF(ikm=z, salt=th, info="ts-hub/1/sessao/n2a", 32) nó → aparelho
399
+ ```
400
+
401
+ As chaves viram `CryptoKey` AES-GCM **não exportáveis** e as cópias em bytes são zeradas.
402
+
403
+ ### 4.2 Sigilo futuro
404
+
405
+ As privadas efêmeras são geradas não exportáveis e descartadas assim que `z` é calculado; `z`
406
+ é zerado depois da derivação. Vazar depois a chave de longo prazo de um dos lados não abre
407
+ sessões passadas. (JavaScript não garante apagar memória; o código zera o que controla e não
408
+ guarda referências.)
409
+
410
+ ### 4.3 Vida da sessão
411
+
412
+ - No máximo **12 h** (`E_SESSAO_EXPIRADA`) e 2³¹−1 quadros por direção
413
+ (`E_LIMITE_SESSAO`). Depois, novo aperto de mão.
414
+ - Reconexão ao relay = nova sessão (novo aperto de mão). Não há retomada.
415
+ - **Uma sessão por aparelho em cada nó**: o relay mantém uma só conexão por ID (a nova fecha a
416
+ anterior com 4001) e não avisa o nó quando o aparelho cai; por isso o nó, ao concluir uma
417
+ sessão nova, encerra as anteriores do mesmo aparelho. O aparelho que recebe 4001 (outra
418
+ aba/janela dele assumiu) não reconecta sozinho — só quando volta a ser usado — para as
419
+ abas não se derrubarem em ciclo.
420
+
421
+ ---
422
+
423
+ ## 5. Cifra dos quadros de sessão
424
+
425
+ ```
426
+ { t:"dados", sid, n, ct }
427
+ nonce = direção (4 bytes, big-endian: 1 = aparelho→nó, 2 = nó→aparelho) || n (8 bytes, big-endian)
428
+ AAD = rotulo("dados") || sid (16 bytes) || direção (1 byte) || n (8 bytes, big-endian)
429
+ ct = AES-256-GCM(K_direção, nonce, UTF-8(JSON do texto claro), AAD) (inclui a etiqueta de 16 bytes)
430
+ ```
431
+
432
+ - `n` começa em 0 em cada direção e sobe de 1 em 1. Como o WebSocket entrega em ordem, o
433
+ receptor exige `n` **exatamente igual** ao próximo esperado.
434
+ - **Qualquer** anomalia derruba a sessão de vez e apaga as chaves: `n` repetido, saltado ou
435
+ fora de ordem (`E_CONTADOR`), falha de autenticação (`E_DECIFRAR`), `sid` trocado ou
436
+ quadro malformado (`E_FORMATO`), texto claro que não é objeto JSON. Depois disso, tudo
437
+ falha com `E_SESSAO_DERRUBADA`; o aparelho refaz o aperto de mão.
438
+ - Nonce nunca se repete: chave por direção + contador estritamente crescente. A direção no
439
+ nonce é defesa extra contra reflexão (a chave já é outra).
440
+ - Texto claro ≤ **32 KiB** (`E_TAMANHO`), para o quadro caber em 64 KiB depois de base64url.
441
+ - Cifrar e decifrar são serializados por sessão: o contador é reservado e os resultados saem
442
+ na ordem das chamadas. O chamador **DEVE** enviar os quadros na ordem em que foram
443
+ produzidos.
444
+
445
+ ---
446
+
447
+ ## 6. Mensagens de aplicação
448
+
449
+ Texto claro dos quadros `dados`. O campo `k` diz o tipo.
450
+
451
+ ### 6.1 Comando (aparelho → nó)
452
+
453
+ ```
454
+ { k:"cmd", env:{ sessao:<sid>, no:<ID do nó>, seq, metodo, params, ts }, assinatura }
455
+ assinatura = ECDSA(chave do aparelho, rotulo("comando") || canonico(env))
456
+ ```
457
+
458
+ - `no`: ID do nó da sessão. Amarra a assinatura do comando ao nó (além do `sid`): um envelope
459
+ assinado para um nó nunca vale em outro.
460
+
461
+ - `seq`: inteiro ≥ 1, estritamente crescente dentro da sessão (o módulo usa 1, 2, 3…).
462
+ `aparelhoCriarComando` serializa por sessão a reserva do `seq`, a assinatura e a cifra:
463
+ com chamadas simultâneas, a ordem dos `seq` é a mesma dos contadores `n` dos quadros, e as
464
+ promessas resolvem na ordem das chamadas (enviar cada quadro quando a sua promessa resolve
465
+ mantém a ordem). Comando recusado localmente não gasta `seq`.
466
+ - `ts`: relógio do aparelho.
467
+ - O envelope é assinado **além** da cifra: o hub verifica com a chave do aparelho guardada
468
+ localmente, então nem o relay, nem quem tiver a chave do nó, nem um defeito no transporte
469
+ consegue produzir um comando.
470
+
471
+ **Verificação no hub (ordem e consequência):**
472
+
473
+ | # | Checagem | Falha | Sessão |
474
+ |---|---|---|---|
475
+ | 1 | aparelho ainda autorizado (consulta a cada comando) | `E_APARELHO_NAO_AUTORIZADO` | derruba |
476
+ | 2 | decifrar o quadro (seção 5) | `E_CONTADOR` / `E_DECIFRAR` / `E_FORMATO` | derruba |
477
+ | 3 | esquema `{k,env,assinatura}` e `env` com exatamente 6 campos, JSON canônico possível | `E_FORMATO` | derruba |
478
+ | 4 | `env.sessao` = `sid` da sessão e `env.no` = ID do próprio nó | `E_SESSAO` | derruba |
479
+ | 5 | assinatura com a chave do aparelho | `E_ASSINATURA` | derruba |
480
+ | 6 | `seq` > último aceito | `E_SEQ` | derruba |
481
+ | 7 | `ts` dentro de ±120 s do relógio do hub | `E_TEMPO` | segue |
482
+ | 8 | `metodo` na lista fechada | `E_METODO` | segue |
483
+ | 9 | `params` válidos para o método | `E_PARAMS` | segue |
484
+
485
+ Nos casos 7–9 o erro carrega o `seq` (único dado não sigiloso), para o hub responder
486
+ `{ok:false}` ao comando certo. O `seq` é consumido a partir do passo 6.
487
+
488
+ ### 6.2 Lista fechada da entrega 1
489
+
490
+ | Método | `params` (esquema fechado) | Efeito |
491
+ |---|---|---|
492
+ | `hub.status` | `{}` | estado do nó (no ar, agentes, versão) |
493
+ | `terminals.list` | `{}` | terminais visíveis do nó |
494
+ | `terminal.watch` | `{ terminalId, historico? }` | passa a receber `terminal.tela` (só leitura) |
495
+ | `terminal.unwatch` | `{ terminalId }` | para de receber |
496
+ | `hub.stopAll` | `{ confirmar: true }` | Parar tudo |
497
+
498
+ - `terminalId`: `^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`.
499
+ - `historico`: inteiro 0–200 (`LIMITES.HISTORICO_MAX`; linhas de histórico iniciais).
500
+ - Evento `terminal.tela` → `{ terminalId, colunas, linhas, tela }`: a tela inteira de novo
501
+ (texto com escapes SGR de cor), só quando mudou. A tela vai **num único quadro**: o limite
502
+ é o texto claro de §5 (32 KiB, contando o JSON e os escapes). Se não couber, o nó corta o
503
+ histórico **mais antigo** (linhas do topo; em último caso, o começo da última linha) até
504
+ caber. `terminal.fim` → `{ terminalId }` quando o terminal some; `hub.estado` →
505
+ `{ parado }`. Resultados e eventos detalhados: `CONTRATO-ENTREGA1.md`.
506
+ - Terminal que não existe (ou sumiu) no nó: `E_TERMINAL_INEXISTENTE` (a sessão segue).
507
+ - `confirmar` **DEVE** ser o booleano `true` (proteção contra toque acidental ou cliente
508
+ com defeito).
509
+ - `hub.status` → `{ nome, versao, so, tempoNoArSeg, parado, pararDisponivel, terminais }`.
510
+ `pararDisponivel:false` = o nó não tem consumidor do "Parar tudo" (ex.: o PC); a PWA
511
+ desabilita o botão para ele.
512
+ - `hub.stopAll`: o hub grava um **pedido** (diretório fixo, `O_EXCL`, sem seguir link) e só
513
+ responde `{ parado:true }` depois da **confirmação** de um consumidor externo (na VPS, uma
514
+ unidade de root; exemplos em `cli/hub/vps/`), em até 10 s. Sem confirmação:
515
+ `E_PARAR_SEM_CONFIRMACAO` (a PWA mostra "não confirmado"). Sem consumidor:
516
+ `E_PARAR_INDISPONIVEL`. `parado` (em `hub.status` e `hub.estado`) reflete a confirmação,
517
+ nunca a existência do pedido.
518
+ - Telas: o hub não fala com o tmux. `ts hub exportar-telas` (rodando como o usuário dos
519
+ agentes) grava instantâneos só das janelas permitidas, com no máximo 200 linhas de
520
+ histórico, só SGR e segredos conhecidos mascarados; o hub só lê esses arquivos.
521
+ - Evento `hub.aparelhoNovo` → `{ nome, impressao }` (§3.6).
522
+ - Qualquer outro método = `E_METODO`. Nunca pelo celular: parear, mudar agentes,
523
+ permissões, drivers ou credenciais, digitar no terminal (fica para entrega futura, com
524
+ biometria).
525
+ - O módulo exporta `METODOS` (lista) e `validarParametros(metodo, params)`.
526
+
527
+ ### 6.3 Resposta e evento (nó → aparelho)
528
+
529
+ ```
530
+ { k:"resp", seq, ok:true, resultado } resultado: qualquer JSON (null se vazio)
531
+ { k:"resp", seq, ok:false, erro:{ codigo } } codigo: ^E_[A-Z_]{1,40}$ — nunca texto livre
532
+ { k:"evento", tipo, dados } tipo: ^[a-z][a-zA-Z0-9._]{0,63}$ (ex.: terminal.tela)
533
+ ```
534
+
535
+ Respostas e eventos não levam assinatura própria: a chave de sessão só existe nas duas
536
+ pontas e o nó já provou a identidade no aperto de mão. O aparelho recusa `seq` que ele não
537
+ enviou (derruba a sessão).
538
+
539
+ ---
540
+
541
+ ## 7. Relay
542
+
543
+ ### 7.1 Conexão
544
+
545
+ - Endpoint: `<relay>/ws` (ex.: `wss://relay.terminalsmart.com.br/ws`), TLS validado.
546
+ - Cada mensagem WebSocket é **texto** com um quadro de controle JSON, ≤ **64 KiB** em bytes
547
+ UTF-8 (`E_TAMANHO`; o relay fecha a conexão).
548
+ - O relay **DEVE**: validar cada quadro com o esquema da sua direção (`lerQuadroRelay`);
549
+ **preencher** `de` a partir da conexão autenticada (o cliente não pode informar `de`);
550
+ tratar `dados` como texto opaco (não analisar, não guardar, não registrar em log); não
551
+ registrar em log nada além de ID, tipo, tamanho, horário e código de erro.
552
+ - Ao abrir a conexão, o relay manda o desafio. Sem `relay.auth` válido em 10 s (ou sem
553
+ `parear.enviar`, para conexão anônima), fecha.
554
+ - Navegador: o relay só aceita no `/ws` o cabeçalho `Origin` das origens da PWA (a do próprio
555
+ relay quando ele serve a PWA; mais `TS_RELAY_ORIGENS_PWA`). Cliente sem `Origin` (o hub)
556
+ passa.
557
+
558
+ ### 7.2 Papéis
559
+
560
+ | Papel | Como se obtém | Pode enviar |
561
+ |---|---|---|
562
+ | `no` | `relay.auth` com `papel:"no"` | `relay.lista`, `rota`, `relay.parear.abrir`, `relay.parear.fechar`, `parear.responder`, `relay.ping` |
563
+ | `aparelho` | `relay.auth` com `papel:"aparelho"` **e** ID presente na lista vigente de pelo menos um nó | `rota` (só para nós cuja lista o contém), `relay.ping` |
564
+ | anônimo | primeiro quadro `parear.enviar` | só `parear.enviar`; 6 quadros/min; fechada em 6 min |
565
+
566
+ ### 7.3 Desafio-resposta (autenticação de nó e aparelho)
567
+
568
+ ```
569
+ relay → cliente { t:"relay.desafio", v:1, nonce:<32 bytes aleatórios>, relay:<origem do relay> }
570
+ cliente → relay { t:"relay.auth", v:1, papel, id, spki, assinatura }
571
+ assinatura = ECDSA(chave do cliente, rotulo("relay/auth") ||
572
+ canonico({ t:"relay.auth", v:1, papel, id, nonce, relay }))
573
+ relay → cliente { t:"relay.ok", papel, id } ou { t:"relay.erro", codigo }
574
+ ```
575
+
576
+ - O cliente **DEVE** conferir que `relay` do desafio é o relay ao qual quis se conectar
577
+ (`E_DESAFIO`), para nunca assinar autenticação para outro relay.
578
+ - O relay confere: desafio desta conexão, não usado, com no máximo 30 s (`E_DESAFIO`);
579
+ `id` = ID do `spki` (`E_ID`); assinatura (`E_ASSINATURA`). `papel` está dentro do que é
580
+ assinado.
581
+ - **Registro do nó**: o ID é autocertificado (hash da própria chave). A primeira
582
+ autenticação de um nó cria o registro dele no relay (ID + SPKI); as seguintes só
583
+ confirmam. O relay **PODE** restringir os nós aceitos (lista `TS_RELAY_NOS`, obrigatória
584
+ em produção); nó fora dela = `relay.erro {codigo:"E_NO_NAO_AUTORIZADO"}` e fecha.
585
+ - **Aparelho**: além da assinatura, o relay exige que o ID esteja na lista vigente de algum
586
+ nó, com o **mesmo SPKI** que está na lista.
587
+
588
+ ### 7.4 Lista de aparelhos autorizados (assinada pelo nó)
589
+
590
+ ```
591
+ { t:"relay.lista",
592
+ lista:{ no:<ID do nó>, versao, emitidaEm, aparelhos:[ { id, spki }, … ] },
593
+ assinatura:ECDSA(chave do nó, rotulo("lista-aparelhos") || canonico(lista)) }
594
+ ```
595
+
596
+ - `versao`: inteiro ≥ 1, **estritamente crescente**. Lista com versão menor ou igual à
597
+ guardada = `E_LISTA_VERSAO` (o relay não aceita lista velha reapresentada).
598
+ - `aparelhos`: no máximo 50, ordenados por `id`, sem repetição, cada `id` = ID do `spki`
599
+ (`E_LISTA_INVALIDA`).
600
+ - O relay confere a assinatura com o SPKI do nó **autenticado** na conexão e exige
601
+ `lista.no` = esse nó.
602
+ - **Revogar** = publicar nova versão sem o aparelho. Ao aceitar a lista, o relay derruba as
603
+ conexões de aparelho que saíram dela (para aquele nó) e passa a recusar `rota` entre eles.
604
+ - O hub mantém a própria lista (fonte da verdade) e corta as sessões do aparelho revogado
605
+ na hora, sem depender do relay. A lista no relay é defesa em profundidade e serve para o
606
+ relay recusar aparelhos desconhecidos antes de gastar recursos com eles.
607
+ - O relay guarda só a última lista (com assinatura) de cada nó. Nada mais.
608
+
609
+ ### 7.5 Roteamento e presença
610
+
611
+ ```
612
+ cliente → relay { t:"rota", para:<ID>, dados:<texto ≤ 61 440 caracteres> }
613
+ relay → destino { t:"rota", de:<ID do remetente autenticado>, dados }
614
+ relay → aparelho { t:"relay.presenca", no:<ID>, online:<booleano> } nós cuja lista contém o aparelho
615
+ cliente → relay { t:"relay.ping" } relay → cliente { t:"relay.pong" }
616
+ ```
617
+
618
+ `dados` carrega os quadros fim a fim (`sessao.*`, `dados`, `parear.*`) como texto JSON. O
619
+ relay só repassa entre nó e aparelho que se autorizam pela lista vigente; nó↔nó e
620
+ aparelho↔aparelho são recusados.
621
+
622
+ ### 7.6 Tabela de quadros de controle
623
+
624
+ | Direção | `t` | Campos (além de `t`) |
625
+ |---|---|---|
626
+ | para o relay | `relay.auth` | `v, papel, id, spki, assinatura` |
627
+ | para o relay | `relay.lista` | `lista, assinatura` |
628
+ | para o relay | `relay.parear.abrir` | `pid, exp` |
629
+ | para o relay | `relay.parear.fechar` | `pid` |
630
+ | para o relay | `parear.enviar` | `pid, dados` |
631
+ | para o relay | `parear.responder` | `canal, dados` |
632
+ | para o relay | `rota` | `para, dados` |
633
+ | para o relay | `relay.ping` | — |
634
+ | do relay | `relay.desafio` | `v, nonce, relay` |
635
+ | do relay | `relay.ok` | `papel, id` |
636
+ | do relay | `relay.erro` | `codigo` |
637
+ | do relay | `relay.presenca` | `no, online` |
638
+ | do relay | `rota` | `de, dados` |
639
+ | do relay | `parear.chegou` | `pid, dados` e, só para o nó, `canal` |
640
+ | do relay | `relay.pong` | — |
641
+
642
+ ---
643
+
644
+ ## 8. Limites
645
+
646
+ | Limite | Valor | Onde |
647
+ |---|---|---|
648
+ | Mensagem WebSocket (quadro de controle) | 64 KiB (65 536 bytes UTF-8) | relay e pontas |
649
+ | Histórico pedido em `terminal.watch` | 0–200 linhas | aparelho e nó |
650
+ | Respostas `usado` por convite | 4 | nó |
651
+ | `sessao.ola` no hub (global) | 10/s, rajada 20 | nó |
652
+ | Reabertura de sessão no aparelho | recuo até 60 s; no máximo 6 apertos de mão por nó por minuto (a presença não zera) | aparelho |
653
+ | Conexões não autenticadas no relay | 200 (de 2000), vagas reservadas para autenticados; cheio, sai a pendente mais antiga | relay |
654
+ | Conexões por IP no relay | 32 (IPv6 por /64) e 128 por /48 | relay |
655
+ | Campo `dados` | 61 440 caracteres | relay e pontas |
656
+ | Texto claro de quadro de sessão | 32 KiB | pontas |
657
+ | Taxa por conexão autenticada | 30 quadros/s (rajada 60) e 256 KiB/s (rajada 512 KiB) | relay |
658
+ | Conexão anônima de pareamento | 6 quadros/min, 8 KiB/s; fechada em 6 min | relay |
659
+ | Validade do convite | 5 min; decisão no PC até o vencimento | nó |
660
+ | Convites vigentes por nó | 4 | nó |
661
+ | Pedidos que não decifram por convite | 5 | nó e relay |
662
+ | Tolerância de relógio (`ts`) | ±120 s | nó e aparelho |
663
+ | Aperto de mão | 30 s por etapa | pontas |
664
+ | Vida da sessão | 12 h ou 2³¹−1 quadros por direção | pontas |
665
+ | Validade do desafio do relay | 30 s, uso único | relay |
666
+ | Aparelhos por lista | 50 (o plano grátis limita a 3 no hub) | nó e relay |
667
+ | Nome do aparelho | 1–40 caracteres | nó e aparelho |
668
+ | Profundidade de JSON | 16 | todos |
669
+
670
+ Excedeu a taxa: `relay.erro {codigo:"E_TAXA"}` e o relay **PODE** fechar a conexão. O
671
+ módulo traz `LimitadorTaxa` (balde de fichas), `criarLimitesConexao(papel)` e `exigirTaxa`.
672
+
673
+ ---
674
+
675
+ ## 9. Códigos de erro
676
+
677
+ Todos os erros são `TsProtoErro` com `codigo` estável e mensagem fixa em português, **sem
678
+ eco** do dado recebido nem de segredo. `JSON.stringify(erro)` = `{"codigo":…}` (mais `seq`,
679
+ quando for erro de comando). Pelo fio, só o código.
680
+
681
+ | Código | Significado |
682
+ |---|---|
683
+ | `E_FORMATO` | quadro ou campo fora do esquema |
684
+ | `E_TAMANHO` | acima do limite de tamanho |
685
+ | `E_VERSAO` | versão não suportada |
686
+ | `E_ARGUMENTO` | uso incorreto da API (erro de programação local) |
687
+ | `E_CRIPTO_INDISPONIVEL` | sem WebCrypto (ex.: página fora de contexto seguro) |
688
+ | `E_CHAVE` | chave pública/privada inválida, ponto fora da curva, SPKI fora do formato |
689
+ | `E_CHAVE_EXPORTAVEL` | tentativa de usar chave de aparelho exportável |
690
+ | `E_ID` | ID não corresponde à chave |
691
+ | `E_ASSINATURA` | assinatura inválida |
692
+ | `E_DECIFRAR` | AES-GCM não autenticou |
693
+ | `E_CONVITE_INVALIDO` | URL de QR malformada |
694
+ | `E_CONVITE_DESCONHECIDO` | `pid` desconhecido (nunca existiu, vencido e apagado, ou descartado) |
695
+ | `E_CONVITE_EXPIRADO` | convite vencido |
696
+ | `E_CONVITE_USADO` | convite já consumido |
697
+ | `E_PAREAMENTO_ESTADO` | resposta de pareamento fora de ordem ou após o fim |
698
+ | `E_NOME` | nome do aparelho inválido |
699
+ | `E_LIMITE` | limite de convites vigentes |
700
+ | `E_HANDSHAKE_EXPIRADO` | etapa do aperto de mão depois de 30 s |
701
+ | `E_APARELHO_NAO_AUTORIZADO` | aparelho não pareado ou revogado |
702
+ | `E_SESSAO` | `sid` ou estado de sessão inválido |
703
+ | `E_SESSAO_DERRUBADA` | sessão já derrubada |
704
+ | `E_SESSAO_EXPIRADA` | sessão passou de 12 h |
705
+ | `E_CONTADOR` | quadro repetido, saltado ou fora de ordem |
706
+ | `E_LIMITE_SESSAO` | contador de quadros esgotado |
707
+ | `E_SEQ` | `seq` de comando não crescente |
708
+ | `E_TEMPO` | `ts` fora de ±120 s |
709
+ | `E_METODO` | método fora da lista fechada |
710
+ | `E_PARAMS` | parâmetros inválidos |
711
+ | `E_DESAFIO` | desafio do relay inválido, vencido, já usado ou de outro relay |
712
+ | `E_LISTA_VERSAO` | lista com versão não maior que a atual |
713
+ | `E_LISTA_INVALIDA` | lista malformada |
714
+ | `E_TAXA` | limite de taxa |
715
+ | `E_NO_NAO_AUTORIZADO` | relay: nó fora da lista de nós permitidos (`TS_RELAY_NOS`) |
716
+ | `E_TERMINAL_INEXISTENTE` | hub: `terminalId` válido, mas o terminal não existe (ou sumiu) |
717
+ | `E_PARAR_SEM_CONFIRMACAO` | hub: "Parar tudo" pedido, mas o consumidor não confirmou no prazo (ou falhou) |
718
+ | `E_PARAR_INDISPONIVEL` | hub: o nó não tem consumidor do "Parar tudo" (`pararDisponivel:false`) |
719
+ | `E_INTERNO` | código desconhecido (não deveria acontecer) |
720
+
721
+ Códigos novos podem ser **acrescentados**; os existentes não mudam de significado.
722
+
723
+ ---
724
+
725
+ ## 10. Versionamento
726
+
727
+ - A versão aparece em três lugares: prefixo do QR (`#v2.`/`#v1.`), campo `v` dos quadros
728
+ (`relay.desafio`, `relay.auth`, `parear.*`, `sessao.*`) e prefixo de todos os rótulos
729
+ (`ts-hub/1/`). O número do QR é a versão do **formato do convite** (§3.1), independente dos
730
+ outros dois: o v2 do QR muda só a codificação da URL e segue no protocolo 1 (`v: 1`,
731
+ `ts-hub/1/`). Mudar o significado de qualquer campo, rótulo, primitiva ou ordem de
732
+ verificação = **nova versão** (2), com prefixo `ts-hub/2/`.
733
+ - Versão desconhecida é recusada com `E_VERSAO`; nunca se tenta "o mais parecido". Não há
734
+ negociação para baixo (sem ataque de rebaixamento): quando existir a v2, o aparelho manda
735
+ o `sessao.ola` da maior versão que conhece e o nó responde `E_VERSAO` se não a suportar;
736
+ o aparelho então mostra "atualize o hub".
737
+ - Acrescentar um método à lista fechada não muda a versão do protocolo (muda a lista
738
+ exportada e a validação), mas exige atualizar hub e PWA juntos.
739
+ - Campos novos em quadros existentes **não** são tolerados (esquema fechado): exigem nova
740
+ versão.
741
+
742
+ ---
743
+
744
+ ## 11. API do módulo (`ts-proto.js`)
745
+
746
+ Carregar: `require('./hub/proto/ts-proto.js')` no Node 22+, ou `<script src="ts-proto.js">`
747
+ no navegador (expõe `globalThis.TsProto`; servir com `charset=utf-8`; exige contexto seguro
748
+ para `crypto.subtle`). Todas as funções aceitam `{ agora }` (ms) para testes; o padrão é
749
+ `Date.now()`.
750
+
751
+ | Área | Funções |
752
+ |---|---|
753
+ | Codificação | `b64u`, `deB64u`, `hex`, `canonico`, `verificarTamanhoQuadro`, `lerQuadroFimAFim` |
754
+ | Primitivas | `sha256`, `hkdf` |
755
+ | Identidade | `gerarIdentidade({tipo})`, `identidadeDeChaves`, `exportarIdentidadeNo`, `importarIdentidadeNo`, `idDeSpki`, `impressaoDeSpki`, `comprimirSpki`, `descomprimirSpki` (ponto SEC1 do convite v2) |
756
+ | Pareamento (nó) | `new RegistroPareamentos({identidade})` → `criarConvite({relay, pwa?})`, `abrirPedido`, `responderUsado`, `responder`, `cancelar`, `pendentes` |
757
+ | Pareamento (aparelho) | `lerConvite(url, {origemPwa?, relay?})`, `criarPedidoPareamento`, `abrirRespostaPareamento`, `normalizarNome`, `validarOrigemPwa` |
758
+ | Sessão | `aparelhoIniciarSessao`, `noResponderSessao`, `aparelhoConcluirSessao`, `noConcluirSessao`; `Sessao#cifrar`, `#decifrar`, `#derrubar`, `#derrubada` |
759
+ | Comandos | `METODOS`, `validarParametros`, `aparelhoCriarComando`, `noAbrirComando`, `noResponder`, `noEnviarEvento`, `aparelhoAbrirMensagem` |
760
+ | Relay | `relayCriarDesafio`, `clienteResponderDesafio`, `relayVerificarAuth`, `noAssinarListaAparelhos`, `verificarListaAparelhos`, `validarQuadroRelay`, `lerQuadroRelay`, `LimitadorTaxa`, `criarLimitesConexao`, `exigirTaxa` |
761
+ | Constantes | `VERSAO`, `LIMITES`, `CODIGOS`, `TsProtoErro` |
762
+
763
+ Segredos nunca ficam em propriedades enumeráveis: o segredo do convite e as chaves de
764
+ pareamento/sessão ficam em `WeakMap` ou em `CryptoKey` não exportável, para não vazarem por
765
+ `JSON.stringify` ou `console.log`. O módulo não escreve em log.
766
+
767
+ ---
768
+
769
+ ## 12. Notas de segurança para as próximas etapas
770
+
771
+ - **Hub (etapa 3)**: guardar a lista de aparelhos autorizados (ID, SPKI, nome, data) fora
772
+ do alcance dos agentes; consultar `aparelhoAutorizado` em todo comando; ao revogar,
773
+ derrubar as sessões do aparelho, registrar a revogação fora do `aparelhos.json` e publicar
774
+ nova lista; confirmar o pareamento digitando no PC o código mostrado no celular; nunca
775
+ registrar a URL do convite (`ts hub parear` só mostra o QR; a URL só com `--mostrar-url`).
776
+ - **Origem da PWA**: quem a controla controla o cliente (§0.1). Servir de origem dedicada,
777
+ sem scripts de terceiros, e com a CSP liberando só o relay em `connect-src`.
778
+ - **PC (entrega 2)**: qualquer processo do mesmo usuário alcança o controle local; antes de
779
+ habilitar terminais no PC, a confirmação do pareamento exigirá Windows Hello (ou outra
780
+ interação do sistema) no processo que decide (§0.1).
781
+ - **Backup**: restaurar `aparelhos.json` antigo é detectado (`lista-versao.json`) e não
782
+ reautoriza revogados (`revogados.jsonl`); restaurar o diretório inteiro desfaz isso (§0.1).
783
+ - **Relay (etapa 2)**: aplicar a tabela de papéis (7.2), sobrescrever `de`, limitar taxa e
784
+ tamanho antes de analisar JSON, guardar só ID/SPKI dos nós e a última lista de cada um.
785
+ - **PWA (etapa 4)**: ler o fragmento e apagá-lo do histórico; gerar a chave com
786
+ `gerarIdentidade({tipo:'aparelho'})` e guardar o `CryptoKey` no IndexedDB; CSP estrita.
787
+ - Assinaturas ECDSA não são determinísticas e são maleáveis (`s` ↔ `n−s`); o protocolo
788
+ nunca usa assinatura como identificador nem como proteção contra repetição (para isso
789
+ existem `seq`, contador, nonce de desafio e uso único).