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.
- package/README.md +7 -5
- package/bin/ts.js +344 -196
- package/hub/cli.js +255 -0
- package/hub/controle.js +262 -0
- package/hub/dpapi.js +55 -0
- package/hub/estado.js +274 -0
- package/hub/exportar-telas.js +149 -0
- package/hub/hub.js +717 -0
- package/hub/mascara.js +67 -0
- package/hub/parar.js +151 -0
- package/hub/principal.js +5 -0
- package/hub/proto/PROTOCOLO.md +789 -0
- package/hub/proto/ts-proto.js +1643 -0
- package/hub/relay-conexao.js +200 -0
- package/hub/telas-arquivo.js +130 -0
- package/hub/tmux.js +189 -0
- package/hub/vps/README.md +109 -0
- package/hub/vps/tmux-agentes.conf +5 -0
- package/hub/vps/ts-hub-exportar-telas.service +30 -0
- package/hub/vps/ts-hub-parar-consumir +67 -0
- package/hub/vps/ts-hub-parar.path +16 -0
- package/hub/vps/ts-hub-parar.service +23 -0
- package/hub/vps/ts-hub-parar.tmpfiles.conf +10 -0
- package/hub/vps/ts-hub.service +39 -0
- package/hub/vps/ts-tmux.service +19 -0
- package/lib/acp.js +8 -2
- package/lib/agent.js +183 -85
- package/lib/chat-local.js +139 -0
- package/lib/core.js +336 -23
- package/lib/doctor.js +13 -5
- package/lib/i18n.js +69 -31
- package/lib/ia-local.js +388 -0
- package/lib/intelligence-core.js +6 -1
- package/lib/keyring.js +0 -1
- package/lib/meta.js +8 -8
- package/lib/personal-agent-llm.js +99 -26
- package/lib/sentinela.js +2 -2
- package/lib/tools.js +638 -8
- package/package.json +4 -2
|
@@ -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).
|